Compare commits
11
Commits
b0b558f015
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08b33ef823 | ||
|
|
d6d6177dd9 | ||
|
|
ff67e422a4 | ||
|
|
e607e8cea0 | ||
|
|
b6ed4aad9c | ||
|
|
1fad3393ac | ||
|
|
d0b76dc6cf | ||
|
|
d411b05cae | ||
|
|
037a62e008 | ||
|
|
1c9447bc5d | ||
|
|
5d56c667da |
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
webui/test/fixtures/.fake-state/
|
||||
@@ -1,6 +1,6 @@
|
||||
# Ubuntu Based Kiosk
|
||||
|
||||
**Current Version:** 2.12.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/
|
||||
@@ -54,6 +54,11 @@ chmod +x ubuntu-based-kiosk.sh && ./ubuntu-based-kiosk.sh
|
||||
|
||||
The installer will guide you through configuration during setup.
|
||||
|
||||
> The modular `./install.sh` (see "Modular Management" below) can also
|
||||
> provision a kiosk from scratch now, and has its own Upgrade, as an
|
||||
> alternative to the single-file installer above. `ubuntu-based-kiosk.sh`
|
||||
> remains the more battle-tested path.
|
||||
|
||||
---
|
||||
|
||||
## Offline / Air-Gapped Download
|
||||
@@ -1255,30 +1260,77 @@ terminal menu and the web UI, so they can't drift apart).
|
||||
Settings): the last of the "destructive trio." Composed from every
|
||||
addon's own `*_do_uninstall` helper instead of re-implementing
|
||||
removal a second time — see "Recent Updates (v2.12.0)" below.
|
||||
- `menus/clone_settings.sh` — **Clone Settings** (Advanced): export/apply
|
||||
the portable parts of `config.json` across several kiosks that should
|
||||
share the same settings. New, not a legacy port — deliberately never
|
||||
copies machine-bound credentials (Authelia, WireGuard, Asterisk
|
||||
Intercom); see "Recent Updates (v2.13.0)" below.
|
||||
- `lib/electron.sh` — `electron_install_binary()`: verify/download the
|
||||
Electron binary and fix `chrome-sandbox` permissions. Shared between
|
||||
fresh provisioning and `menus/advanced_electron.sh`'s "Fix blank
|
||||
screen" action — the same repair sequence applies whether the binary
|
||||
never downloaded during the initial `npm install` or went missing
|
||||
later.
|
||||
- `lib/provision.sh` — first-time provisioning: packages, kiosk user,
|
||||
Node.js/Electron, LightDM+Openbox autologin, audio/video/HDMI/
|
||||
power-button hardware setup, firewall, then hands off to
|
||||
`core_settings_menu` and other already-migrated Advanced actions for
|
||||
initial configuration, rather than reimplementing that logic a third
|
||||
time. See "Recent Updates (v2.14.0)" below.
|
||||
- `kiosk-app/` — the Electron app source (`main.js`, `preload.js`, the
|
||||
dialog HTML files, `package.json`, `start.sh`), copied to the kiosk
|
||||
directory during provisioning and re-copied during Upgrade.
|
||||
- `provision/files/` — every other system template file provisioning
|
||||
installs (X11 configs, udev rules, systemd units, the power-button
|
||||
and HDMI-mirroring scripts, polkit rules), laid out mirroring their
|
||||
real destination path, e.g. `provision/files/etc/X11/xorg.conf.d/
|
||||
foo.conf` installs to `/etc/X11/xorg.conf.d/foo.conf`.
|
||||
- `menus/advanced_upgrade.sh` — **Upgrade** (Advanced): `git pull` (only
|
||||
as a clean fast-forward) plus re-running the same
|
||||
packages/kiosk-app/display/firewall/power-management provisioning
|
||||
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, 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. Run it against an
|
||||
*already-installed* kiosk:
|
||||
Settings / Addons / Advanced** like the legacy menu. On a machine
|
||||
with no kiosk installed yet, it provisions one first (see
|
||||
`lib/provision.sh` above); on an already-installed kiosk, it goes
|
||||
straight to the same menus:
|
||||
```bash
|
||||
git clone https://github.com/outis1one/ubuntu-based-kiosk/
|
||||
cd ubuntu-based-kiosk
|
||||
./install.sh
|
||||
```
|
||||
|
||||
**Honest status:** this does not yet replace first-time installation, or
|
||||
most of the old installer. `ubuntu-based-kiosk.sh` is still ~12,000
|
||||
lines and still contains its own unremoved, unmodified copies of every
|
||||
menu above, including the legacy three-option (Client/Server/Full)
|
||||
Easy Asterisk Intercom — the modular version only replaces the Client
|
||||
option, by design (plus Upgrade, Full Reinstall, Export/Import
|
||||
Settings, and Fix Squeezelite Audio — none of that has moved yet;
|
||||
Complete Uninstall *is* now migrated, but Upgrade and Full Reinstall
|
||||
are staying put — both are coupled to this file's own heredoc self-
|
||||
extraction of main.js/preload.js/etc, which has no modular equivalent).
|
||||
**Honest status:** first-time installation and Upgrade are now covered —
|
||||
`install.sh` provisions a kiosk from a bare Ubuntu Server box, not just
|
||||
an already-installed one, and can pull/apply its own updates — but
|
||||
`ubuntu-based-kiosk.sh` is still ~12,000 lines and still contains its
|
||||
own unremoved, unmodified copies of every menu above, including the
|
||||
legacy three-option (Client/Server/Full) Easy Asterisk Intercom — the
|
||||
modular version only replaces the Client option, by design. Full
|
||||
Reinstall is deliberately not being carried forward — it never worked
|
||||
reliably in the legacy script, and the modular tool already covers the
|
||||
same outcome more reliably as two already-tested pieces run back to
|
||||
back: Complete Uninstall (Core Settings), then `./install.sh` again to
|
||||
provision fresh. The legacy Export/Import Settings is also staying
|
||||
as-is; Clone Settings is a new, narrower feature alongside it, not a
|
||||
replacement for it — see "Recent Updates (v2.13.0)" below for why
|
||||
they're not the same thing.
|
||||
Both copies coexist deliberately: the old ones stay until enough of
|
||||
Core Settings/Addons/Advanced is migrated to retire them in one pass,
|
||||
rather than leaving the legacy menu half-wired. Migration continues one
|
||||
`menus/*.sh` file at a time; first-time installation itself is the last
|
||||
and largest piece to move, if it moves at all.
|
||||
rather than leaving the legacy menu half-wired.
|
||||
|
||||
**Resolved (v2.9.0):** `is_service_enabled()` — shared by both scripts
|
||||
— had a pre-check (`systemctl list-unit-files | grep -q "^${service}\s"`)
|
||||
@@ -1305,9 +1357,46 @@ full migration pass.
|
||||
|
||||
## Project Status & Future Plans
|
||||
|
||||
**Current Version:** 2.12.0
|
||||
**Current Version:** 2.17.0
|
||||
|
||||
**Recent Updates (v2.12.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.
|
||||
- Deliberately narrow scope for this first pass: WiFi, Timezone, Power/Display/Quiet Hours, Complete Uninstall, every other addon, and everything in Advanced remain terminal-only — a network-facing process shouldn't be handed `sudo`-level system mutation (netplan, `timedatectl`, `apt`, systemd timers) without a lot more thought than this pass gives it. A "restart kiosk display" action was left out for the same reason — would need a narrow polkit grant, a follow-up.
|
||||
- Wired into Complete Uninstall (`webui_do_uninstall`) and Clone Settings (addon-presence detection) the same way every other addon is.
|
||||
- This is the single-kiosk piece of the web-based GUI mentioned in this doc's "Modular Management" notes for a while — a central multi-kiosk fleet dashboard is an intentional follow-up, not part of this pass.
|
||||
|
||||
**Previous (v2.15.0):**
|
||||
- **New: Upgrade** (Advanced → Upgrade) — not a port of the legacy Upgrade, which re-extracted `main.js`/`preload.js`/etc from its own heredocs on every run. `kiosk-app/` and `provision/files/` are real files in this git checkout now, so the modular Upgrade is `git pull` (after confirming a clean working tree, and only as a fast-forward — never an automatic merge) followed by re-running the same packages/kiosk-app/display/firewall/power-management steps `lib/provision.sh` already has for a fresh install, reused rather than reimplemented. Skips the interactive first-run settings wizard and the "reboot now" prompt.
|
||||
- Also offers an on-demand Electron version check regardless of whether there was code to pull (Electron isn't versioned by this repo) — reuses the existing, already-tested `action_update_electron` as-is.
|
||||
- Requires a real git checkout (not the no-git ZIP download option) and a clean working tree; a diverged local history fails the pull cleanly with a clear message rather than attempting an automatic merge.
|
||||
- **Full Reinstall dropped, not carried forward.** It never worked reliably in the legacy script, and the modular tool already covers the same outcome more reliably as two already-tested pieces run back to back: Complete Uninstall (Core Settings), then `./install.sh` again to provision fresh — no need for a dedicated combined action.
|
||||
|
||||
**Previous (v2.14.0):**
|
||||
- **`./install.sh` now provisions a kiosk from scratch, not just manages an existing one.** Until now it only worked against an already-installed kiosk — `ubuntu-based-kiosk.sh` was still the only path from a bare Ubuntu Server box to a running one. On a machine with no kiosk-app directory yet, it now installs packages, creates the kiosk user, installs Node.js/Electron, sets up LightDM+Openbox autologin, audio/video/HDMI/power-button hardware handling, and the firewall, then hands off to the same Core Settings menus for initial configuration — matching the legacy script's own install-then-configure flow, on the modular codebase.
|
||||
- **New: `lib/provision.sh`**, the provisioning steps — built almost entirely by calling menus already migrated below (`core_settings_menu`, emergency hotspot, virtual consoles) instead of reimplementing that configuration logic a third time. Reuse cut it down to roughly 300 lines against the legacy script's ~4,000-line `first_time_install()`.
|
||||
- **New: `lib/electron.sh`** — `electron_install_binary()`, extracted out of `menus/advanced_electron.sh` so fresh provisioning and the existing "Fix blank screen" action share one implementation instead of two copies of the same repair sequence.
|
||||
- **New: `kiosk-app/`** (the Electron app source — `main.js`, `preload.js`, the dialog HTML files, `package.json`, `start.sh`) and **`provision/files/`** (every other system template file — X11 configs, udev rules, systemd units, the power-button and HDMI-mirroring scripts, polkit rules), extracted byte-for-byte out of `ubuntu-based-kiosk.sh`'s heredocs into real files, laid out mirroring their real destination paths.
|
||||
- **Bug found and fixed while writing this:** a bash `set -e` gotcha where testing a multi-statement function as an if-condition (`if ! some_func; then`) silently exempts everything inside that function from `set -e` for the duration of the call — found via direct testing, then swept for elsewhere in the codebase and also fixed in `menus/advanced_electron.sh`'s pre-existing "Fix blank screen" action, which had the same shape.
|
||||
- **Known, deliberate limitation carried over unchanged:** a few of the extracted system scripts (`start.sh`, `kiosk-hotplug.sh`, the power-button handler) hardcode the username `kiosk` rather than substituting `$KIOSK_USER`, exactly as the legacy script's quoted heredocs always did. Only matters if `$KIOSK_USER` is overridden from its default, which in practice is rare.
|
||||
- Upgrade and Full Reinstall are still not ported — both are coupled to `ubuntu-based-kiosk.sh`'s own heredoc self-extraction, a different mechanism than the new provisioning (which copies real files, not heredocs). `ubuntu-based-kiosk.sh` remains the way to upgrade/reinstall an existing install for now.
|
||||
|
||||
**Previous (v2.13.0):**
|
||||
- **New: Clone Settings** (Advanced → Clone Settings) — not a port of the legacy Export/Import Settings, a narrower MVP for the "set up one kiosk, then stamp out a dozen more like it" use case. Exports the portable parts of `config.json` (sites, display/touch/navigation, lockout, password protection) to a JSON file; applies that file to any other already-installed kiosk.
|
||||
- **Deliberately does not copy machine-bound credentials**, because copying them would be actively wrong: Authelia's encrypted password is keyed off `/etc/machine-id` and decrypts to garbage on another machine; a WireGuard private key is a device identity, and reusing one across machines is a peer conflict, not a saving; most Asterisk PBXes reject two simultaneous registrations to the same extension. Applying a profile prints these as an explicit "needs a human" checklist instead of silently skipping or cloning them.
|
||||
- Records which addons were present at export time and reports which are/aren't present on the target — doesn't install anything itself. Non-interactive addon installation (so applying a profile needs zero prompts — scriptable over SSH to a whole fleet) is a deliberate follow-up, not bundled into this MVP.
|
||||
|
||||
**Previous (v2.12.0):**
|
||||
- **Complete Uninstall migrated** — the last of the "destructive trio." Rather than re-implementing every addon's teardown a second time (the legacy shape — CUPS/VNC/WireGuard/Tailscale/Netbird/LMS/Squeezelite removal all inlined again, independently of each addon's own uninstall action), `menus/complete_uninstall.sh` composes the `*_do_uninstall` helpers each addon already has. Every addon menu with an uninstall action was split into a confirm-and-call wrapper (unchanged from the user's perspective) plus a silent removal helper that both the wrapper and Complete Uninstall call — no duplicated logic anywhere, and if an addon's removal logic changes later, Complete Uninstall picks it up automatically.
|
||||
- **Important bug found and fixed while composing these:** several `*_do_uninstall` helpers (CUPS's `apt autoremove`/`apt clean`, VNC/WireGuard/Tailscale/Netbird's `apt remove`) had a bare, unguarded `apt` call. Previously this only risked aborting that one menu action if the package was already gone. Composed together as sequential calls inside Complete Uninstall, the same failure would have silently truncated the *entire* uninstall partway through — e.g. the kiosk user might never get removed because an already-uninstalled VPN client's `apt remove` failed first. Guarded all of them with `|| true`.
|
||||
- Non-addon teardown (kiosk user/files, Node.js, LightDM/Openbox, remaining systemd units/scripts, polkit rules, re-enabling virtual consoles, final package cleanup) stays inline in `menus/complete_uninstall.sh`, since no single addon owns those paths — same as the legacy script.
|
||||
|
||||
+83
-33
@@ -1,38 +1,56 @@
|
||||
#!/bin/bash
|
||||
################################################################################
|
||||
# install.sh - Modular management entry point for Ubuntu Based Kiosk.
|
||||
# install.sh - Ubuntu Based Kiosk: install and manage, one entry point.
|
||||
#
|
||||
# This is NOT yet the full system installer - that is still the big
|
||||
# single-file script (ubuntu-based-kiosk.sh) documented in Readme.md, and
|
||||
# first-time provisioning of a new kiosk still goes through it. That file
|
||||
# still also contains its own (unmigrated, unmodified) copies of every
|
||||
# menu below - both copies coexist deliberately until enough of Core
|
||||
# Settings/Addons/Advanced has moved over to retire the old ones in one
|
||||
# pass. This entry point is the modular replacement, one menus/*.sh file
|
||||
# at a time, so a change to (say) the Sites menu can't accidentally break
|
||||
# WiFi setup or the uninstaller three thousand lines away.
|
||||
# On a bare Ubuntu Server box with no kiosk installed, this provisions
|
||||
# one (lib/provision.sh) - packages, kiosk user, LightDM/Openbox, the
|
||||
# Electron app, audio/video/power hardware setup - then hands off to the
|
||||
# same Core Settings/Addons/Advanced menus below for initial
|
||||
# configuration. On a machine that already has a kiosk, it skips
|
||||
# straight to those menus. Same entry point either way.
|
||||
#
|
||||
# ubuntu-based-kiosk.sh, the original single-file installer, still
|
||||
# exists and still works, but is no longer the only way to provision a
|
||||
# new kiosk. Upgrade (Advanced -> Upgrade) is now here too, but not a
|
||||
# port of the legacy version - that one re-extracted heredocs on every
|
||||
# run; kiosk-app/ and provision/files/ are real files in this git
|
||||
# checkout, so the modular Upgrade is `git pull` + re-running the same
|
||||
# provisioning steps, reused rather than reimplemented (see
|
||||
# menus/advanced_upgrade.sh). Full Reinstall is deliberately not
|
||||
# carried forward - it never worked reliably in the legacy script, and
|
||||
# the same outcome is already available here, more reliably, as two
|
||||
# already-tested pieces run back to back: Complete Uninstall (Core
|
||||
# Settings), then ./install.sh again to provision fresh.
|
||||
#
|
||||
# Migrated so far, grouped the same way the legacy menu groups them:
|
||||
# Core Settings: Sites & Page Timing, Display & Interaction, Timezone,
|
||||
# Hidden Site PIN, Password Protection & Lockout, WiFi,
|
||||
# Power/Display/Quiet Hours, Complete Uninstall
|
||||
# (menus/complete_uninstall.sh - composed from every addon's own
|
||||
# uninstall helper rather than re-implementing removal a second
|
||||
# time; Upgrade and Full Reinstall stay in the legacy script, both
|
||||
# coupled to its heredoc self-extraction of main.js/preload.js/etc).
|
||||
# uninstall helper rather than re-implementing removal a second time).
|
||||
# Addons: CUPS Printing (menus/addon_cups.sh), Authelia Auto-Login
|
||||
# (menus/addon_authelia.sh), Remote Access - VNC/WireGuard/
|
||||
# Tailscale/Netbird (menus/addon_remote_access.sh), LMS Server /
|
||||
# Squeezelite Player (menus/addon_lms_squeezelite.sh), Asterisk
|
||||
# Intercom - SIP extension client (menus/addon_asterisk_intercom.sh).
|
||||
# Intercom - SIP extension client (menus/addon_asterisk_intercom.sh),
|
||||
# Web UI (menus/addon_webui.sh - browser-based editor for Sites/
|
||||
# Display/Lockout, the webui/ Node app installed as a systemd
|
||||
# service; 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 the file header).
|
||||
# Advanced: Diagnostics (menus/diagnostics.sh - system status/logs/
|
||||
# audio/network), Electron Maintenance (menus/advanced_electron.sh -
|
||||
# manual update, fix blank screen), Factory Reset
|
||||
# (menus/advanced_factory_reset.sh), Virtual Consoles
|
||||
# (menus/advanced_virtual_consoles.sh), Emergency Hotspot
|
||||
# (menus/advanced_emergency_hotspot.sh).
|
||||
# (menus/advanced_emergency_hotspot.sh), Clone Settings
|
||||
# (menus/clone_settings.sh - export/apply portable settings across
|
||||
# several kiosks; deliberately excludes machine-bound credentials
|
||||
# like Authelia/WireGuard/Asterisk Intercom - see the file header),
|
||||
# Upgrade (menus/advanced_upgrade.sh - git pull + re-provision, plus
|
||||
# an on-demand Electron version check).
|
||||
#
|
||||
# Usage (once the kiosk has already been installed):
|
||||
# Usage (works whether or not a kiosk is already installed):
|
||||
# git clone <repo>
|
||||
# cd ubuntu-based-kiosk
|
||||
# ./install.sh
|
||||
@@ -46,6 +64,8 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/lib/menu.sh"
|
||||
# shellcheck source=lib/config.sh
|
||||
source "$SCRIPT_DIR/lib/config.sh"
|
||||
# shellcheck source=lib/electron.sh
|
||||
source "$SCRIPT_DIR/lib/electron.sh"
|
||||
# shellcheck source=menus/sites.sh
|
||||
source "$SCRIPT_DIR/menus/sites.sh"
|
||||
# shellcheck source=menus/display.sh
|
||||
@@ -72,8 +92,15 @@ source "$SCRIPT_DIR/menus/addon_remote_access.sh"
|
||||
source "$SCRIPT_DIR/menus/addon_lms_squeezelite.sh"
|
||||
# shellcheck source=menus/addon_asterisk_intercom.sh
|
||||
source "$SCRIPT_DIR/menus/addon_asterisk_intercom.sh"
|
||||
# shellcheck source=menus/addon_webui.sh
|
||||
source "$SCRIPT_DIR/menus/addon_webui.sh"
|
||||
# shellcheck source=menus/advanced_electron.sh
|
||||
source "$SCRIPT_DIR/menus/advanced_electron.sh"
|
||||
# shellcheck source=menus/advanced_upgrade.sh
|
||||
# Depends on action_update_electron above and the provision_* functions
|
||||
# sourced later (lib/provision.sh) - safe either way, bash resolves
|
||||
# function calls at run time, not source time.
|
||||
source "$SCRIPT_DIR/menus/advanced_upgrade.sh"
|
||||
# shellcheck source=menus/advanced_factory_reset.sh
|
||||
source "$SCRIPT_DIR/menus/advanced_factory_reset.sh"
|
||||
# shellcheck source=menus/advanced_virtual_consoles.sh
|
||||
@@ -81,9 +108,18 @@ source "$SCRIPT_DIR/menus/advanced_virtual_consoles.sh"
|
||||
# shellcheck source=menus/advanced_emergency_hotspot.sh
|
||||
source "$SCRIPT_DIR/menus/advanced_emergency_hotspot.sh"
|
||||
# shellcheck source=menus/complete_uninstall.sh
|
||||
# Sourced last: composes the *_do_uninstall/*_do_remove_all/*_do_disable
|
||||
# helpers defined in every file above it.
|
||||
# Sourced last among menus/*.sh: composes the *_do_uninstall/
|
||||
# *_do_remove_all/*_do_disable helpers defined in every file above it.
|
||||
source "$SCRIPT_DIR/menus/complete_uninstall.sh"
|
||||
# shellcheck source=menus/clone_settings.sh
|
||||
# Also composes detection helpers (*_is_installed) from every addon above.
|
||||
source "$SCRIPT_DIR/menus/clone_settings.sh"
|
||||
# shellcheck source=lib/provision.sh
|
||||
# Sourced last of all: calls into core_settings_menu and the Advanced
|
||||
# actions below during first-time setup, so everything they depend on
|
||||
# must already be defined by the time it actually runs (not merely
|
||||
# sourced - bash resolves function calls at run time either way).
|
||||
source "$SCRIPT_DIR/lib/provision.sh"
|
||||
|
||||
################################################################################
|
||||
# Preflight
|
||||
@@ -104,17 +140,6 @@ if ! command -v jq &>/dev/null; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! is_kiosk_installed; then
|
||||
echo
|
||||
log_error "No installed kiosk found at ${KIOSK_DIR}."
|
||||
echo
|
||||
echo "This tool manages an already-installed kiosk. To provision a new"
|
||||
echo "one for the first time, use the full installer instead - see"
|
||||
echo "Readme.md ('Quick Install') for the current download command."
|
||||
echo
|
||||
exit 1
|
||||
fi
|
||||
|
||||
################################################################################
|
||||
# Top-level menu - grouped the same way the legacy menu groups them
|
||||
# (Core Settings / Addons / Advanced), so the structure stays familiar
|
||||
@@ -149,8 +174,8 @@ core_settings_menu() {
|
||||
}
|
||||
|
||||
addons_menu_builder() {
|
||||
MENU_LABELS=("CUPS Printing" "Authelia Auto-Login" "Remote Access" "LMS Server / Squeezelite Player" "Asterisk Intercom (SIP Extension)")
|
||||
MENU_HANDLERS=(addon_cups_menu addon_authelia_menu remote_access_menu addon_lms_squeezelite_menu addon_asterisk_intercom_menu)
|
||||
MENU_LABELS=("CUPS Printing" "Authelia Auto-Login" "Remote Access" "LMS Server / Squeezelite Player" "Asterisk Intercom (SIP Extension)" "Web UI")
|
||||
MENU_HANDLERS=(addon_cups_menu addon_authelia_menu remote_access_menu addon_lms_squeezelite_menu addon_asterisk_intercom_menu addon_webui_menu)
|
||||
}
|
||||
|
||||
addons_menu() {
|
||||
@@ -158,8 +183,8 @@ addons_menu() {
|
||||
}
|
||||
|
||||
advanced_menu_builder() {
|
||||
MENU_LABELS=("Diagnostics" "Electron Maintenance" "Factory Reset" "Virtual Consoles" "Emergency Hotspot")
|
||||
MENU_HANDLERS=(diagnostics_menu advanced_electron_menu advanced_factory_reset_menu advanced_virtual_consoles_menu advanced_emergency_hotspot_menu)
|
||||
MENU_LABELS=("Diagnostics" "Electron Maintenance" "Factory Reset" "Virtual Consoles" "Emergency Hotspot" "Clone Settings" "Upgrade")
|
||||
MENU_HANDLERS=(diagnostics_menu advanced_electron_menu advanced_factory_reset_menu advanced_virtual_consoles_menu advanced_emergency_hotspot_menu clone_settings_menu advanced_upgrade_menu)
|
||||
}
|
||||
|
||||
advanced_menu() {
|
||||
@@ -175,4 +200,29 @@ main_menu_status() {
|
||||
echo "Managing kiosk at: ${KIOSK_DIR}"
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Provision if there's nothing here yet, otherwise go straight to management.
|
||||
################################################################################
|
||||
|
||||
if ! is_kiosk_installed; then
|
||||
echo
|
||||
echo "No installed kiosk found at ${KIOSK_DIR}."
|
||||
echo "This will provision a new one on this machine."
|
||||
echo
|
||||
# Bare call, not `if run_first_time_install; then ...`: this is a
|
||||
# large multi-step function, and testing it as an if-condition would
|
||||
# exempt every step inside it from set -e for the duration - see the
|
||||
# comment at its own call to provision_install_app for why that
|
||||
# matters. Called bare, a real failure anywhere inside it halts the
|
||||
# whole script immediately (set -e's normal behavior); reaching the
|
||||
# lines below is itself proof every step succeeded. A declined
|
||||
# install prints "Cancelled" from inside the function and returns
|
||||
# non-zero, which the same bare-statement rule turns into a normal
|
||||
# exit here - nothing further to print either way.
|
||||
run_first_time_install
|
||||
echo
|
||||
echo "Run ./install.sh again to manage this kiosk."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
run_menu "UBUNTU BASED KIOSK - MANAGEMENT" main_menu_builder main_menu_status "Exit"
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
background: rgba(0,0,0,0.9);
|
||||
color: #ecf0f1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
padding: 40px;
|
||||
}
|
||||
.container { text-align: center; max-width: 600px; }
|
||||
h2 { font-size: 32px; margin-bottom: 20px; }
|
||||
.message { font-size: 20px; margin-bottom: 20px; line-height: 1.5; }
|
||||
.countdown { font-size: 72px; font-weight: bold; color: #e74c3c; margin: 20px 0; }
|
||||
.options {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 15px;
|
||||
margin: 30px 0;
|
||||
}
|
||||
.btn {
|
||||
padding: 20px 40px;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
font-weight: bold;
|
||||
transition: all 0.2s;
|
||||
color: white;
|
||||
}
|
||||
.btn-primary {
|
||||
background: #27ae60;
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.btn-primary:hover { background: #229954; }
|
||||
.btn-primary:active { background: #1e8449; }
|
||||
|
||||
.btn-extend { background: #3498db; }
|
||||
.btn-extend:hover { background: #2980b9; }
|
||||
.btn-extend:active { background: #21618c; }
|
||||
|
||||
.btn-home {
|
||||
background: #95a5a6;
|
||||
grid-column: 1 / -1;
|
||||
font-size: 14px;
|
||||
padding: 12px;
|
||||
}
|
||||
.btn-home:hover { background: #7f8c8d; }
|
||||
|
||||
.info {
|
||||
font-size: 14px;
|
||||
color: #95a5a6;
|
||||
margin-top: 20px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h2>👋 Are you still here?</h2>
|
||||
<div class="message">
|
||||
No activity detected. Choose an option:
|
||||
</div>
|
||||
<div class="countdown" id="countdown">15</div>
|
||||
|
||||
<div class="options">
|
||||
<button class="btn btn-primary" onclick="imHere(0)">
|
||||
✓ Yes, I'm here!
|
||||
</button>
|
||||
|
||||
<button class="btn btn-extend" onclick="imHere(15)">
|
||||
🍿 15 more minutes
|
||||
</button>
|
||||
|
||||
<button class="btn btn-extend" onclick="imHere(30)">
|
||||
⏱️ 30 more minutes
|
||||
</button>
|
||||
|
||||
<button class="btn btn-extend" onclick="imHere(60)">
|
||||
🎬 1 hour
|
||||
</button>
|
||||
|
||||
<button class="btn btn-extend" onclick="imHere(120)">
|
||||
📺 2 hours
|
||||
</button>
|
||||
|
||||
<button class="btn btn-home" onclick="goHome()">
|
||||
🏠 Return to home now
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="info">
|
||||
ℹ️ Extensions pause the inactivity timer<br>
|
||||
Media playback (video/audio) automatically pauses the timer<br>
|
||||
Maximum extension: 4 hours (safety timeout)
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
const {ipcRenderer} = require('electron');
|
||||
let count = 15;
|
||||
const interval = setInterval(() => {
|
||||
count--;
|
||||
document.getElementById('countdown').textContent = count;
|
||||
if (count <= 0) {
|
||||
clearInterval(interval);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
function imHere(minutes) {
|
||||
clearInterval(interval);
|
||||
console.log('[PROMPT] User selected: '+(minutes===0?'Continue':minutes+' minutes'));
|
||||
ipcRenderer.send('user-still-here', minutes);
|
||||
}
|
||||
|
||||
function goHome() {
|
||||
clearInterval(interval);
|
||||
console.log('[PROMPT] User requested immediate home return');
|
||||
ipcRenderer.send('user-still-here', -1);
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === ' ' || e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
imHere(0);
|
||||
} else if (e.key === 'Escape') {
|
||||
goHome();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,261 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
background: rgba(0, 0, 0, 0.95);
|
||||
color: #ecf0f1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.keyboard { width: 100%; max-width: 1200px; margin: 0 auto; }
|
||||
.row { display: flex; justify-content: center; margin-bottom: 8px; gap: 6px; }
|
||||
.key {
|
||||
min-width: 60px; height: 60px;
|
||||
background: linear-gradient(135deg, #34495e 0%, #2c3e50 100%);
|
||||
border: 2px solid #4a5f7f; border-radius: 8px; color: white;
|
||||
font-size: 24px; font-weight: 600; cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
transition: all 0.1s; user-select: none; box-shadow: 0 4px 8px rgba(0,0,0,0.3);
|
||||
}
|
||||
.key:active {
|
||||
transform: scale(0.95);
|
||||
background: linear-gradient(135deg, #3498db 0%, #2980b9 100%);
|
||||
border-color: #5dade2;
|
||||
}
|
||||
.key.space { flex: 3; }
|
||||
.key.wide { min-width: 90px; }
|
||||
.key.extra-wide { min-width: 120px; }
|
||||
.key.special {
|
||||
background: linear-gradient(135deg, #2c3e50 0%, #1a252f 100%);
|
||||
font-size: 16px;
|
||||
}
|
||||
.key.enter {
|
||||
background: linear-gradient(135deg, #27ae60 0%, #229954 100%);
|
||||
border-color: #52be80;
|
||||
}
|
||||
.key.backspace {
|
||||
background: linear-gradient(135deg, #e74c3c 0%, #c0392b 100%);
|
||||
border-color: #ec7063;
|
||||
}
|
||||
.key.shift, .key.caps {
|
||||
background: linear-gradient(135deg, #f39c12 0%, #d68910 100%);
|
||||
border-color: #f8c471;
|
||||
}
|
||||
.key.shift.active, .key.caps.active {
|
||||
background: linear-gradient(135deg, #16a085 0%, #138d75 100%);
|
||||
border-color: #48c9b0;
|
||||
}
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 10px;
|
||||
font-size: 16px;
|
||||
color: #bdc3c7;
|
||||
}
|
||||
.close-btn {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: rgba(231, 76, 60, 0.9);
|
||||
border: 2px solid #e74c3c;
|
||||
border-radius: 50%;
|
||||
color: white;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
z-index: 9999;
|
||||
}
|
||||
.close-btn:active {
|
||||
transform: scale(0.9);
|
||||
background: rgba(192, 57, 43, 0.9);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="close-btn" onclick="closeKeyboard()">×</div>
|
||||
|
||||
<div class="keyboard">
|
||||
<div class="header">⌨️ Keyboard - Click icon or swipe to reopen</div>
|
||||
|
||||
<!-- Number Row -->
|
||||
<div class="row">
|
||||
<div class="key" data-key="1" data-shift="!" onclick="typeKey(this)">1</div>
|
||||
<div class="key" data-key="2" data-shift="@" onclick="typeKey(this)">2</div>
|
||||
<div class="key" data-key="3" data-shift="#" onclick="typeKey(this)">3</div>
|
||||
<div class="key" data-key="4" data-shift="$" onclick="typeKey(this)">4</div>
|
||||
<div class="key" data-key="5" data-shift="%" onclick="typeKey(this)">5</div>
|
||||
<div class="key" data-key="6" data-shift="^" onclick="typeKey(this)">6</div>
|
||||
<div class="key" data-key="7" data-shift="&" onclick="typeKey(this)">7</div>
|
||||
<div class="key" data-key="8" data-shift="*" onclick="typeKey(this)">8</div>
|
||||
<div class="key" data-key="9" data-shift="(" onclick="typeKey(this)">9</div>
|
||||
<div class="key" data-key="0" data-shift=")" onclick="typeKey(this)">0</div>
|
||||
<div class="key" data-key="-" data-shift="_" onclick="typeKey(this)">-</div>
|
||||
<div class="key" data-key="=" data-shift="+" onclick="typeKey(this)">=</div>
|
||||
<div class="key backspace wide" onclick="typeKey(this)" data-special="Backspace">⌫</div>
|
||||
</div>
|
||||
|
||||
<!-- Top Row -->
|
||||
<div class="row">
|
||||
<div class="key special wide" onclick="typeKey(this)" data-special="Tab">Tab</div>
|
||||
<div class="key" data-key="q" onclick="typeKey(this)">q</div>
|
||||
<div class="key" data-key="w" onclick="typeKey(this)">w</div>
|
||||
<div class="key" data-key="e" onclick="typeKey(this)">e</div>
|
||||
<div class="key" data-key="r" onclick="typeKey(this)">r</div>
|
||||
<div class="key" data-key="t" onclick="typeKey(this)">t</div>
|
||||
<div class="key" data-key="y" onclick="typeKey(this)">y</div>
|
||||
<div class="key" data-key="u" onclick="typeKey(this)">u</div>
|
||||
<div class="key" data-key="i" onclick="typeKey(this)">i</div>
|
||||
<div class="key" data-key="o" onclick="typeKey(this)">o</div>
|
||||
<div class="key" data-key="p" onclick="typeKey(this)">p</div>
|
||||
<div class="key" data-key="[" data-shift="{" onclick="typeKey(this)">[</div>
|
||||
<div class="key" data-key="]" data-shift="}" onclick="typeKey(this)">]</div>
|
||||
<div class="key" data-key="\\" data-shift="|" onclick="typeKey(this)">\</div>
|
||||
</div>
|
||||
|
||||
<!-- Home Row -->
|
||||
<div class="row">
|
||||
<div class="key caps special extra-wide" onclick="toggleCaps()" id="caps-key">Caps</div>
|
||||
<div class="key" data-key="a" onclick="typeKey(this)">a</div>
|
||||
<div class="key" data-key="s" onclick="typeKey(this)">s</div>
|
||||
<div class="key" data-key="d" onclick="typeKey(this)">d</div>
|
||||
<div class="key" data-key="f" onclick="typeKey(this)">f</div>
|
||||
<div class="key" data-key="g" onclick="typeKey(this)">g</div>
|
||||
<div class="key" data-key="h" onclick="typeKey(this)">h</div>
|
||||
<div class="key" data-key="j" onclick="typeKey(this)">j</div>
|
||||
<div class="key" data-key="k" onclick="typeKey(this)">k</div>
|
||||
<div class="key" data-key="l" onclick="typeKey(this)">l</div>
|
||||
<div class="key" data-key=";" data-shift=":" onclick="typeKey(this)">;</div>
|
||||
<div class="key" data-key="'" data-shift='"' onclick="typeKey(this)">'</div>
|
||||
<div class="key enter extra-wide" onclick="typeKey(this)" data-special="Enter">↵</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom Row -->
|
||||
<div class="row">
|
||||
<div class="key shift extra-wide" onclick="toggleShift()" id="shift-left">⇧</div>
|
||||
<div class="key" data-key="z" onclick="typeKey(this)">z</div>
|
||||
<div class="key" data-key="x" onclick="typeKey(this)">x</div>
|
||||
<div class="key" data-key="c" onclick="typeKey(this)">c</div>
|
||||
<div class="key" data-key="v" onclick="typeKey(this)">v</div>
|
||||
<div class="key" data-key="b" onclick="typeKey(this)">b</div>
|
||||
<div class="key" data-key="n" onclick="typeKey(this)">n</div>
|
||||
<div class="key" data-key="m" onclick="typeKey(this)">m</div>
|
||||
<div class="key" data-key="," data-shift="<" onclick="typeKey(this)">,</div>
|
||||
<div class="key" data-key="." data-shift=">" onclick="typeKey(this)">.</div>
|
||||
<div class="key" data-key="/" data-shift="?" onclick="typeKey(this)">/</div>
|
||||
<div class="key shift extra-wide" onclick="toggleShift()" id="shift-right">⇧</div>
|
||||
</div>
|
||||
|
||||
<!-- Space Row -->
|
||||
<div class="row">
|
||||
<div class="key special" onclick="typeKey(this)" data-special="Control">Ctrl</div>
|
||||
<div class="key special" onclick="typeKey(this)" data-special="Alt">Alt</div>
|
||||
<div class="key space" onclick="typeKey(this)" data-special=" ">Space</div>
|
||||
<div class="key special" onclick="typeKey(this)" data-special="Alt">Alt</div>
|
||||
<div class="key special" onclick="typeKey(this)" data-special="Control">Ctrl</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const { ipcRenderer } = require('electron');
|
||||
let shiftPressed = false;
|
||||
let capsLock = false;
|
||||
|
||||
// CRITICAL: Do NOT preventDefault on keyboard events
|
||||
// This allows physical keyboard to work alongside OSK
|
||||
window.addEventListener('keydown', (e) => {
|
||||
// Don't interfere with physical keyboard
|
||||
// Just update our visual state if needed
|
||||
if (e.key === 'CapsLock') {
|
||||
capsLock = !capsLock;
|
||||
updateCapsDisplay();
|
||||
}
|
||||
}, {passive: true});
|
||||
|
||||
function updateKeyDisplay() {
|
||||
const keys = document.querySelectorAll('.key[data-key]');
|
||||
keys.forEach(key => {
|
||||
const baseKey = key.getAttribute('data-key');
|
||||
const shiftKey = key.getAttribute('data-shift');
|
||||
if (/^[a-z]$/.test(baseKey)) {
|
||||
const shouldBeUpper = (shiftPressed && !capsLock) || (!shiftPressed && capsLock);
|
||||
key.textContent = shouldBeUpper ? baseKey.toUpperCase() : baseKey.toLowerCase();
|
||||
} else if (shiftKey) {
|
||||
key.textContent = shiftPressed ? shiftKey : baseKey;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function typeKey(element) {
|
||||
const special = element.getAttribute('data-special');
|
||||
if (special) {
|
||||
ipcRenderer.send('keyboard-type', special);
|
||||
return;
|
||||
}
|
||||
|
||||
const baseKey = element.getAttribute('data-key');
|
||||
const shiftKey = element.getAttribute('data-shift');
|
||||
let finalKey = baseKey;
|
||||
|
||||
if (/^[a-z]$/.test(baseKey)) {
|
||||
const shouldBeUpper = (shiftPressed && !capsLock) || (!shiftPressed && capsLock);
|
||||
finalKey = shouldBeUpper ? baseKey.toUpperCase() : baseKey.toLowerCase();
|
||||
} else if (shiftPressed && shiftKey) {
|
||||
finalKey = shiftKey;
|
||||
}
|
||||
|
||||
ipcRenderer.send('keyboard-type', finalKey);
|
||||
|
||||
if (shiftPressed) {
|
||||
shiftPressed = false;
|
||||
updateShiftDisplay();
|
||||
updateKeyDisplay();
|
||||
}
|
||||
}
|
||||
|
||||
function toggleShift() {
|
||||
shiftPressed = !shiftPressed;
|
||||
updateShiftDisplay();
|
||||
updateKeyDisplay();
|
||||
}
|
||||
|
||||
function toggleCaps() {
|
||||
capsLock = !capsLock;
|
||||
updateCapsDisplay();
|
||||
updateKeyDisplay();
|
||||
}
|
||||
|
||||
function updateShiftDisplay() {
|
||||
document.querySelectorAll('.shift').forEach(key => {
|
||||
if (shiftPressed) key.classList.add('active');
|
||||
else key.classList.remove('active');
|
||||
});
|
||||
}
|
||||
|
||||
function updateCapsDisplay() {
|
||||
const capsKey = document.getElementById('caps-key');
|
||||
if (capsLock) capsKey.classList.add('active');
|
||||
else capsKey.classList.remove('active');
|
||||
}
|
||||
|
||||
function closeKeyboard() {
|
||||
ipcRenderer.send('close-keyboard');
|
||||
}
|
||||
|
||||
updateKeyDisplay();
|
||||
|
||||
// Tell main process we're ready
|
||||
ipcRenderer.send('keyboard-ready');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+1675
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "kiosk-app",
|
||||
"version": "1.0.0",
|
||||
"main": "main.js",
|
||||
"dependencies": {
|
||||
"electron": "^42.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
background: rgba(0,0,0,0.9);
|
||||
color: #ecf0f1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
padding: 40px;
|
||||
}
|
||||
.container { text-align: center; max-width: 600px; }
|
||||
h2 { font-size: 32px; margin-bottom: 20px; }
|
||||
.message { font-size: 20px; margin-bottom: 30px; line-height: 1.5; }
|
||||
.options {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 15px;
|
||||
margin: 30px 0;
|
||||
}
|
||||
.btn {
|
||||
padding: 20px 40px;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
font-weight: bold;
|
||||
transition: all 0.2s;
|
||||
color: white;
|
||||
}
|
||||
.btn-extend { background: #e67e22; }
|
||||
.btn-extend:hover { background: #d35400; }
|
||||
.btn-extend:active { background: #ba4a00; }
|
||||
|
||||
.btn-cancel {
|
||||
background: #95a5a6;
|
||||
grid-column: 1 / -1;
|
||||
font-size: 16px;
|
||||
padding: 15px;
|
||||
}
|
||||
.btn-cancel:hover { background: #7f8c8d; }
|
||||
|
||||
.info {
|
||||
font-size: 14px;
|
||||
color: #95a5a6;
|
||||
margin-top: 20px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.countdown {
|
||||
font-size: 16px;
|
||||
color: #e74c3c;
|
||||
margin-top: 15px;
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h2>⏸️ Pause Timers</h2>
|
||||
<div class="message">
|
||||
Select how long to pause rotation and inactivity timers:
|
||||
</div>
|
||||
|
||||
<div class="options">
|
||||
<button class="btn btn-extend" onclick="selectTime(15)">
|
||||
🍿 15 minutes
|
||||
</button>
|
||||
|
||||
<button class="btn btn-extend" onclick="selectTime(30)">
|
||||
⏱️ 30 minutes
|
||||
</button>
|
||||
|
||||
<button class="btn btn-extend" onclick="selectTime(60)">
|
||||
🎬 1 hour
|
||||
</button>
|
||||
|
||||
<button class="btn btn-extend" onclick="selectTime(120)">
|
||||
📺 2 hours
|
||||
</button>
|
||||
|
||||
<button class="btn btn-cancel" onclick="selectTime(0)">
|
||||
✗ Cancel
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="info">
|
||||
After the time expires, normal rotation and return-to-home logic will resume.
|
||||
</div>
|
||||
<div class="countdown" id="countdown">Auto-closing in 30 seconds...</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const {ipcRenderer} = require('electron');
|
||||
let timeLeft = 30;
|
||||
let countdownInterval;
|
||||
|
||||
function selectTime(minutes) {
|
||||
clearInterval(countdownInterval);
|
||||
ipcRenderer.send('pause-time-selected', minutes);
|
||||
}
|
||||
|
||||
function updateCountdown() {
|
||||
timeLeft--;
|
||||
document.getElementById('countdown').textContent = 'Auto-closing in ' + timeLeft + ' seconds...';
|
||||
if (timeLeft <= 0) {
|
||||
clearInterval(countdownInterval);
|
||||
selectTime(0);
|
||||
}
|
||||
}
|
||||
|
||||
countdownInterval = setInterval(updateCountdown, 1000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,139 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
background: #2c3e50;
|
||||
color: #ecf0f1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
.container { width: 100%; max-width: 400px; }
|
||||
h2 { text-align: center; margin-bottom: 30px; font-size: 24px; }
|
||||
#pin-display {
|
||||
width: 100%;
|
||||
padding: 20px;
|
||||
font-size: 48px;
|
||||
text-align: center;
|
||||
margin: 20px 0;
|
||||
border: 3px solid #34495e;
|
||||
border-radius: 12px;
|
||||
background: #34495e;
|
||||
color: #ecf0f1;
|
||||
letter-spacing: 20px;
|
||||
min-height: 90px;
|
||||
line-height: 50px;
|
||||
font-family: monospace;
|
||||
}
|
||||
#error { color: #e74c3c; text-align: center; display: none; margin: 10px 0; font-weight: bold; font-size: 18px; }
|
||||
.numpad { display: grid; grid-template-columns: repeat(3, 1fr); gap: 15px; margin: 20px 0; }
|
||||
.numpad button {
|
||||
padding: 30px;
|
||||
font-size: 32px;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
background: #34495e;
|
||||
color: #ecf0f1;
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.numpad button:active { background: #3498db; }
|
||||
.numpad button:hover { background: #475d6d; }
|
||||
.actions { display: grid; grid-template-columns: 1fr 1fr; gap: 15px; margin-top: 20px; }
|
||||
.btn { padding: 20px; font-size: 20px; cursor: pointer; border: none; border-radius: 12px; font-weight: bold; }
|
||||
#clear { background: #f39c12; color: white; }
|
||||
#backspace { background: #e67e22; color: white; }
|
||||
#submit { background: #27ae60; color: white; }
|
||||
#cancel { background: #e74c3c; color: white; }
|
||||
.info { text-align: center; font-size: 14px; color: #95a5a6; margin-top: 20px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h2>🔒 Enter PIN</h2>
|
||||
<div id="pin-display">••••</div>
|
||||
<div id="error">❌ Incorrect PIN</div>
|
||||
<div class="numpad">
|
||||
<button onclick="addDigit('1')">1</button>
|
||||
<button onclick="addDigit('2')">2</button>
|
||||
<button onclick="addDigit('3')">3</button>
|
||||
<button onclick="addDigit('4')">4</button>
|
||||
<button onclick="addDigit('5')">5</button>
|
||||
<button onclick="addDigit('6')">6</button>
|
||||
<button onclick="addDigit('7')">7</button>
|
||||
<button onclick="addDigit('8')">8</button>
|
||||
<button onclick="addDigit('9')">9</button>
|
||||
<button id="clear" onclick="clearPin()">Clear</button>
|
||||
<button onclick="addDigit('0')">0</button>
|
||||
<button id="backspace" onclick="backspace()">⌫</button>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn" id="submit" onclick="submitPin()">✓ Submit</button>
|
||||
<button class="btn" id="cancel" onclick="cancel()">✗ Cancel</button>
|
||||
</div>
|
||||
<div class="info">Default PIN: 1234 (4-8 digits)</div>
|
||||
</div>
|
||||
<script>
|
||||
const {ipcRenderer} = require('electron');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const pinFile = path.join(__dirname, '.jitsi-pin');
|
||||
let correctPin = '1234';
|
||||
let enteredPin = '';
|
||||
try {
|
||||
const stored = fs.readFileSync(pinFile, 'utf8').trim();
|
||||
if (stored !== 'NOPIN') correctPin = stored;
|
||||
else correctPin = null;
|
||||
} catch(e) {}
|
||||
function updateDisplay() {
|
||||
const display = document.getElementById('pin-display');
|
||||
if (enteredPin.length === 0) {
|
||||
display.textContent = '••••';
|
||||
display.style.color = '#7f8c8d';
|
||||
} else {
|
||||
display.textContent = '•'.repeat(enteredPin.length);
|
||||
display.style.color = '#ecf0f1';
|
||||
}
|
||||
}
|
||||
function addDigit(digit) {
|
||||
if (enteredPin.length < 8) {
|
||||
enteredPin += digit;
|
||||
updateDisplay();
|
||||
document.getElementById('error').style.display = 'none';
|
||||
}
|
||||
}
|
||||
function backspace() { enteredPin = enteredPin.slice(0, -1); updateDisplay(); }
|
||||
function clearPin() { enteredPin = ''; updateDisplay(); document.getElementById('error').style.display = 'none'; }
|
||||
function submitPin() {
|
||||
if (enteredPin.length < 4) {
|
||||
document.getElementById('error').textContent = '❌ PIN must be 4-8 digits';
|
||||
document.getElementById('error').style.display = 'block';
|
||||
return;
|
||||
}
|
||||
if (correctPin === null || enteredPin === correctPin) {
|
||||
ipcRenderer.send('pin-correct');
|
||||
} else {
|
||||
document.getElementById('error').textContent = '❌ Incorrect PIN';
|
||||
document.getElementById('error').style.display = 'block';
|
||||
enteredPin = '';
|
||||
updateDisplay();
|
||||
}
|
||||
}
|
||||
function cancel() { ipcRenderer.send('pin-cancelled'); }
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key >= '0' && e.key <= '9') addDigit(e.key);
|
||||
else if (e.key === 'Backspace') backspace();
|
||||
else if (e.key === 'Enter') submitPin();
|
||||
else if (e.key === 'Escape') cancel();
|
||||
});
|
||||
updateDisplay();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,931 @@
|
||||
const {contextBridge,ipcRenderer}=require('electron');
|
||||
|
||||
console.log('════════════════════════════════════════════════════════════');
|
||||
console.log(' Gestures:');
|
||||
console.log(' 3-finger DOWN: Toggle hidden tabs (PIN required)');
|
||||
console.log(' 2-finger HORIZONTAL: Switch between sites');
|
||||
console.log(' 1-finger HORIZONTAL: Navigate within page');
|
||||
console.log(' Navigation: Top-left key icon for site menu');
|
||||
console.log('════════════════════════════════════════════════════════════');
|
||||
|
||||
contextBridge.exposeInMainWorld('electronAPI', {
|
||||
notifyActivity: () => ipcRenderer.send('user-activity'),
|
||||
showKeyboard: () => ipcRenderer.send('show-keyboard'),
|
||||
closeKeyboard: () => ipcRenderer.send('close-keyboard'),
|
||||
keyboardActivity: () => ipcRenderer.send('keyboard-activity'),
|
||||
showPauseDialog: () => ipcRenderer.send('show-pause-dialog')
|
||||
});
|
||||
|
||||
// Pause button state (MUST be outside DOMContentLoaded to persist across page loads)
|
||||
let pauseButton=null;
|
||||
let pauseButtonShouldShow=false;
|
||||
let pauseButtonShown=false;
|
||||
let pauseButtonHideTimer=null;
|
||||
const PAUSE_BUTTON_HIDE_DELAY=5000; // Hide after 5 seconds of inactivity
|
||||
|
||||
// Pause button functions (must be outside DOMContentLoaded for IPC listener)
|
||||
function createPauseButton(){
|
||||
if(pauseButton)return;
|
||||
|
||||
pauseButton=document.createElement('div');
|
||||
pauseButton.id='electron-pause-button';
|
||||
pauseButton.innerHTML='<div style="display:flex;gap:4px;"><div style="width:6px;height:24px;background:white;border-radius:2px;"></div><div style="width:6px;height:24px;background:white;border-radius:2px;"></div></div>';
|
||||
pauseButton.title='Pause rotation';
|
||||
pauseButton.style.cssText=`
|
||||
position:fixed;bottom:20px;left:20px;width:60px;height:60px;
|
||||
background:rgba(230,126,34,0.95);border:3px solid rgba(255,255,255,0.9);
|
||||
border-radius:50%;display:none;align-items:center;justify-content:center;
|
||||
font-size:32px;cursor:pointer;z-index:999999;
|
||||
box-shadow:0 4px 12px rgba(0,0,0,0.4);user-select:none;
|
||||
`;
|
||||
|
||||
pauseButton.addEventListener('click',(e)=>{
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
ipcRenderer.send('show-pause-dialog');
|
||||
});
|
||||
|
||||
document.body.appendChild(pauseButton);
|
||||
}
|
||||
|
||||
function showPauseButton(){
|
||||
if(!pauseButton)createPauseButton();
|
||||
pauseButton.style.display='flex';
|
||||
pauseButtonShown=true;
|
||||
|
||||
// Clear existing hide timer
|
||||
if(pauseButtonHideTimer){
|
||||
clearTimeout(pauseButtonHideTimer);
|
||||
pauseButtonHideTimer=null;
|
||||
}
|
||||
|
||||
// Set new hide timer - button will auto-hide after inactivity
|
||||
pauseButtonHideTimer=setTimeout(()=>{
|
||||
console.log('[PAUSE-BTN] Auto-hiding after '+PAUSE_BUTTON_HIDE_DELAY+'ms inactivity');
|
||||
hidePauseButton();
|
||||
},PAUSE_BUTTON_HIDE_DELAY);
|
||||
}
|
||||
|
||||
function hidePauseButton(){
|
||||
if(pauseButtonHideTimer){
|
||||
clearTimeout(pauseButtonHideTimer);
|
||||
pauseButtonHideTimer=null;
|
||||
}
|
||||
if(pauseButton){
|
||||
pauseButton.style.display='none';
|
||||
pauseButtonShown=false;
|
||||
}
|
||||
}
|
||||
|
||||
// Declare variables at top level so IPC handlers and DOMContentLoaded can share them
|
||||
let keyboardButtonEnabled=true;
|
||||
let keyboardVisible=false;
|
||||
let keyboardIcon=null;
|
||||
let navButtonEnabled=true;
|
||||
let navButton=null;
|
||||
let navButtonShown=false;
|
||||
let navButtonHideTimer=null;
|
||||
let navMenu=null;
|
||||
let navMenuVisible=false;
|
||||
let navMenuTimer=null;
|
||||
const NAV_MENU_TIMEOUT=30000; // 30 seconds
|
||||
const NAV_BUTTON_HIDE_DELAY=5000; // Hide after 5 seconds of inactivity
|
||||
|
||||
// Listen for pause button visibility control from main process
|
||||
// CRITICAL: This must be outside DOMContentLoaded so it doesn't reset on page load
|
||||
ipcRenderer.on('pause-button-visibility',(event,shouldShow)=>{
|
||||
console.log('[PAUSE-BTN] Visibility update: shouldShow='+shouldShow);
|
||||
pauseButtonShouldShow=shouldShow;
|
||||
if(!shouldShow){
|
||||
// If button should not show on this site, hide it immediately
|
||||
console.log('[PAUSE-BTN] Hiding button (manual site)');
|
||||
hidePauseButton();
|
||||
}else{
|
||||
console.log('[PAUSE-BTN] Button enabled - will show on user interaction');
|
||||
}
|
||||
// If shouldShow is true, button will appear on user interaction
|
||||
});
|
||||
|
||||
ipcRenderer.on('keyboard-button-enabled',(event,enabled)=>{
|
||||
keyboardButtonEnabled=enabled;
|
||||
console.log('[KEYBOARD-BTN] Keyboard button enabled: '+enabled);
|
||||
// Note: keyboardIcon may not exist yet if page hasn't loaded
|
||||
if(keyboardIcon&&!enabled){
|
||||
keyboardIcon.style.display='none';
|
||||
}
|
||||
});
|
||||
|
||||
ipcRenderer.on('nav-button-enabled',(event,enabled)=>{
|
||||
navButtonEnabled=enabled;
|
||||
console.log('[NAV-BTN] Navigation button enabled: '+enabled);
|
||||
if(navButton&&!enabled){
|
||||
navButton.style.display='none';
|
||||
}
|
||||
if(navMenu&&!enabled){
|
||||
navMenu.style.display='none';
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('DOMContentLoaded',()=>{
|
||||
document.addEventListener('contextmenu',e=>e.preventDefault());
|
||||
|
||||
const SWIPE_THRESHOLD=120;
|
||||
const SWIPE_MAX_TIME=500;
|
||||
const SWIPE_TOLERANCE=50;
|
||||
|
||||
let touchStartX=0;
|
||||
let touchStartY=0;
|
||||
let touchStartTime=0;
|
||||
let fingerCount=0;
|
||||
let lastKeyboardRequest=0;
|
||||
let keyboardAutoClosedThisSession=false;
|
||||
const KEYBOARD_REQUEST_THROTTLE=1000;
|
||||
|
||||
const activityEvents=[
|
||||
'mousedown','mouseup','mousemove','click','dblclick',
|
||||
'wheel','scroll',
|
||||
'keydown','keyup','keypress',
|
||||
'touchstart','touchmove','touchend',
|
||||
'pointerdown','pointerup','pointermove',
|
||||
'input','change'
|
||||
];
|
||||
|
||||
let lastActivityNotification=0;
|
||||
const ACTIVITY_THROTTLE=1000;
|
||||
|
||||
function notifyActivity(){
|
||||
const now=Date.now();
|
||||
if(now-lastActivityNotification>ACTIVITY_THROTTLE){
|
||||
if(window.electronAPI?.notifyActivity){
|
||||
window.electronAPI.notifyActivity();
|
||||
lastActivityNotification=now;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
activityEvents.forEach(eventType=>{
|
||||
document.addEventListener(eventType,notifyActivity,{
|
||||
passive:true,
|
||||
capture:true
|
||||
});
|
||||
});
|
||||
|
||||
ipcRenderer.on('keyboard-state-changed',(event,visible)=>{
|
||||
keyboardVisible=visible;
|
||||
if(visible){
|
||||
showKeyboardIcon();
|
||||
keyboardAutoClosedThisSession=false;
|
||||
}else{
|
||||
hideKeyboardIcon();
|
||||
}
|
||||
});
|
||||
|
||||
ipcRenderer.on('keyboard-auto-closed',()=>{
|
||||
keyboardAutoClosedThisSession=true;
|
||||
});
|
||||
|
||||
function createKeyboardIcon(){
|
||||
if(keyboardIcon||!keyboardButtonEnabled)return;
|
||||
|
||||
|
||||
keyboardIcon=document.createElement('div');
|
||||
keyboardIcon.id='electron-keyboard-icon';
|
||||
keyboardIcon.innerHTML='⌨️';
|
||||
keyboardIcon.style.cssText=`
|
||||
position:fixed;bottom:20px;right:20px;width:60px;height:60px;
|
||||
background:rgba(52,152,219,0.95);border:3px solid rgba(255,255,255,0.9);
|
||||
border-radius:50%;display:none;align-items:center;justify-content:center;
|
||||
font-size:32px;cursor:pointer;z-index:999999;
|
||||
box-shadow:0 4px 12px rgba(0,0,0,0.4);user-select:none;
|
||||
`;
|
||||
|
||||
keyboardIcon.addEventListener('click',(e)=>{
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
keyboardAutoClosedThisSession=false;
|
||||
if(keyboardVisible){
|
||||
ipcRenderer.send('close-keyboard');
|
||||
}else{
|
||||
ipcRenderer.send('show-keyboard');
|
||||
}
|
||||
});
|
||||
|
||||
document.body.appendChild(keyboardIcon);
|
||||
}
|
||||
|
||||
function showKeyboardIcon(){
|
||||
if(!keyboardButtonEnabled)return;
|
||||
if(!keyboardIcon)createKeyboardIcon();
|
||||
if(keyboardIcon)keyboardIcon.style.display='flex';
|
||||
}
|
||||
|
||||
function hideKeyboardIcon(){
|
||||
if(keyboardIcon)keyboardIcon.style.display='none';
|
||||
}
|
||||
|
||||
function createNavButton(){
|
||||
if(navButton||!navButtonEnabled)return;
|
||||
|
||||
navButton=document.createElement('div');
|
||||
navButton.id='electron-nav-button';
|
||||
// Use SVG key icon instead of emoji for better compatibility
|
||||
navButton.innerHTML='<svg width="32" height="32" viewBox="0 0 24 24" fill="white"><path d="M12.65 10C11.7 7.31 8.9 5.5 5.77 6.12c-2.29.46-4.15 2.29-4.63 4.58C.32 14.57 3.26 18 7 18c2.61 0 4.83-1.67 5.65-4H17v2c0 1.1.9 2 2 2s2-.9 2-2v-2c1.1 0 2-.9 2-2s-.9-2-2-2h-8.35zM7 14c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2z"/></svg>';
|
||||
navButton.title='Navigation Menu';
|
||||
navButton.style.cssText=`
|
||||
position:fixed;top:20px;left:20px;width:60px;height:60px;
|
||||
background:rgba(155,89,182,0.95);border:3px solid rgba(255,255,255,0.9);
|
||||
border-radius:50%;display:none;align-items:center;justify-content:center;
|
||||
cursor:pointer;z-index:999999;
|
||||
box-shadow:0 4px 12px rgba(0,0,0,0.4);user-select:none;
|
||||
`;
|
||||
|
||||
navButton.addEventListener('click',(e)=>{
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
console.log('[NAV] Button clicked');
|
||||
try{
|
||||
toggleNavMenu();
|
||||
}catch(err){
|
||||
console.error('[NAV] Error toggling menu:',err);
|
||||
}
|
||||
});
|
||||
|
||||
document.body.appendChild(navButton);
|
||||
}
|
||||
|
||||
// Power button in top-right corner (follows same show/hide logic as nav button)
|
||||
let powerButton=null;
|
||||
let powerButtonHideTimer=null;
|
||||
const POWER_BUTTON_HIDE_DELAY=5000; // Same as nav button
|
||||
function createPowerButton(){
|
||||
if(powerButton)return;
|
||||
powerButton=document.createElement('div');
|
||||
powerButton.id='electron-power-button';
|
||||
// Power icon SVG
|
||||
powerButton.innerHTML='<svg width="28" height="28" viewBox="0 0 24 24" fill="white"><path d="M13 3h-2v10h2V3zm4.83 2.17l-1.42 1.42C17.99 7.86 19 9.81 19 12c0 3.87-3.13 7-7 7s-7-3.13-7-7c0-2.19 1.01-4.14 2.58-5.42L6.17 5.17C4.23 6.82 3 9.26 3 12c0 4.97 4.03 9 9 9s9-4.03 9-9c0-2.74-1.23-5.18-3.17-6.83z"/></svg>';
|
||||
powerButton.title='Power Menu';
|
||||
powerButton.style.cssText=`
|
||||
position:fixed;top:20px;right:20px;width:60px;height:60px;
|
||||
background:rgba(231,76,60,0.95);border:3px solid rgba(255,255,255,0.9);
|
||||
border-radius:50%;display:none;align-items:center;justify-content:center;
|
||||
cursor:pointer;z-index:999999;
|
||||
box-shadow:0 4px 12px rgba(0,0,0,0.4);user-select:none;
|
||||
`;
|
||||
powerButton.addEventListener('click',(e)=>{
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
console.log('[POWER] Button clicked');
|
||||
ipcRenderer.send('show-power-menu');
|
||||
});
|
||||
document.body.appendChild(powerButton);
|
||||
}
|
||||
|
||||
function showPowerButton(){
|
||||
if(!powerButton)createPowerButton();
|
||||
if(powerButton){
|
||||
powerButton.style.display='flex';
|
||||
}
|
||||
|
||||
// Clear existing hide timer
|
||||
if(powerButtonHideTimer){
|
||||
clearTimeout(powerButtonHideTimer);
|
||||
powerButtonHideTimer=null;
|
||||
}
|
||||
|
||||
// Set new hide timer - button will auto-hide after inactivity
|
||||
powerButtonHideTimer=setTimeout(()=>{
|
||||
console.log('[POWER-BTN] Auto-hiding after '+POWER_BUTTON_HIDE_DELAY+'ms inactivity');
|
||||
hidePowerButton();
|
||||
},POWER_BUTTON_HIDE_DELAY);
|
||||
}
|
||||
|
||||
function hidePowerButton(){
|
||||
if(powerButtonHideTimer){
|
||||
clearTimeout(powerButtonHideTimer);
|
||||
powerButtonHideTimer=null;
|
||||
}
|
||||
if(powerButton){
|
||||
powerButton.style.display='none';
|
||||
}
|
||||
}
|
||||
|
||||
function showNavButton(){
|
||||
if(!navButtonEnabled)return;
|
||||
if(!navButton)createNavButton();
|
||||
if(navButton){
|
||||
navButton.style.display='flex';
|
||||
navButtonShown=true;
|
||||
}
|
||||
|
||||
// Clear existing hide timer
|
||||
if(navButtonHideTimer){
|
||||
clearTimeout(navButtonHideTimer);
|
||||
navButtonHideTimer=null;
|
||||
}
|
||||
|
||||
// Set new hide timer - button will auto-hide after inactivity
|
||||
navButtonHideTimer=setTimeout(()=>{
|
||||
console.log('[NAV-BTN] Auto-hiding after '+NAV_BUTTON_HIDE_DELAY+'ms inactivity');
|
||||
hideNavButton();
|
||||
},NAV_BUTTON_HIDE_DELAY);
|
||||
}
|
||||
|
||||
function hideNavButton(){
|
||||
if(navButtonHideTimer){
|
||||
clearTimeout(navButtonHideTimer);
|
||||
navButtonHideTimer=null;
|
||||
}
|
||||
if(navButton){
|
||||
navButton.style.display='none';
|
||||
navButtonShown=false;
|
||||
}
|
||||
}
|
||||
|
||||
function createNavMenu(){
|
||||
if(navMenu)return;
|
||||
console.log('[NAV] Creating navigation menu');
|
||||
|
||||
navMenu=document.createElement('div');
|
||||
navMenu.id='electron-nav-menu';
|
||||
navMenu.style.cssText=`
|
||||
position:fixed;top:0;left:0;width:100%;height:100%;
|
||||
background:rgba(0,0,0,0.9);display:none;align-items:center;justify-content:center;
|
||||
z-index:999998;pointer-events:auto;
|
||||
`;
|
||||
|
||||
const content=document.createElement('div');
|
||||
content.style.cssText=`
|
||||
position:relative;background:rgba(44,62,80,0.98);border-radius:20px;padding:40px;
|
||||
max-width:90%;max-height:90%;overflow:hidden;
|
||||
box-shadow:0 10px 40px rgba(0,0,0,0.5);
|
||||
`;
|
||||
|
||||
const closeBtn=document.createElement('div');
|
||||
closeBtn.innerHTML='✕';
|
||||
closeBtn.style.cssText=`
|
||||
position:absolute;top:10px;right:10px;font-size:32px;color:white;
|
||||
cursor:pointer;width:40px;height:40px;display:flex;align-items:center;
|
||||
justify-content:center;border-radius:50%;background:rgba(231,76,60,0.8);
|
||||
user-select:none;
|
||||
`;
|
||||
closeBtn.addEventListener('click',(e)=>{
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
console.log('[NAV] Close button clicked');
|
||||
hideNavMenu();
|
||||
});
|
||||
content.appendChild(closeBtn);
|
||||
|
||||
const columns=document.createElement('div');
|
||||
columns.style.cssText='display:flex;gap:40px;margin-top:20px;max-height:70vh;';
|
||||
|
||||
// Column 1: Sites (scrollable)
|
||||
const sitesCol=document.createElement('div');
|
||||
sitesCol.style.cssText='flex:1;min-width:300px;display:flex;flex-direction:column;';
|
||||
sitesCol.innerHTML='<h2 style="color:white;margin-bottom:20px;">Sites</h2>';
|
||||
const sitesList=document.createElement('div');
|
||||
sitesList.id='nav-sites-list';
|
||||
sitesList.style.cssText='display:flex;flex-direction:column;gap:10px;overflow-y:auto;padding-right:10px;';
|
||||
sitesCol.appendChild(sitesList);
|
||||
|
||||
// Column 2: Gesture Cheat Sheet (fixed, no scroll)
|
||||
const cheatCol=document.createElement('div');
|
||||
cheatCol.style.cssText='flex:1;min-width:300px;overflow-y:hidden;';
|
||||
cheatCol.innerHTML=`
|
||||
<h2 style="color:white;margin-bottom:20px;">Touch Gestures</h2>
|
||||
<div style="color:#ecf0f1;line-height:1.8;font-size:16px;">
|
||||
<div style="margin-bottom:15px;">
|
||||
<div style="font-weight:bold;color:#3498db;">2-Finger Horizontal Swipe</div>
|
||||
<div style="padding-left:15px;">Switch between sites</div>
|
||||
</div>
|
||||
<div style="margin-bottom:15px;">
|
||||
<div style="font-weight:bold;color:#3498db;">1-Finger Horizontal Swipe</div>
|
||||
<div style="padding-left:15px;">Navigate within page (arrow keys)</div>
|
||||
</div>
|
||||
<div style="margin-bottom:15px;">
|
||||
<div style="font-weight:bold;color:#9b59b6;">3-Finger Down Swipe</div>
|
||||
<div style="padding-left:15px;">Toggle hidden tabs (PIN required)</div>
|
||||
</div>
|
||||
<div style="margin-bottom:25px;padding-top:15px;border-top:1px solid rgba(255,255,255,0.2);">
|
||||
<div style="font-weight:bold;color:#e74c3c;">Keyboard Shortcuts</div>
|
||||
</div>
|
||||
<div style="margin-bottom:10px;">
|
||||
<div style="padding-left:15px;"><kbd style="background:rgba(255,255,255,0.2);padding:2px 8px;border-radius:3px;">Ctrl+Tab</kbd> or <kbd style="background:rgba(255,255,255,0.2);padding:2px 8px;border-radius:3px;">Ctrl+]</kbd> Next tab</div>
|
||||
</div>
|
||||
<div style="margin-bottom:10px;">
|
||||
<div style="padding-left:15px;"><kbd style="background:rgba(255,255,255,0.2);padding:2px 8px;border-radius:3px;">Ctrl+Shift+Tab</kbd> or <kbd style="background:rgba(255,255,255,0.2);padding:2px 8px;border-radius:3px;">Ctrl+[</kbd> Previous tab</div>
|
||||
</div>
|
||||
<div style="margin-bottom:10px;">
|
||||
<div style="padding-left:15px;"><kbd style="background:rgba(255,255,255,0.2);padding:2px 8px;border-radius:3px;">F10</kbd> or <kbd style="background:rgba(255,255,255,0.2);padding:2px 8px;border-radius:3px;">Ctrl+H</kbd> Toggle hidden tabs</div>
|
||||
</div>
|
||||
<div style="margin-bottom:10px;">
|
||||
<div style="padding-left:15px;"><kbd style="background:rgba(255,255,255,0.2);padding:2px 8px;border-radius:3px;">Escape</kbd> Return to normal tabs</div>
|
||||
</div>
|
||||
<div style="margin-bottom:10px;">
|
||||
<div style="padding-left:15px;"><kbd style="background:rgba(255,255,255,0.2);padding:2px 8px;border-radius:3px;">Ctrl+Alt+Delete</kbd> or <kbd style="background:rgba(255,255,255,0.2);padding:2px 8px;border-radius:3px;">Ctrl+Alt+P</kbd> Power menu</div>
|
||||
</div>
|
||||
<div style="margin-bottom:10px;">
|
||||
<div style="padding-left:15px;"><kbd style="background:rgba(255,255,255,0.2);padding:2px 8px;border-radius:3px;">Ctrl+K</kbd> Toggle keyboard</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
columns.appendChild(sitesCol);
|
||||
columns.appendChild(cheatCol);
|
||||
content.appendChild(columns);
|
||||
navMenu.appendChild(content);
|
||||
|
||||
navMenu.addEventListener('click',(e)=>{
|
||||
if(e.target===navMenu){
|
||||
console.log('[NAV] Background clicked, closing menu');
|
||||
hideNavMenu();
|
||||
}
|
||||
});
|
||||
|
||||
// Prevent clicks inside content from closing menu
|
||||
content.addEventListener('click',(e)=>{
|
||||
e.stopPropagation();
|
||||
});
|
||||
|
||||
document.body.appendChild(navMenu);
|
||||
console.log('[NAV] Navigation menu created and appended to body');
|
||||
}
|
||||
|
||||
function toggleNavMenu(){
|
||||
console.log('[NAV] Toggle menu, current state:',navMenuVisible);
|
||||
if(navMenuVisible){
|
||||
hideNavMenu();
|
||||
}else{
|
||||
showNavMenu();
|
||||
}
|
||||
}
|
||||
|
||||
function showNavMenu(){
|
||||
console.log('[NAV] Showing navigation menu');
|
||||
try{
|
||||
if(!navMenu){
|
||||
createNavMenu();
|
||||
}
|
||||
|
||||
// Request sites data
|
||||
loadSitesIntoNav();
|
||||
|
||||
navMenu.style.display='flex';
|
||||
navMenuVisible=true;
|
||||
|
||||
// Force reflow and repaint to ensure proper rendering
|
||||
navMenu.offsetHeight;
|
||||
navMenu.style.opacity='0';
|
||||
setTimeout(()=>{
|
||||
navMenu.style.transition='opacity 0.15s ease-in';
|
||||
navMenu.style.opacity='1';
|
||||
},10);
|
||||
|
||||
// Set 30-second auto-dismiss timer
|
||||
if(navMenuTimer){
|
||||
clearTimeout(navMenuTimer);
|
||||
}
|
||||
navMenuTimer=setTimeout(()=>{
|
||||
console.log('[NAV] Auto-dismissing menu after 30 seconds');
|
||||
hideNavMenu();
|
||||
},NAV_MENU_TIMEOUT);
|
||||
|
||||
console.log('[NAV] Menu displayed, 30-second timer started');
|
||||
}catch(err){
|
||||
console.error('[NAV] Error showing menu:',err);
|
||||
}
|
||||
}
|
||||
|
||||
function hideNavMenu(){
|
||||
console.log('[NAV] Hiding navigation menu');
|
||||
try{
|
||||
if(navMenuTimer){
|
||||
clearTimeout(navMenuTimer);
|
||||
navMenuTimer=null;
|
||||
}
|
||||
if(navMenu){
|
||||
navMenu.style.display='none';
|
||||
navMenu.style.opacity='1';
|
||||
navMenu.style.transition='';
|
||||
}
|
||||
navMenuVisible=false;
|
||||
console.log('[NAV] Menu hidden');
|
||||
}catch(err){
|
||||
console.error('[NAV] Error hiding menu:',err);
|
||||
}
|
||||
}
|
||||
|
||||
// Power menu overlay (with 30-second auto-dismiss)
|
||||
let powerMenu=null;
|
||||
let powerMenuVisible=false;
|
||||
let powerMenuTimer=null;
|
||||
const POWER_MENU_TIMEOUT=30000;
|
||||
let powerMenuInfo={version:'',localIP:'',vpnIP:''};
|
||||
|
||||
function createPowerMenu(){
|
||||
if(powerMenu)return;
|
||||
console.log('[POWER-MENU] Creating power menu');
|
||||
|
||||
powerMenu=document.createElement('div');
|
||||
powerMenu.id='electron-power-menu';
|
||||
powerMenu.style.cssText=`
|
||||
position:fixed;top:0;left:0;width:100%;height:100%;
|
||||
background:rgba(0,0,0,0.9);display:none;align-items:center;justify-content:center;
|
||||
z-index:999998;pointer-events:auto;
|
||||
`;
|
||||
|
||||
const content=document.createElement('div');
|
||||
content.style.cssText=`
|
||||
position:relative;background:rgba(44,62,80,0.98);border-radius:20px;padding:40px;
|
||||
min-width:400px;max-width:90%;box-shadow:0 10px 40px rgba(0,0,0,0.5);text-align:center;
|
||||
`;
|
||||
|
||||
const closeBtn=document.createElement('div');
|
||||
closeBtn.innerHTML='✕';
|
||||
closeBtn.style.cssText=`
|
||||
position:absolute;top:10px;right:10px;font-size:32px;color:white;
|
||||
cursor:pointer;width:40px;height:40px;display:flex;align-items:center;
|
||||
justify-content:center;border-radius:50%;background:rgba(231,76,60,0.8);
|
||||
user-select:none;
|
||||
`;
|
||||
closeBtn.addEventListener('click',(e)=>{
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
hidePowerMenu();
|
||||
});
|
||||
content.appendChild(closeBtn);
|
||||
|
||||
const title=document.createElement('h2');
|
||||
title.textContent='Power Options';
|
||||
title.style.cssText='color:white;margin-bottom:20px;font-size:28px;';
|
||||
content.appendChild(title);
|
||||
|
||||
const infoDiv=document.createElement('div');
|
||||
infoDiv.id='power-menu-info';
|
||||
infoDiv.style.cssText='color:#bdc3c7;margin-bottom:30px;font-size:14px;line-height:1.6;';
|
||||
content.appendChild(infoDiv);
|
||||
|
||||
const buttonsDiv=document.createElement('div');
|
||||
buttonsDiv.style.cssText='display:flex;flex-direction:column;gap:15px;';
|
||||
|
||||
const btnStyle=`
|
||||
padding:20px 40px;font-size:20px;border:none;border-radius:10px;
|
||||
cursor:pointer;font-weight:bold;transition:transform 0.2s,opacity 0.2s;
|
||||
`;
|
||||
|
||||
const shutdownBtn=document.createElement('button');
|
||||
shutdownBtn.textContent='⏻ Shutdown';
|
||||
shutdownBtn.style.cssText=btnStyle+'background:#e74c3c;color:white;';
|
||||
shutdownBtn.addEventListener('click',()=>{
|
||||
hidePowerMenu();
|
||||
ipcRenderer.send('power-action','shutdown');
|
||||
});
|
||||
|
||||
const restartBtn=document.createElement('button');
|
||||
restartBtn.textContent='↻ Restart';
|
||||
restartBtn.style.cssText=btnStyle+'background:#f39c12;color:white;';
|
||||
restartBtn.addEventListener('click',()=>{
|
||||
hidePowerMenu();
|
||||
ipcRenderer.send('power-action','restart');
|
||||
});
|
||||
|
||||
const reloadBtn=document.createElement('button');
|
||||
reloadBtn.textContent='⟳ Reload App';
|
||||
reloadBtn.style.cssText=btnStyle+'background:#3498db;color:white;';
|
||||
reloadBtn.addEventListener('click',()=>{
|
||||
hidePowerMenu();
|
||||
ipcRenderer.send('power-action','reload');
|
||||
});
|
||||
|
||||
const cancelBtn=document.createElement('button');
|
||||
cancelBtn.textContent='Cancel';
|
||||
cancelBtn.style.cssText=btnStyle+'background:#7f8c8d;color:white;';
|
||||
cancelBtn.addEventListener('click',()=>{
|
||||
hidePowerMenu();
|
||||
});
|
||||
|
||||
buttonsDiv.appendChild(shutdownBtn);
|
||||
buttonsDiv.appendChild(restartBtn);
|
||||
buttonsDiv.appendChild(reloadBtn);
|
||||
buttonsDiv.appendChild(cancelBtn);
|
||||
content.appendChild(buttonsDiv);
|
||||
|
||||
powerMenu.appendChild(content);
|
||||
|
||||
powerMenu.addEventListener('click',(e)=>{
|
||||
if(e.target===powerMenu){
|
||||
hidePowerMenu();
|
||||
}
|
||||
});
|
||||
|
||||
content.addEventListener('click',(e)=>{
|
||||
e.stopPropagation();
|
||||
});
|
||||
|
||||
document.body.appendChild(powerMenu);
|
||||
}
|
||||
|
||||
function showPowerMenu(info){
|
||||
console.log('[POWER-MENU] Showing power menu');
|
||||
try{
|
||||
if(!powerMenu)createPowerMenu();
|
||||
|
||||
// Update info display
|
||||
const infoDiv=document.getElementById('power-menu-info');
|
||||
if(infoDiv&&info){
|
||||
let infoText='Version: '+info.version+'<br>Local: '+info.localIP;
|
||||
if(info.vpnIP){
|
||||
infoText+='<br>VPN: '+info.vpnIP;
|
||||
}
|
||||
infoDiv.innerHTML=infoText;
|
||||
}
|
||||
|
||||
powerMenu.style.display='flex';
|
||||
powerMenuVisible=true;
|
||||
|
||||
// Set 30-second auto-dismiss timer
|
||||
if(powerMenuTimer){
|
||||
clearTimeout(powerMenuTimer);
|
||||
}
|
||||
powerMenuTimer=setTimeout(()=>{
|
||||
console.log('[POWER-MENU] Auto-dismissing after 30 seconds');
|
||||
hidePowerMenu();
|
||||
},POWER_MENU_TIMEOUT);
|
||||
|
||||
}catch(err){
|
||||
console.error('[POWER-MENU] Error showing menu:',err);
|
||||
}
|
||||
}
|
||||
|
||||
function hidePowerMenu(){
|
||||
console.log('[POWER-MENU] Hiding power menu');
|
||||
try{
|
||||
if(powerMenuTimer){
|
||||
clearTimeout(powerMenuTimer);
|
||||
powerMenuTimer=null;
|
||||
}
|
||||
if(powerMenu){
|
||||
powerMenu.style.display='none';
|
||||
}
|
||||
powerMenuVisible=false;
|
||||
}catch(err){
|
||||
console.error('[POWER-MENU] Error hiding menu:',err);
|
||||
}
|
||||
}
|
||||
|
||||
// Listen for power menu display request from main process
|
||||
ipcRenderer.on('display-power-menu',(event,info)=>{
|
||||
showPowerMenu(info);
|
||||
});
|
||||
|
||||
function loadSitesIntoNav(){
|
||||
console.log('[NAV] Requesting config from main process');
|
||||
try{
|
||||
ipcRenderer.send('get-config');
|
||||
}catch(err){
|
||||
console.error('[NAV] Error requesting config:',err);
|
||||
}
|
||||
}
|
||||
|
||||
ipcRenderer.on('config-data',(event,config)=>{
|
||||
console.log('[NAV] Received config data:',config);
|
||||
try{
|
||||
const sitesList=document.getElementById('nav-sites-list');
|
||||
if(!sitesList){
|
||||
console.error('[NAV] Sites list element not found');
|
||||
return;
|
||||
}
|
||||
|
||||
if(!config||!config.tabs){
|
||||
console.error('[NAV] Invalid config data');
|
||||
sitesList.innerHTML='<div style="color:white;padding:10px;">No sites configured</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
sitesList.innerHTML='';
|
||||
let siteCount=0;
|
||||
|
||||
config.tabs.forEach((tab,index)=>{
|
||||
// Skip hidden tabs (duration === -1)
|
||||
if(tab.duration===-1){
|
||||
console.log('[NAV] Skipping hidden tab at index',index);
|
||||
return;
|
||||
}
|
||||
|
||||
const siteBtn=document.createElement('div');
|
||||
const displayName=tab.name||tab.url;
|
||||
siteBtn.textContent=displayName;
|
||||
siteBtn.style.cssText=`
|
||||
padding:15px 20px;background:rgba(52,152,219,0.7);color:white;
|
||||
border-radius:10px;cursor:pointer;font-size:18px;
|
||||
transition:all 0.3s;border:3px solid rgba(52,152,219,0.9);
|
||||
user-select:none;font-weight:normal;
|
||||
box-shadow:0 2px 8px rgba(0,0,0,0.2);
|
||||
`;
|
||||
siteBtn.addEventListener('mouseenter',()=>{
|
||||
siteBtn.style.background='rgba(41,128,185,1)';
|
||||
siteBtn.style.borderColor='rgba(255,255,255,0.9)';
|
||||
siteBtn.style.fontWeight='bold';
|
||||
siteBtn.style.transform='translateY(-2px)';
|
||||
siteBtn.style.boxShadow='0 4px 12px rgba(0,0,0,0.4)';
|
||||
});
|
||||
siteBtn.addEventListener('mouseleave',()=>{
|
||||
siteBtn.style.background='rgba(52,152,219,0.7)';
|
||||
siteBtn.style.borderColor='rgba(52,152,219,0.9)';
|
||||
siteBtn.style.fontWeight='normal';
|
||||
siteBtn.style.transform='translateY(0)';
|
||||
siteBtn.style.boxShadow='0 2px 8px rgba(0,0,0,0.2)';
|
||||
});
|
||||
siteBtn.addEventListener('mousedown',()=>{
|
||||
siteBtn.style.background='rgba(31,97,141,1)';
|
||||
siteBtn.style.transform='translateY(0)';
|
||||
siteBtn.style.boxShadow='0 1px 4px rgba(0,0,0,0.3)';
|
||||
});
|
||||
siteBtn.addEventListener('click',(e)=>{
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
console.log('[NAV] Navigating to tab',index);
|
||||
try{
|
||||
ipcRenderer.send('navigate-to-tab',index);
|
||||
hideNavMenu();
|
||||
}catch(err){
|
||||
console.error('[NAV] Error navigating:',err);
|
||||
}
|
||||
});
|
||||
|
||||
sitesList.appendChild(siteBtn);
|
||||
siteCount++;
|
||||
});
|
||||
|
||||
console.log('[NAV] Loaded',siteCount,'sites into menu');
|
||||
}catch(err){
|
||||
console.error('[NAV] Error processing config data:',err);
|
||||
}
|
||||
});
|
||||
|
||||
function isTextInput(el){
|
||||
if(!el)return false;
|
||||
const tag=(el.tagName||'').toLowerCase();
|
||||
const type=(el.type||'').toLowerCase();
|
||||
const editable=el.isContentEditable||el.contentEditable==='true';
|
||||
return(tag==='input'&&['text','email','password','search','tel','url','number'].includes(type))||tag==='textarea'||editable;
|
||||
}
|
||||
|
||||
document.addEventListener('focusin',(e)=>{
|
||||
if(keyboardButtonEnabled&&isTextInput(e.target)){
|
||||
showKeyboardIcon();
|
||||
}
|
||||
},true);
|
||||
|
||||
document.addEventListener('focusout',(e)=>{
|
||||
if(keyboardButtonEnabled&&isTextInput(e.target)){
|
||||
setTimeout(()=>{
|
||||
if(!isTextInput(document.activeElement)){
|
||||
hideKeyboardIcon();
|
||||
}
|
||||
},100);
|
||||
}
|
||||
},true);
|
||||
|
||||
document.addEventListener('mousedown',(e)=>{
|
||||
if(keyboardButtonEnabled&&isTextInput(e.target)){
|
||||
if(keyboardVisible){
|
||||
if(window.electronAPI?.keyboardActivity){
|
||||
window.electronAPI.keyboardActivity();
|
||||
}
|
||||
}else{
|
||||
keyboardAutoClosedThisSession=false;
|
||||
const now=Date.now();
|
||||
if(now-lastKeyboardRequest>KEYBOARD_REQUEST_THROTTLE){
|
||||
lastKeyboardRequest=now;
|
||||
setTimeout(()=>ipcRenderer.send('show-keyboard'),50);
|
||||
}
|
||||
}
|
||||
}
|
||||
},true);
|
||||
|
||||
// Shared debounce prevents double-firing when both touch and pointer events fire
|
||||
let lastSwipeSent=0;
|
||||
function sendSwipeIPC(direction){
|
||||
const now=Date.now();
|
||||
if(now-lastSwipeSent<500)return;
|
||||
lastSwipeSent=now;
|
||||
ipcRenderer.send(direction);
|
||||
}
|
||||
|
||||
document.addEventListener('touchstart',e=>{
|
||||
if(e.touches.length>=1){
|
||||
touchStartX=e.touches[0].clientX;
|
||||
touchStartY=e.touches[0].clientY;
|
||||
touchStartTime=Date.now();
|
||||
fingerCount=e.touches.length;
|
||||
}
|
||||
},{passive:true});
|
||||
|
||||
document.addEventListener('touchend',e=>{
|
||||
if(e.changedTouches.length>=1){
|
||||
const touchEndX=e.changedTouches[0].clientX;
|
||||
const touchEndY=e.changedTouches[0].clientY;
|
||||
const deltaX=touchEndX-touchStartX;
|
||||
const deltaY=touchEndY-touchStartY;
|
||||
const deltaTime=Date.now()-touchStartTime;
|
||||
|
||||
if(deltaTime>SWIPE_MAX_TIME)return;
|
||||
|
||||
const absX=Math.abs(deltaX);
|
||||
const absY=Math.abs(deltaY);
|
||||
|
||||
if(fingerCount===3&&absY>SWIPE_THRESHOLD&&absX<SWIPE_TOLERANCE&&deltaY>0){
|
||||
console.log('[TOUCH] 3-finger DOWN - toggle hidden tabs');
|
||||
ipcRenderer.send('toggle-hidden');
|
||||
}else if(fingerCount===2&&absX>SWIPE_THRESHOLD&&absY<SWIPE_TOLERANCE){
|
||||
console.log('[TOUCH] 2-finger HORIZONTAL - change tab');
|
||||
sendSwipeIPC(deltaX>0?'swipe-right':'swipe-left');
|
||||
}else if(fingerCount===1&&absX>SWIPE_THRESHOLD&&absY<SWIPE_TOLERANCE){
|
||||
const key=deltaX>0?'ArrowRight':'ArrowLeft';
|
||||
const keyCode=deltaX>0?39:37;
|
||||
['keydown','keyup'].forEach(eventType=>{
|
||||
document.dispatchEvent(new KeyboardEvent(eventType,{
|
||||
key:key,code:key,keyCode:keyCode,which:keyCode,bubbles:true,cancelable:true
|
||||
}));
|
||||
});
|
||||
}
|
||||
}
|
||||
},{passive:true});
|
||||
|
||||
// Pointer event fallback — handles devices/drivers where touchstart/touchend don't fire
|
||||
// (e.g. Electron 42 on some Linux touchscreen drivers that only generate PointerEvents)
|
||||
let ptrIds=new Set();
|
||||
let ptrPeak=0;
|
||||
let ptrStartX=0,ptrStartY=0,ptrStartTime=0;
|
||||
|
||||
document.addEventListener('pointerdown',e=>{
|
||||
if(e.pointerType!=='touch')return;
|
||||
ptrIds.add(e.pointerId);
|
||||
if(ptrIds.size===1){ptrStartX=e.clientX;ptrStartY=e.clientY;ptrStartTime=Date.now();ptrPeak=1;}
|
||||
else{ptrPeak=Math.max(ptrPeak,ptrIds.size);}
|
||||
},{passive:true});
|
||||
|
||||
document.addEventListener('pointerup',e=>{
|
||||
if(e.pointerType!=='touch')return;
|
||||
ptrIds.delete(e.pointerId);
|
||||
if(ptrIds.size!==0)return;
|
||||
const deltaTime=Date.now()-ptrStartTime;
|
||||
if(deltaTime>SWIPE_MAX_TIME){ptrPeak=0;return;}
|
||||
const deltaX=e.clientX-ptrStartX;
|
||||
const deltaY=e.clientY-ptrStartY;
|
||||
const absX=Math.abs(deltaX);
|
||||
const absY=Math.abs(deltaY);
|
||||
if(ptrPeak===3&&absY>SWIPE_THRESHOLD&&absX<SWIPE_TOLERANCE&&deltaY>0){
|
||||
console.log('[TOUCH] 3-finger DOWN (ptr) - toggle hidden tabs');
|
||||
ipcRenderer.send('toggle-hidden');
|
||||
}else if(ptrPeak===2&&absX>SWIPE_THRESHOLD&&absY<SWIPE_TOLERANCE){
|
||||
console.log('[TOUCH] 2-finger HORIZONTAL (ptr) - change tab');
|
||||
sendSwipeIPC(deltaX>0?'swipe-right':'swipe-left');
|
||||
}else if(ptrPeak===1&&absX>SWIPE_THRESHOLD&&absY<SWIPE_TOLERANCE){
|
||||
const key=deltaX>0?'ArrowRight':'ArrowLeft';
|
||||
const keyCode=deltaX>0?39:37;
|
||||
['keydown','keyup'].forEach(eventType=>{
|
||||
document.dispatchEvent(new KeyboardEvent(eventType,{
|
||||
key:key,code:key,keyCode:keyCode,which:keyCode,bubbles:true,cancelable:true
|
||||
}));
|
||||
});
|
||||
}
|
||||
ptrPeak=0;
|
||||
},{passive:true});
|
||||
|
||||
// Show pause button on user interaction (for rotation sites only)
|
||||
let lastUserInteraction=0;
|
||||
const USER_INTERACTION_THROTTLE=500;
|
||||
|
||||
function handleUserInteraction(eventType){
|
||||
const now=Date.now();
|
||||
if(now-lastUserInteraction<USER_INTERACTION_THROTTLE)return;
|
||||
lastUserInteraction=now;
|
||||
|
||||
console.log('[PAUSE-BTN] User interaction ('+eventType+') - shouldShow='+pauseButtonShouldShow+', shown='+pauseButtonShown);
|
||||
// Show/refresh pause button if allowed on this site
|
||||
if(pauseButtonShouldShow){
|
||||
if(!pauseButtonShown){
|
||||
console.log('[PAUSE-BTN] Showing pause button now');
|
||||
}else{
|
||||
console.log('[PAUSE-BTN] Resetting auto-hide timer');
|
||||
}
|
||||
showPauseButton(); // This will reset the hide timer
|
||||
}
|
||||
|
||||
// Always show navigation button on user interaction (if enabled)
|
||||
if(navButtonEnabled){
|
||||
showNavButton();
|
||||
}
|
||||
|
||||
// Always show power button on user interaction
|
||||
showPowerButton();
|
||||
}
|
||||
|
||||
// Show pause button on any user interaction
|
||||
const pauseButtonTriggers=['mousedown','touchstart','keydown'];
|
||||
pauseButtonTriggers.forEach(eventType=>{
|
||||
document.addEventListener(eventType,()=>handleUserInteraction(eventType),{passive:true,capture:true});
|
||||
});
|
||||
});
|
||||
Executable
+30
@@ -0,0 +1,30 @@
|
||||
#!/bin/bash
|
||||
cd /home/kiosk/kiosk-app
|
||||
|
||||
# Wait for network
|
||||
for i in {1..30}; do
|
||||
ping -c 1 -W 2 8.8.8.8 >/dev/null 2>&1 && break
|
||||
sleep 2
|
||||
done
|
||||
|
||||
export DISPLAY=:0
|
||||
export XAUTHORITY=/home/kiosk/.Xauthority
|
||||
export ELECTRON_ENABLE_LOGGING=1
|
||||
|
||||
# Ensure PipeWire is running
|
||||
systemctl --user is-active --quiet pipewire || systemctl --user start pipewire
|
||||
systemctl --user is-active --quiet pipewire-pulse || systemctl --user start pipewire-pulse
|
||||
systemctl --user is-active --quiet wireplumber || systemctl --user start wireplumber
|
||||
|
||||
# Wait for PipeWire
|
||||
for i in {1..10}; do
|
||||
pactl info >/dev/null 2>&1 && break
|
||||
sleep 1
|
||||
done
|
||||
|
||||
exec node_modules/electron/dist/electron . \
|
||||
--no-sandbox --disable-gpu-sandbox --disable-dev-shm-usage \
|
||||
--enable-features=UseOzonePlatform --ozone-platform=x11 \
|
||||
--enable-audio-service-sandbox=false --autoplay-policy=no-user-gesture-required \
|
||||
--password-store=basic \
|
||||
2>&1 | tee -a /home/kiosk/electron.log
|
||||
@@ -31,6 +31,15 @@
|
||||
: "${NETPLAN_DIR:=/etc/netplan}"
|
||||
: "${POLKIT_DIR:=/etc/polkit-1/localauthority/50-local.d}"
|
||||
: "${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
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
#!/bin/bash
|
||||
################################################################################
|
||||
# lib/electron.sh - Electron binary install/repair, shared between fresh
|
||||
# provisioning (lib/provision.sh) and ongoing maintenance
|
||||
# (menus/advanced_electron.sh's "Fix blank screen" action) - the exact
|
||||
# same repair sequence applies whether the binary never downloaded during
|
||||
# `npm install` or went missing later.
|
||||
#
|
||||
# Depends on: lib/config.sh being sourced first (for $KIOSK_DIR/$KIOSK_USER).
|
||||
################################################################################
|
||||
|
||||
# Re-verify/download the Electron binary and fix chrome-sandbox
|
||||
# permissions, without touching package.json or reinstalling anything else.
|
||||
electron_install_binary() {
|
||||
local electron_bin="$KIOSK_DIR/node_modules/electron/dist/electron"
|
||||
|
||||
if ! sudo -u "$KIOSK_USER" test -f "$electron_bin"; then
|
||||
log_warning "Electron binary missing - retrying via install.js..."
|
||||
sudo -u "$KIOSK_USER" bash -lc "cd '$KIOSK_DIR' && ELECTRON_FORCE_DOWNLOAD=true node node_modules/electron/install.js" || true
|
||||
fi
|
||||
|
||||
if ! sudo -u "$KIOSK_USER" test -f "$electron_bin"; then
|
||||
log_warning "Attempting direct download of Electron binary (~120MB)..."
|
||||
local electron_ver
|
||||
electron_ver=$(sudo -u "$KIOSK_USER" node -e \
|
||||
"try{console.log(require('$KIOSK_DIR/node_modules/electron/package.json').version)}catch(e){}" 2>/dev/null || true)
|
||||
if [[ -n "$electron_ver" ]]; then
|
||||
local electron_url="https://github.com/electron/electron/releases/download/v${electron_ver}/electron-v${electron_ver}-linux-x64.zip"
|
||||
log_info "Downloading Electron v${electron_ver} directly..."
|
||||
local tmp_zip
|
||||
tmp_zip=$(mktemp --suffix=.zip)
|
||||
if wget --timeout=300 --tries=3 -O "$tmp_zip" "$electron_url"; then
|
||||
command -v unzip &>/dev/null || sudo apt install -y unzip
|
||||
chmod 644 "$tmp_zip"
|
||||
sudo chown -R "$KIOSK_USER:$KIOSK_USER" "$KIOSK_DIR/node_modules/electron/" 2>/dev/null || true
|
||||
sudo -u "$KIOSK_USER" mkdir -p "$KIOSK_DIR/node_modules/electron/dist"
|
||||
sudo -u "$KIOSK_USER" unzip -o "$tmp_zip" -d "$KIOSK_DIR/node_modules/electron/dist/" || true
|
||||
sudo -u "$KIOSK_USER" chmod +x "$electron_bin" || true
|
||||
fi
|
||||
rm -f "$tmp_zip"
|
||||
fi
|
||||
fi
|
||||
|
||||
if ! sudo -u "$KIOSK_USER" test -f "$electron_bin"; then
|
||||
log_error "Electron binary download failed after all attempts."
|
||||
log_error "Check your internet connection and try again."
|
||||
return 1
|
||||
fi
|
||||
log_success "Electron binary verified"
|
||||
|
||||
# chrome-sandbox MUST be owned by root and setuid, or Electron shows a blank screen.
|
||||
local sandbox="$KIOSK_DIR/node_modules/electron/dist/chrome-sandbox"
|
||||
if sudo -u "$KIOSK_USER" test -f "$sandbox"; then
|
||||
sudo chown root:root "$sandbox"
|
||||
sudo chmod 4755 "$sandbox"
|
||||
log_success "Chrome sandbox permissions set (required for display)"
|
||||
fi
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
#!/bin/bash
|
||||
################################################################################
|
||||
# lib/provision.sh - First-time kiosk provisioning: turns a bare Ubuntu
|
||||
# Server box into a working kiosk. This is the piece the modular tool
|
||||
# never had - everything else in menus/*.sh only manages a kiosk that
|
||||
# already exists.
|
||||
#
|
||||
# The legacy ubuntu-based-kiosk.sh did this in one ~4,000-line function
|
||||
# (first_time_install) that mixed three different things together:
|
||||
# 1. ~2,900 lines of embedded app source (main.js, preload.js, 4 HTML
|
||||
# dialogs, package.json, start.sh), written via `sudo tee ... <<'EOF'`.
|
||||
# 2. A few hundred more lines of embedded system scripts/units/configs
|
||||
# (HDMI mirroring, audio routing, hotplug udev rules, power button
|
||||
# handling, etc), written the same way.
|
||||
# 3. The actual provisioning logic - roughly 1,000 lines once (1) and
|
||||
# (2) are out of the way.
|
||||
#
|
||||
# Every one of those embedded files used a quoted heredoc delimiter
|
||||
# (<<'EOF', not <<EOF) - meaning none of them did variable substitution
|
||||
# at write time - so they've been extracted byte-for-byte into real
|
||||
# files: the app source under kiosk-app/, everything else under
|
||||
# provision/files/ (mirroring its real destination path, e.g.
|
||||
# provision/files/etc/X11/xorg.conf.d/foo.conf -> /etc/X11/xorg.conf.d/foo.conf).
|
||||
# This function copies them into place instead of re-embedding them, and
|
||||
# calls straight into the Core Settings / Addons / Advanced menus this
|
||||
# tool already has for configuration - sites, timezone, touch/nav
|
||||
# settings, password protection, WiFi, schedules, emergency hotspot, and
|
||||
# virtual consoles are NOT reimplemented a third time here.
|
||||
#
|
||||
# One known, deliberate limitation carried over unchanged: several of
|
||||
# the extracted system scripts (start.sh, kiosk-hotplug.sh, the power
|
||||
# button handler) hardcode the username "kiosk" rather than using
|
||||
# $KIOSK_USER, exactly as the legacy heredocs did (quoted heredocs can't
|
||||
# substitute at write time either way). Fine for the common case since
|
||||
# $KIOSK_USER is virtually never overridden outside this project's own
|
||||
# tests, but a real gap if someone ever does. Not fixed here - fixing it
|
||||
# means moving those scripts off static templates onto generated ones,
|
||||
# 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
|
||||
# (including menus/addon_webui.sh, for provision_configure_webui below).
|
||||
################################################################################
|
||||
|
||||
PROVISION_FILES="$SCRIPT_DIR/provision/files"
|
||||
KIOSK_APP_SRC="$SCRIPT_DIR/kiosk-app"
|
||||
|
||||
# provision_install_file SRC_REL DEST [MODE]
|
||||
# Copies a template from provision/files/SRC_REL to the real system path
|
||||
# DEST (creating parent directories as needed) as root, mode 644 unless
|
||||
# overridden - pass 755 for scripts, 750 for the openbox autostart.
|
||||
provision_install_file() {
|
||||
local src_rel="$1" dest="$2" mode="${3:-644}"
|
||||
sudo install -D -m "$mode" "$PROVISION_FILES/$src_rel" "$dest"
|
||||
}
|
||||
|
||||
provision_install_packages() {
|
||||
echo "[1/10] Installing packages..."
|
||||
sudo apt update
|
||||
sudo apt install -y \
|
||||
xorg openbox lightdm unclutter screen curl git build-essential \
|
||||
ca-certificates gnupg lsb-release jq ufw x11-xserver-utils xinput \
|
||||
vainfo mesa-utils libgl1-mesa-dri libglx-mesa0 mesa-vulkan-drivers \
|
||||
libva2 libva-drm2 libva-x11-2 mesa-va-drivers \
|
||||
libegl-mesa0 libegl1-mesa-dev libgles2-mesa-dev \
|
||||
pipewire pipewire-pulse pipewire-alsa wireplumber pipewire-audio-client-libraries alsa-utils libnotify-bin \
|
||||
gstreamer1.0-pipewire libspa-0.2-bluetooth \
|
||||
systemd-timesyncd acpid xbindkeys xdotool python3-evdev unzip \
|
||||
net-tools ncdu evtest
|
||||
|
||||
if lspci | grep -i "VGA.*Intel" >/dev/null 2>&1; then
|
||||
sudo apt install -y intel-gpu-tools xserver-xorg-video-intel \
|
||||
i965-va-driver intel-media-va-driver
|
||||
provision_install_file "etc/X11/xorg.conf.d/20-intel.conf" /etc/X11/xorg.conf.d/20-intel.conf
|
||||
fi
|
||||
|
||||
sudo systemctl enable systemd-timesyncd
|
||||
sudo systemctl start systemd-timesyncd
|
||||
log_success "Packages installed, NTP time sync enabled"
|
||||
}
|
||||
|
||||
provision_create_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
|
||||
log_success "Kiosk user created (default password: kiosk - change it)"
|
||||
else
|
||||
log_success "Kiosk user already exists"
|
||||
fi
|
||||
|
||||
sudo mkdir -p "$KIOSK_DIR"
|
||||
sudo chown -R "$KIOSK_USER:$KIOSK_USER" "$KIOSK_HOME"
|
||||
|
||||
sudo -u "$KIOSK_USER" mkdir -p "$KIOSK_HOME/.config/pipewire/pipewire.conf.d"
|
||||
provision_install_file "pipewire/99-noise-cancellation.conf" \
|
||||
"$KIOSK_HOME/.config/pipewire/pipewire.conf.d/99-noise-cancellation.conf"
|
||||
sudo chown -R "$KIOSK_USER:$KIOSK_USER" "$KIOSK_HOME/.config"
|
||||
}
|
||||
|
||||
provision_install_nodejs() {
|
||||
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
|
||||
fi
|
||||
echo "Node.js: $(node -v)"
|
||||
}
|
||||
|
||||
provision_install_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"
|
||||
|
||||
echo "Installing npm dependencies (Electron ~120MB - may take several minutes)..."
|
||||
sudo -u "$KIOSK_USER" bash -lc "
|
||||
npm config set fetch-timeout 600000
|
||||
npm config set fetch-retries 5
|
||||
npm config set fetch-retry-mintimeout 30000
|
||||
npm config set fetch-retry-maxtimeout 300000
|
||||
"
|
||||
if ! sudo -u "$KIOSK_USER" bash -lc "cd '$KIOSK_DIR' && npm install --unsafe-perm"; then
|
||||
log_error "npm install failed"
|
||||
return 1
|
||||
fi
|
||||
|
||||
electron_install_binary
|
||||
}
|
||||
|
||||
# Not moved to lib/electron.sh or anywhere else - this is the one piece
|
||||
# of first-time setup with no modular equivalent to call into, and
|
||||
# nothing else needs it.
|
||||
provision_configure_lightdm_autologin() {
|
||||
sudo mkdir -p /etc/lightdm/lightdm.conf.d
|
||||
# nopasswdlogin is checked by PAM on Ubuntu 24.04; autologin group for older versions
|
||||
sudo groupadd -f nopasswdlogin
|
||||
sudo groupadd -f autologin
|
||||
sudo usermod -aG nopasswdlogin,autologin "$KIOSK_USER"
|
||||
# [Seat:*] works on all LightDM versions; [SeatDefaults] is ignored on newer Ubuntu
|
||||
sudo tee /etc/lightdm/lightdm.conf.d/10-kiosk.conf > /dev/null <<EOF
|
||||
[Seat:*]
|
||||
autologin-user=$KIOSK_USER
|
||||
autologin-user-timeout=0
|
||||
user-session=openbox
|
||||
autologin-session=openbox
|
||||
greeter-hide-users=true
|
||||
greeter-show-manual-login=false
|
||||
allow-guest=false
|
||||
EOF
|
||||
}
|
||||
|
||||
provision_configure_display() {
|
||||
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
|
||||
|
||||
# Touch screens always use libinput (proper multitouch for Chromium
|
||||
# gestures) and virtual consoles start enabled - matches the choice
|
||||
# a fresh kiosk ships with; menus/advanced_virtual_consoles.sh can
|
||||
# flip this later, and does below if declined at the end of setup.
|
||||
provision_install_file "etc/X11/xorg.conf.d/99-finger-libinput.conf" /etc/X11/xorg.conf.d/99-finger-libinput.conf
|
||||
provision_install_file "etc/X11/xorg.conf.d/10-serverflags.conf" /etc/X11/xorg.conf.d/10-serverflags.conf
|
||||
|
||||
provision_install_file "usr/local/bin/kiosk-mirror-display.sh" /usr/local/bin/kiosk-mirror-display.sh 755
|
||||
provision_install_file "usr/local/bin/kiosk-audio-route.sh" /usr/local/bin/kiosk-audio-route.sh 755
|
||||
|
||||
provision_install_file "openbox/autostart" "$KIOSK_HOME/.config/openbox/autostart" 750
|
||||
sudo chown "$KIOSK_USER:$KIOSK_USER" "$KIOSK_HOME/.config/openbox/autostart"
|
||||
sudo -u "$KIOSK_USER" touch "$KIOSK_HOME/.xbindkeysrc"
|
||||
|
||||
# HDMI hotplug: re-mirror any newly connected external display without
|
||||
# waiting for the next login, triggered by udev on DRM "change" events.
|
||||
provision_install_file "usr/local/bin/kiosk-hotplug.sh" /usr/local/bin/kiosk-hotplug.sh 755
|
||||
provision_install_file "etc/systemd/system/kiosk-hotplug.service" /etc/systemd/system/kiosk-hotplug.service
|
||||
provision_install_file "etc/udev/rules.d/99-kiosk-hotplug.rules" /etc/udev/rules.d/99-kiosk-hotplug.rules
|
||||
sudo systemctl daemon-reload
|
||||
sudo udevadm control --reload-rules
|
||||
|
||||
provision_configure_lightdm_autologin
|
||||
|
||||
log_success "Display configured"
|
||||
}
|
||||
|
||||
provision_configure_firewall() {
|
||||
echo "[6/10] Configuring firewall..."
|
||||
sudo ufw --force enable
|
||||
sudo ufw default deny incoming
|
||||
sudo ufw default allow outgoing
|
||||
sudo ufw allow ssh
|
||||
log_success "Firewall configured (SSH allowed, incoming denied by default)"
|
||||
}
|
||||
|
||||
provision_configure_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
|
||||
|
||||
provision_install_file "usr/local/bin/kiosk-volume-up" /usr/local/bin/kiosk-volume-up 755
|
||||
provision_install_file "usr/local/bin/kiosk-volume-down" /usr/local/bin/kiosk-volume-down 755
|
||||
|
||||
provision_install_file "usr/local/bin/kiosk-power-button.sh" /usr/local/bin/kiosk-power-button.sh 755
|
||||
# Backwards-compatible second location some older docs/scripts reference.
|
||||
sudo cp /usr/local/bin/kiosk-power-button.sh "$KIOSK_HOME/trigger-power-menu.sh"
|
||||
sudo chown "$KIOSK_USER:$KIOSK_USER" "$KIOSK_HOME/trigger-power-menu.sh"
|
||||
provision_install_file "usr/local/bin/test-power-button" /usr/local/bin/test-power-button 755
|
||||
|
||||
sudo rm -f /etc/acpi/events/powerbtn* /etc/acpi/events/power* 2>/dev/null || true
|
||||
provision_install_file "etc/acpi/events/kiosk-power-button" /etc/acpi/events/kiosk-power-button
|
||||
provision_install_file "etc/acpi/events/kiosk-power-pbtn" /etc/acpi/events/kiosk-power-pbtn
|
||||
provision_install_file "etc/acpi/events/kiosk-power-pwr" /etc/acpi/events/kiosk-power-pwr
|
||||
|
||||
sudo mkdir -p /etc/systemd/logind.conf.d
|
||||
provision_install_file "etc/systemd/logind.conf.d/power-button.conf" /etc/systemd/logind.conf.d/power-button.conf
|
||||
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl restart systemd-logind
|
||||
sudo systemctl enable acpid
|
||||
sudo systemctl restart acpid
|
||||
|
||||
sleep 2
|
||||
if systemctl is-active --quiet acpid; then
|
||||
log_success "Power button configured"
|
||||
else
|
||||
log_warning "acpid may not be running properly - check: sudo systemctl status acpid"
|
||||
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 "[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."
|
||||
echo
|
||||
if ask_yes_no "Configure Core Settings now?" "y"; then
|
||||
core_settings_menu
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "Emergency Hotspot auto-starts a WiFi hotspot if no internet is"
|
||||
echo "detected after boot, so you can connect and reconfigure remotely."
|
||||
if ask_yes_no "Configure emergency hotspot now?" "n"; then
|
||||
action_configure_emergency_hotspot
|
||||
else
|
||||
log_info "Configure later: Advanced -> Emergency Hotspot"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "Virtual consoles (Ctrl+Alt+F1-F8) are ENABLED by default."
|
||||
if ! ask_yes_no "Keep virtual consoles enabled?" "y"; then
|
||||
action_disable_virtual_consoles
|
||||
fi
|
||||
}
|
||||
|
||||
provision_finish() {
|
||||
echo "[10/10] Done."
|
||||
echo
|
||||
log_success "Core installation complete!"
|
||||
echo
|
||||
echo "Run ./install.sh again anytime to configure Core Settings, Addons"
|
||||
echo "(CUPS, LMS/Squeezelite, Remote Access, Authelia, Asterisk Intercom),"
|
||||
echo "or Advanced options."
|
||||
echo
|
||||
if ask_yes_no "Reboot now to start the kiosk?" "y"; then
|
||||
echo "Rebooting in 3 seconds..."
|
||||
sleep 3
|
||||
sudo reboot
|
||||
else
|
||||
log_warning "Remember to reboot before the kiosk will start: sudo reboot"
|
||||
fi
|
||||
}
|
||||
|
||||
run_first_time_install() {
|
||||
clear
|
||||
echo "════════════════════════════════════════════════════════════"
|
||||
echo " Ubuntu Based Kiosk - First-Time Installation"
|
||||
echo "════════════════════════════════════════════════════════════"
|
||||
echo
|
||||
echo "This will install a HEADLESS KIOSK (no desktop environment):"
|
||||
echo
|
||||
echo "CORE:"
|
||||
echo " - Kiosk user with auto-login"
|
||||
echo " - LightDM + Openbox (minimal window manager)"
|
||||
echo " - Electron browser"
|
||||
echo " - Multi-site rotation with touch controls"
|
||||
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"
|
||||
echo " - CUPS printing"
|
||||
echo " - Remote desktop (VNC), VPN (WireGuard/Tailscale/Netbird)"
|
||||
echo " - Authelia auto-login, Asterisk Intercom"
|
||||
echo
|
||||
ask_yes_no "Proceed with installation?" "y" || { echo "Cancelled"; return 1; }
|
||||
|
||||
# Cache sudo credentials upfront so they don't expire mid-install.
|
||||
sudo -v
|
||||
|
||||
provision_install_packages
|
||||
provision_create_kiosk_user
|
||||
provision_install_nodejs
|
||||
# Bare call, not `if ! provision_install_app; then ...`: testing a
|
||||
# multi-statement function's result as an if-condition exempts
|
||||
# everything *inside* that function from set -e for the duration -
|
||||
# an early step failing (e.g. the `cp` before npm install even
|
||||
# runs) would silently not stop the later steps. provision_install_app
|
||||
# already reports its own npm-install failure via a guarded
|
||||
# single-command `if`, which doesn't have this problem; letting its
|
||||
# overall exit status propagate here as a bare statement preserves
|
||||
# real fail-fast for every step in between.
|
||||
provision_install_app
|
||||
provision_configure_display
|
||||
provision_configure_firewall
|
||||
provision_configure_power_management
|
||||
provision_configure_webui
|
||||
provision_configure_kiosk_settings
|
||||
provision_finish
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
#!/bin/bash
|
||||
################################################################################
|
||||
# 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 - 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, 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, 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.
|
||||
################################################################################
|
||||
|
||||
webui_is_installed() {
|
||||
[[ -f "$SYSTEMD_DIR/kiosk-webui.service" ]]
|
||||
}
|
||||
|
||||
webui_is_active() {
|
||||
systemctl is-active --quiet kiosk-webui
|
||||
}
|
||||
|
||||
webui_port() {
|
||||
if sudo test -f "$WEBUI_ENV_DIR/webui.env" 2>/dev/null; then
|
||||
sudo grep -oP '^PORT=\K.*' "$WEBUI_ENV_DIR/webui.env" 2>/dev/null || echo "8090"
|
||||
else
|
||||
echo "8090"
|
||||
fi
|
||||
}
|
||||
|
||||
addon_webui_status() {
|
||||
if webui_is_installed && webui_is_active; then
|
||||
echo "Web UI: running at http://$(get_ip_address):$(webui_port)"
|
||||
elif webui_is_installed; then
|
||||
echo "Web UI: installed but not running"
|
||||
else
|
||||
echo "Web UI: not installed"
|
||||
fi
|
||||
}
|
||||
|
||||
addon_webui_menu_builder() {
|
||||
if webui_is_installed; then
|
||||
MENU_LABELS=("Change port" "Restart service" "Uninstall")
|
||||
MENU_HANDLERS=(action_webui_reconfigure action_webui_restart action_webui_uninstall)
|
||||
else
|
||||
MENU_LABELS=("Install Web UI")
|
||||
MENU_HANDLERS=(action_webui_install)
|
||||
fi
|
||||
}
|
||||
|
||||
addon_webui_menu() {
|
||||
run_menu "WEB UI" addon_webui_menu_builder addon_webui_status
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Shared install helpers
|
||||
################################################################################
|
||||
|
||||
# Copies webui/ from this checkout onto the kiosk and installs its npm
|
||||
# dependencies as $KIOSK_USER - same shape as provision_install_app in
|
||||
# lib/provision.sh, but addon-scoped (opt-in) rather than core. Every
|
||||
# critical step is individually guarded (matches menus/addon_cups.sh's
|
||||
# action_install_cups) rather than relying on set -e to stop a bare
|
||||
# sequence - menu actions are always invoked via run_menu's
|
||||
# `"${MENU_HANDLERS[...]}" "$choice" || true` dispatch, which already
|
||||
# exempts everything they do from set -e for the whole call, so a bare
|
||||
# unguarded sequence here would silently continue past a real failure
|
||||
# (e.g. attempting npm install into a directory `cp` never populated).
|
||||
webui_install_app_files() {
|
||||
if ! sudo mkdir -p "$WEBUI_DIR"; then
|
||||
log_error "Could not create $WEBUI_DIR"
|
||||
return 1
|
||||
fi
|
||||
if ! sudo cp -r "$SCRIPT_DIR/webui/." "$WEBUI_DIR/"; then
|
||||
log_error "Could not copy Web UI app files"
|
||||
return 1
|
||||
fi
|
||||
sudo chown -R "$KIOSK_USER:$KIOSK_USER" "$WEBUI_DIR"
|
||||
if ! sudo -u "$KIOSK_USER" bash -lc "cd '$WEBUI_DIR' && npm install --omit=dev --unsafe-perm"; then
|
||||
log_error "npm install failed"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
webui_write_env_file() {
|
||||
local port="$1"
|
||||
sudo mkdir -p "$WEBUI_ENV_DIR"
|
||||
sudo tee "$WEBUI_ENV_DIR/webui.env" > /dev/null <<EOF
|
||||
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]
|
||||
Description=Kiosk Web UI
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=${KIOSK_USER}
|
||||
WorkingDirectory=${WEBUI_DIR}
|
||||
EnvironmentFile=${WEBUI_ENV_DIR}/webui.env
|
||||
ExecStart=/usr/bin/node ${WEBUI_DIR}/server.js
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Actions
|
||||
################################################################################
|
||||
|
||||
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 - 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; }
|
||||
|
||||
local port
|
||||
port=$(ask_integer "Port to listen on" "8090" 1024 65535)
|
||||
|
||||
echo "Installing app files and npm dependencies..."
|
||||
if ! webui_install_app_files; then
|
||||
log_error "Web UI install failed"
|
||||
return 1
|
||||
fi
|
||||
|
||||
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
|
||||
log_success "Web UI installed: http://$(get_ip_address):${port}"
|
||||
else
|
||||
log_error "Web UI installed but failed to start - check: sudo journalctl -u kiosk-webui -n 50"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
action_webui_reconfigure() {
|
||||
echo
|
||||
local current_port
|
||||
current_port=$(webui_port)
|
||||
local port
|
||||
port=$(ask_integer "Port to listen on" "$current_port" 1024 65535)
|
||||
|
||||
if [[ "$port" == "$current_port" ]]; then
|
||||
echo "No change"
|
||||
return
|
||||
fi
|
||||
|
||||
webui_write_env_file "$port"
|
||||
sudo systemctl restart kiosk-webui
|
||||
sudo ufw allow "${port}/tcp" comment 'Kiosk Web UI' 2>/dev/null || true
|
||||
log_success "Web UI now listening on port ${port}"
|
||||
}
|
||||
|
||||
action_webui_restart() {
|
||||
sudo systemctl restart kiosk-webui
|
||||
sleep 1
|
||||
if webui_is_active; then
|
||||
log_success "Web UI restarted"
|
||||
else
|
||||
log_error "Web UI failed to restart - check: sudo journalctl -u kiosk-webui -n 50"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
action_webui_uninstall() {
|
||||
echo
|
||||
ask_yes_no "Uninstall the Web UI?" "n" || { echo "Cancelled"; return; }
|
||||
webui_do_uninstall
|
||||
}
|
||||
|
||||
# The actual removal, no prompt - shared with Complete Uninstall so that
|
||||
# operation doesn't need to re-implement Web UI teardown a second time.
|
||||
# No `ufw delete` - matches every other addon's uninstall in this
|
||||
# codebase, which never removes its own firewall rule either.
|
||||
webui_do_uninstall() {
|
||||
sudo systemctl stop kiosk-webui 2>/dev/null || true
|
||||
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"
|
||||
}
|
||||
+16
-53
@@ -2,15 +2,18 @@
|
||||
################################################################################
|
||||
# menus/advanced_electron.sh - "Electron Maintenance" (Advanced): the
|
||||
# legacy "Manual Electron Update" and "Fix Blank Screen" items, combined
|
||||
# under one submenu since both maintain the same Electron installation
|
||||
# and share the binary-repair logic (electron_install_binary).
|
||||
# under one submenu since both maintain the same Electron installation.
|
||||
# The binary-repair logic itself (electron_install_binary) now lives in
|
||||
# lib/electron.sh, shared with fresh provisioning (lib/provision.sh) -
|
||||
# the same repair sequence applies whether the binary never downloaded
|
||||
# during the initial `npm install` or went missing later.
|
||||
#
|
||||
# Real system state: $KIOSK_DIR/node_modules, package.json, lightdm.
|
||||
# Every write goes through `sudo`/`sudo -u "$KIOSK_USER"`, stubbed at the
|
||||
# command level in tests - there's no relocatable equivalent for another
|
||||
# project's (npm/Electron's) own directory layout.
|
||||
#
|
||||
# Depends on: lib/menu.sh, lib/config.sh being sourced first.
|
||||
# Depends on: lib/menu.sh, lib/config.sh, lib/electron.sh being sourced first.
|
||||
################################################################################
|
||||
|
||||
electron_installed_version() {
|
||||
@@ -35,55 +38,6 @@ electron_is_running() {
|
||||
pgrep -f "electron.*main.js" &>/dev/null || pgrep -f "node.*electron" &>/dev/null
|
||||
}
|
||||
|
||||
# Re-verify/download the Electron binary and fix chrome-sandbox
|
||||
# permissions, without touching package.json or reinstalling anything
|
||||
# else. Shared by both actions below.
|
||||
electron_install_binary() {
|
||||
local electron_bin="$KIOSK_DIR/node_modules/electron/dist/electron"
|
||||
|
||||
if ! sudo -u "$KIOSK_USER" test -f "$electron_bin"; then
|
||||
log_warning "Electron binary missing - retrying via install.js..."
|
||||
sudo -u "$KIOSK_USER" bash -lc "cd '$KIOSK_DIR' && ELECTRON_FORCE_DOWNLOAD=true node node_modules/electron/install.js" || true
|
||||
fi
|
||||
|
||||
if ! sudo -u "$KIOSK_USER" test -f "$electron_bin"; then
|
||||
log_warning "Attempting direct download of Electron binary (~120MB)..."
|
||||
local electron_ver
|
||||
electron_ver=$(sudo -u "$KIOSK_USER" node -e \
|
||||
"try{console.log(require('$KIOSK_DIR/node_modules/electron/package.json').version)}catch(e){}" 2>/dev/null || true)
|
||||
if [[ -n "$electron_ver" ]]; then
|
||||
local electron_url="https://github.com/electron/electron/releases/download/v${electron_ver}/electron-v${electron_ver}-linux-x64.zip"
|
||||
log_info "Downloading Electron v${electron_ver} directly..."
|
||||
local tmp_zip
|
||||
tmp_zip=$(mktemp --suffix=.zip)
|
||||
if wget --timeout=300 --tries=3 -O "$tmp_zip" "$electron_url"; then
|
||||
command -v unzip &>/dev/null || sudo apt install -y unzip
|
||||
chmod 644 "$tmp_zip"
|
||||
sudo chown -R "$KIOSK_USER:$KIOSK_USER" "$KIOSK_DIR/node_modules/electron/" 2>/dev/null || true
|
||||
sudo -u "$KIOSK_USER" mkdir -p "$KIOSK_DIR/node_modules/electron/dist"
|
||||
sudo -u "$KIOSK_USER" unzip -o "$tmp_zip" -d "$KIOSK_DIR/node_modules/electron/dist/" || true
|
||||
sudo -u "$KIOSK_USER" chmod +x "$electron_bin" || true
|
||||
fi
|
||||
rm -f "$tmp_zip"
|
||||
fi
|
||||
fi
|
||||
|
||||
if ! sudo -u "$KIOSK_USER" test -f "$electron_bin"; then
|
||||
log_error "Electron binary download failed after all attempts."
|
||||
log_error "Check your internet connection and try again."
|
||||
return 1
|
||||
fi
|
||||
log_success "Electron binary verified"
|
||||
|
||||
# chrome-sandbox MUST be owned by root and setuid, or Electron shows a blank screen.
|
||||
local sandbox="$KIOSK_DIR/node_modules/electron/dist/chrome-sandbox"
|
||||
if sudo -u "$KIOSK_USER" test -f "$sandbox"; then
|
||||
sudo chown root:root "$sandbox"
|
||||
sudo chmod 4755 "$sandbox"
|
||||
log_success "Chrome sandbox permissions set (required for display)"
|
||||
fi
|
||||
}
|
||||
|
||||
advanced_electron_status() {
|
||||
local ver
|
||||
ver=$(electron_installed_version)
|
||||
@@ -258,7 +212,16 @@ action_repair_electron() {
|
||||
sudo systemctl stop lightdm 2>/dev/null || true
|
||||
sleep 1
|
||||
|
||||
if ! electron_install_binary; then
|
||||
# Bare call, not `if ! electron_install_binary; then`: testing a
|
||||
# multi-statement function as an if-condition exempts everything
|
||||
# inside it from set -e for the duration (e.g. the sandbox chown/
|
||||
# chmod below would silently continue past an earlier failure).
|
||||
# Capturing $? right after a bare call doesn't have that problem -
|
||||
# the exemption only affects whether a nonzero status halts the
|
||||
# script, never the actual value $? holds.
|
||||
electron_install_binary
|
||||
local electron_rc=$?
|
||||
if [[ $electron_rc -ne 0 ]]; then
|
||||
log_error "Could not install Electron. Check internet and retry."
|
||||
pause
|
||||
return 1
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
#!/bin/bash
|
||||
################################################################################
|
||||
# menus/advanced_upgrade.sh - "Upgrade" (Advanced): pull the latest code
|
||||
# from git and re-apply provisioning, plus an on-demand Electron update.
|
||||
#
|
||||
# ubuntu-based-kiosk.sh's Upgrade extracted fresh copies of main.js/
|
||||
# preload.js/etc from its own heredocs on every run - the modular tool
|
||||
# has no heredocs to extract from. kiosk-app/ and provision/files/ are
|
||||
# real files in this git checkout, so "get whatever's new" is just
|
||||
# `git pull` followed by re-running the same steps lib/provision.sh
|
||||
# already has for a fresh install - reused here, not reimplemented,
|
||||
# minus the interactive first-run settings wizard and the "reboot now"
|
||||
# prompt (upgrading shouldn't re-ask sites/hotspot/vconsoles or reboot
|
||||
# the whole machine). Electron itself isn't versioned by this repo, so
|
||||
# checking for a newer Electron is a separate step: the existing,
|
||||
# already-tested action_update_electron (menus/advanced_electron.sh),
|
||||
# reused as-is rather than duplicated.
|
||||
#
|
||||
# Depends on: lib/menu.sh, lib/config.sh, lib/electron.sh,
|
||||
# lib/provision.sh, menus/advanced_electron.sh being sourced first.
|
||||
################################################################################
|
||||
|
||||
advanced_upgrade_status() {
|
||||
if git -C "$SCRIPT_DIR" rev-parse --is-inside-work-tree &>/dev/null; then
|
||||
local branch rev
|
||||
branch=$(git -C "$SCRIPT_DIR" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown")
|
||||
rev=$(git -C "$SCRIPT_DIR" rev-parse --short HEAD 2>/dev/null || echo "unknown")
|
||||
echo "Installed from git: $branch @ $rev"
|
||||
else
|
||||
echo "Installed from git: not a git checkout (upgrade unavailable)"
|
||||
fi
|
||||
}
|
||||
|
||||
advanced_upgrade_menu_builder() {
|
||||
MENU_LABELS=("Check for and apply updates")
|
||||
MENU_HANDLERS=(action_upgrade)
|
||||
}
|
||||
|
||||
advanced_upgrade_menu() {
|
||||
run_menu "UPGRADE" advanced_upgrade_menu_builder advanced_upgrade_status
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Actions
|
||||
################################################################################
|
||||
|
||||
action_upgrade() {
|
||||
echo
|
||||
if ! command -v git &>/dev/null; then
|
||||
log_error "git is not installed - can't check for updates"
|
||||
pause
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! git -C "$SCRIPT_DIR" rev-parse --is-inside-work-tree &>/dev/null; then
|
||||
log_error "$SCRIPT_DIR is not a git checkout - re-clone the repo to get this feature"
|
||||
pause
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ -n "$(git -C "$SCRIPT_DIR" status --porcelain)" ]]; then
|
||||
log_error "Local changes in $SCRIPT_DIR - commit or discard them first, then retry"
|
||||
pause
|
||||
return 1
|
||||
fi
|
||||
|
||||
log_info "Checking for updates..."
|
||||
if ! git -C "$SCRIPT_DIR" fetch origin; then
|
||||
log_error "Could not reach GitHub - check your internet connection"
|
||||
pause
|
||||
return 1
|
||||
fi
|
||||
|
||||
local branch local_rev remote_rev
|
||||
branch=$(git -C "$SCRIPT_DIR" rev-parse --abbrev-ref HEAD)
|
||||
local_rev=$(git -C "$SCRIPT_DIR" rev-parse HEAD)
|
||||
remote_rev=$(git -C "$SCRIPT_DIR" rev-parse "origin/$branch" 2>/dev/null || true)
|
||||
|
||||
if [[ -z "$remote_rev" ]]; then
|
||||
log_error "Could not find origin/$branch - is this checkout tracking a real branch?"
|
||||
pause
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ "$local_rev" == "$remote_rev" ]]; then
|
||||
log_success "Already up to date ($branch @ ${local_rev:0:8})"
|
||||
else
|
||||
echo
|
||||
echo "Changes available:"
|
||||
git -C "$SCRIPT_DIR" --no-pager log --oneline "${local_rev}..${remote_rev}"
|
||||
echo
|
||||
if ask_yes_no "Pull these changes and re-apply setup (packages, app files, hardware config)?" "y"; then
|
||||
if ! git -C "$SCRIPT_DIR" pull --ff-only origin "$branch"; then
|
||||
log_error "Pull failed (not a fast-forward) - resolve manually in $SCRIPT_DIR"
|
||||
pause
|
||||
return 1
|
||||
fi
|
||||
log_success "Pulled latest code ($branch @ $(git -C "$SCRIPT_DIR" rev-parse --short HEAD))"
|
||||
|
||||
echo
|
||||
log_info "Re-applying setup with the updated code..."
|
||||
provision_install_packages
|
||||
provision_create_kiosk_user
|
||||
provision_install_nodejs
|
||||
|
||||
# Bare call, not `if ! provision_install_app; then`: testing a
|
||||
# multi-statement function as an if-condition exempts
|
||||
# everything inside it from set -e for the duration (see
|
||||
# lib/provision.sh's own call to this same function). $? is
|
||||
# captured right after instead - accurate either way, and
|
||||
# doesn't add a new exemption on top of the one this action
|
||||
# already has from being invoked through run_menu's dispatch.
|
||||
provision_install_app
|
||||
local app_rc=$?
|
||||
if [[ $app_rc -ne 0 ]]; then
|
||||
log_error "Upgrade stopped - app reinstall failed, see above"
|
||||
pause
|
||||
return 1
|
||||
fi
|
||||
|
||||
provision_configure_display
|
||||
provision_configure_firewall
|
||||
provision_configure_power_management
|
||||
log_success "Setup refreshed"
|
||||
|
||||
echo
|
||||
if ask_yes_no "Restart kiosk display now to apply changes?" "y"; then
|
||||
sudo systemctl restart lightdm
|
||||
else
|
||||
log_info "Restart later with: sudo systemctl restart lightdm"
|
||||
fi
|
||||
else
|
||||
echo "Cancelled"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo
|
||||
if ask_yes_no "Check for and install the latest Electron version too?" "y"; then
|
||||
action_update_electron
|
||||
return
|
||||
fi
|
||||
|
||||
pause
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
#!/bin/bash
|
||||
################################################################################
|
||||
# menus/clone_settings.sh - "Clone Settings" (Advanced): export the portable
|
||||
# parts of this kiosk's configuration to a JSON file, and apply that file
|
||||
# to other already-installed kiosks - for standing up several kiosks that
|
||||
# should share the same sites/settings.
|
||||
#
|
||||
# This is deliberately an MVP, not the legacy Export/Import Settings
|
||||
# redesigned 1:1. It moves only what's actually safe to copy between
|
||||
# machines automatically:
|
||||
# - config.json's portable fields (sites, display/touch, navigation,
|
||||
# lockout, password protection) - plain data, no machine binding.
|
||||
# - Which addons were present at export time, as an informational
|
||||
# checklist on apply - NOT automated installation. That's a
|
||||
# deliberately separate, bigger follow-up (each addon would need a
|
||||
# non-interactive install variant, mirroring the *_do_uninstall
|
||||
# helpers Complete Uninstall already composes).
|
||||
#
|
||||
# Explicitly NOT exported, because copying them would be actively wrong,
|
||||
# not just incomplete:
|
||||
# - Authelia credentials: encrypted with a key derived from this
|
||||
# machine's /etc/machine-id (addon_authelia.sh) - decrypts to
|
||||
# garbage on any other machine.
|
||||
# - WireGuard/VPN identity: a private key is that device's identity;
|
||||
# reusing one across machines is a peer conflict, not a saving.
|
||||
# - Asterisk Intercom's SIP extension: most PBXes reject two
|
||||
# simultaneous registrations to the same extension.
|
||||
# Apply prints all three as an explicit "needs a human" checklist rather
|
||||
# than silently skipping them.
|
||||
#
|
||||
# Depends on: lib/menu.sh, lib/config.sh, and every menus/addon_*.sh
|
||||
# being sourced first (for the *_is_installed detection helpers).
|
||||
################################################################################
|
||||
|
||||
clone_settings_status() {
|
||||
echo "Exports/applies sites, display, navigation, and lockout settings"
|
||||
echo "between already-installed kiosks. Addon credentials that are"
|
||||
echo "bound to one machine (Authelia, WireGuard, Asterisk Intercom)"
|
||||
echo "are never copied - see the checklist after applying a profile."
|
||||
}
|
||||
|
||||
clone_settings_menu_builder() {
|
||||
MENU_LABELS=("Export settings" "Apply settings (clone)")
|
||||
MENU_HANDLERS=(action_export_clone_settings action_apply_clone_settings)
|
||||
}
|
||||
|
||||
clone_settings_menu() {
|
||||
run_menu "CLONE SETTINGS" clone_settings_menu_builder clone_settings_status
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Helpers
|
||||
################################################################################
|
||||
|
||||
# JSON array of addon identifiers currently present on this machine.
|
||||
clone_detect_addons() {
|
||||
local addons=()
|
||||
|
||||
cups_is_installed 2>/dev/null && addons+=("cups")
|
||||
lms_is_installed 2>/dev/null && addons+=("lms")
|
||||
squeezelite_is_installed 2>/dev/null && addons+=("squeezelite")
|
||||
is_service_active x11vnc 2>/dev/null && addons+=("vnc")
|
||||
command -v wg &>/dev/null && addons+=("wireguard")
|
||||
command -v tailscale &>/dev/null && addons+=("tailscale")
|
||||
command -v netbird &>/dev/null && addons+=("netbird")
|
||||
baresip_is_installed 2>/dev/null && addons+=("asterisk_intercom")
|
||||
webui_is_installed 2>/dev/null && addons+=("webui")
|
||||
|
||||
if sudo -u "$KIOSK_USER" test -f "$CONFIG_PATH" 2>/dev/null; then
|
||||
local authelia_url
|
||||
authelia_url=$(sudo -u "$KIOSK_USER" jq -r '.autheliaURL // ""' "$CONFIG_PATH" 2>/dev/null || true)
|
||||
[[ -n "$authelia_url" ]] && addons+=("authelia")
|
||||
fi
|
||||
|
||||
if [[ "${#addons[@]}" -eq 0 ]]; then
|
||||
echo "[]"
|
||||
else
|
||||
printf '%s\n' "${addons[@]}" | jq -R . | jq -s . || echo "[]"
|
||||
fi
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Actions
|
||||
################################################################################
|
||||
|
||||
action_export_clone_settings() {
|
||||
echo
|
||||
if ! sudo -u "$KIOSK_USER" test -f "$CONFIG_PATH" 2>/dev/null; then
|
||||
log_error "config.json not found at $CONFIG_PATH - configure sites/settings first"
|
||||
pause
|
||||
return 1
|
||||
fi
|
||||
|
||||
local out_path
|
||||
out_path=$(ask_text "Export profile to" "$HOME/kiosk-clone-settings.json")
|
||||
|
||||
local raw_config
|
||||
raw_config=$(sudo -u "$KIOSK_USER" cat "$CONFIG_PATH" 2>/dev/null)
|
||||
if ! echo "$raw_config" | jq empty 2>/dev/null; then
|
||||
log_error "config.json is not valid JSON - cannot export"
|
||||
pause
|
||||
return 1
|
||||
fi
|
||||
|
||||
local settings
|
||||
settings=$(echo "$raw_config" | jq 'del(.autheliaURL, .autheliaUsername, .autheliaEncryptedPassword)')
|
||||
|
||||
local addons_present
|
||||
addons_present=$(clone_detect_addons)
|
||||
|
||||
jq -n \
|
||||
--argjson settings "$settings" \
|
||||
--argjson addons "$addons_present" \
|
||||
--arg script_version "${SCRIPT_VERSION:-unknown}" \
|
||||
'{profile_version: 1, script_version: $script_version, settings: $settings, addons_present: $addons}' \
|
||||
> "$out_path"
|
||||
|
||||
log_success "Settings exported to $out_path"
|
||||
echo
|
||||
echo "Included: sites, display/touch/navigation settings, lockout,"
|
||||
echo "password protection (SHA-256 hash only)."
|
||||
echo
|
||||
echo "NOT included (needs fresh setup on each new device):"
|
||||
echo " - Authelia credentials (encrypted per-machine, won't decrypt elsewhere)"
|
||||
echo " - WireGuard/VPN keys (each device needs its own identity)"
|
||||
echo " - Asterisk Intercom extension (most PBXes reject duplicate registrations)"
|
||||
|
||||
pause
|
||||
}
|
||||
|
||||
action_apply_clone_settings() {
|
||||
echo
|
||||
local in_path
|
||||
in_path=$(ask_text "Profile file to apply" "")
|
||||
if [[ -z "$in_path" ]]; then
|
||||
echo "Cancelled"
|
||||
pause
|
||||
return
|
||||
fi
|
||||
if [[ ! -f "$in_path" ]]; then
|
||||
log_error "File not found: $in_path"
|
||||
pause
|
||||
return 1
|
||||
fi
|
||||
if ! jq empty "$in_path" 2>/dev/null; then
|
||||
log_error "Not valid JSON: $in_path"
|
||||
pause
|
||||
return 1
|
||||
fi
|
||||
|
||||
local settings addons_present
|
||||
settings=$(jq -c '.settings // {}' "$in_path" || echo '{}')
|
||||
addons_present=$(jq -r '.addons_present[]? // empty' "$in_path" || true)
|
||||
|
||||
echo "Profile summary:"
|
||||
echo " Sites: $(echo "$settings" | jq '.tabs | length // 0')"
|
||||
echo " Addons expected: $(echo "$addons_present" | tr '\n' ' ')"
|
||||
echo
|
||||
ask_yes_no "Apply this profile? This overwrites current sites/display/lockout settings." "n" || { echo "Cancelled"; pause; return; }
|
||||
|
||||
sudo mkdir -p "$KIOSK_DIR"
|
||||
sudo chown -R "$KIOSK_USER:$KIOSK_USER" "$KIOSK_DIR"
|
||||
|
||||
local existing="{}"
|
||||
if sudo -u "$KIOSK_USER" test -f "$CONFIG_PATH" 2>/dev/null; then
|
||||
existing=$(sudo -u "$KIOSK_USER" cat "$CONFIG_PATH" 2>/dev/null)
|
||||
echo "$existing" | jq empty 2>/dev/null || existing="{}"
|
||||
fi
|
||||
|
||||
# Merge, not replace - same reasoning as save_config: this machine's
|
||||
# own Authelia fields (never in the exported settings blob) must
|
||||
# survive an apply untouched. Guarded: $settings came from an
|
||||
# external file - a corrupted/hand-edited profile whose "settings"
|
||||
# key isn't a JSON object must not be allowed to crash the session.
|
||||
local merged
|
||||
if ! merged=$(echo "$existing" | jq --argjson s "$settings" '. + $s' 2>/dev/null); then
|
||||
log_error "Profile's settings are not a valid JSON object - nothing was changed"
|
||||
pause
|
||||
return 1
|
||||
fi
|
||||
|
||||
local tmp
|
||||
tmp=$(mktemp)
|
||||
echo "$merged" > "$tmp"
|
||||
sudo -u "$KIOSK_USER" bash -c "cat > '$CONFIG_PATH'" < "$tmp"
|
||||
sudo -u "$KIOSK_USER" chmod 644 "$CONFIG_PATH"
|
||||
rm -f "$tmp"
|
||||
|
||||
log_success "Settings applied"
|
||||
|
||||
echo
|
||||
echo "Addon checklist (from the exported profile):"
|
||||
local missing_any=false
|
||||
if [[ -z "$addons_present" ]]; then
|
||||
echo " (profile recorded no addons)"
|
||||
fi
|
||||
while IFS= read -r addon; do
|
||||
[[ -z "$addon" ]] && continue
|
||||
case "$addon" in
|
||||
cups)
|
||||
if cups_is_installed; then
|
||||
echo " [x] CUPS Printing - already installed"
|
||||
else
|
||||
echo " [ ] CUPS Printing - not installed, install via Addons"
|
||||
missing_any=true
|
||||
fi ;;
|
||||
lms)
|
||||
if lms_is_installed; then
|
||||
echo " [x] LMS Server - already installed"
|
||||
else
|
||||
echo " [ ] LMS Server - not installed, install via Addons"
|
||||
missing_any=true
|
||||
fi ;;
|
||||
squeezelite)
|
||||
if squeezelite_is_installed; then
|
||||
echo " [x] Squeezelite Player - already installed"
|
||||
else
|
||||
echo " [ ] Squeezelite Player - not installed, install via Addons"
|
||||
missing_any=true
|
||||
fi ;;
|
||||
vnc)
|
||||
if is_service_active x11vnc; then
|
||||
echo " [x] VNC - already installed"
|
||||
else
|
||||
echo " [ ] VNC - not installed, install via Addons"
|
||||
missing_any=true
|
||||
fi ;;
|
||||
wireguard)
|
||||
echo " [!] WireGuard - needs a NEW keypair/peer on this device, never clone the key" ;;
|
||||
tailscale)
|
||||
if command -v tailscale &>/dev/null; then
|
||||
echo " [x] Tailscale - installed (connect with a reusable auth key if not yet connected)"
|
||||
else
|
||||
echo " [ ] Tailscale - not installed, install via Addons"
|
||||
missing_any=true
|
||||
fi ;;
|
||||
netbird)
|
||||
if command -v netbird &>/dev/null; then
|
||||
echo " [x] Netbird - installed (connect with a reusable setup key if not yet connected)"
|
||||
else
|
||||
echo " [ ] Netbird - not installed, install via Addons"
|
||||
missing_any=true
|
||||
fi ;;
|
||||
asterisk_intercom)
|
||||
echo " [!] Asterisk Intercom - needs its own SIP extension on this device" ;;
|
||||
authelia)
|
||||
echo " [!] Authelia - needs a fresh login on this device" ;;
|
||||
*)
|
||||
echo " [?] $addon - unrecognized entry in profile" ;;
|
||||
esac
|
||||
done <<< "$addons_present"
|
||||
|
||||
if $missing_any; then
|
||||
echo
|
||||
echo "Install anything marked \"not installed\" above via the Addons menu."
|
||||
fi
|
||||
|
||||
pause
|
||||
}
|
||||
@@ -7,7 +7,8 @@
|
||||
# (cups_do_uninstall, vnc_do_uninstall, wireguard_do_uninstall,
|
||||
# tailscale_do_uninstall, netbird_do_uninstall, lms_do_uninstall,
|
||||
# squeezelite_do_uninstall, asterisk_intercom_do_uninstall,
|
||||
# power_schedule_do_remove_all, emergency_hotspot_do_disable) instead of
|
||||
# webui_do_uninstall, power_schedule_do_remove_all,
|
||||
# emergency_hotspot_do_disable) instead of
|
||||
# re-implementing removal logic for each addon a second time here - if an
|
||||
# addon's uninstall logic changes, this picks it up automatically. Only
|
||||
# the pieces no single addon owns - the kiosk user/files, Node.js/
|
||||
@@ -57,6 +58,7 @@ action_complete_uninstall() {
|
||||
echo " • Squeezelite and LMS (Lyrion Music Server)"
|
||||
echo " • Remote access (VNC, WireGuard, Tailscale, Netbird)"
|
||||
echo " • Asterisk Intercom (Baresip)"
|
||||
echo " • Web UI"
|
||||
echo " • LightDM and Openbox"
|
||||
echo " • All kiosk schedules and services"
|
||||
echo " • Emergency hotspot configuration"
|
||||
@@ -86,6 +88,7 @@ action_complete_uninstall() {
|
||||
lms_do_uninstall purge
|
||||
squeezelite_do_uninstall
|
||||
asterisk_intercom_do_uninstall purge
|
||||
webui_do_uninstall
|
||||
|
||||
echo "[3/12] Removing schedules and emergency hotspot..."
|
||||
power_schedule_do_remove_all
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
Section "ServerFlags"
|
||||
# Disable Ctrl+Alt+Backspace (X server kill)
|
||||
Option "DontZap" "true"
|
||||
|
||||
# Disable VT switching (Ctrl+Alt+F1-F12)
|
||||
Option "DontVTSwitch" "true"
|
||||
|
||||
# Don't allow clients to disconnect on exit
|
||||
Option "AllowClosedownGrabs" "false"
|
||||
EndSection
|
||||
@@ -0,0 +1,7 @@
|
||||
Section "Device"
|
||||
Identifier "Intel Graphics"
|
||||
Driver "intel"
|
||||
Option "AccelMethod" "sna"
|
||||
Option "TearFree" "true"
|
||||
Option "DRI" "3"
|
||||
EndSection
|
||||
@@ -0,0 +1,5 @@
|
||||
Section "InputClass"
|
||||
Identifier "Touch screen use libinput"
|
||||
MatchIsTouchscreen "on"
|
||||
Driver "libinput"
|
||||
EndSection
|
||||
@@ -0,0 +1,2 @@
|
||||
event=button/power.*
|
||||
action=/usr/local/bin/kiosk-power-button.sh
|
||||
@@ -0,0 +1,2 @@
|
||||
event=button/power PBTN
|
||||
action=/usr/local/bin/kiosk-power-button.sh
|
||||
@@ -0,0 +1,2 @@
|
||||
event=button/power PWRF
|
||||
action=/usr/local/bin/kiosk-power-button.sh
|
||||
@@ -0,0 +1,6 @@
|
||||
[Allow kiosk power operations]
|
||||
Identity=unix-user:kiosk
|
||||
Action=org.freedesktop.login1.power-off;org.freedesktop.login1.power-off-multiple-sessions;org.freedesktop.login1.reboot;org.freedesktop.login1.reboot-multiple-sessions;org.freedesktop.login1.suspend;org.freedesktop.login1.suspend-multiple-sessions
|
||||
ResultAny=yes
|
||||
ResultInactive=yes
|
||||
ResultActive=yes
|
||||
@@ -0,0 +1,6 @@
|
||||
[Login]
|
||||
HandlePowerKey=ignore
|
||||
HandlePowerKeyLongPress=poweroff
|
||||
HandleSuspendKey=ignore
|
||||
HandleHibernateKey=ignore
|
||||
HandleLidSwitch=ignore
|
||||
@@ -0,0 +1,8 @@
|
||||
[Unit]
|
||||
Description=Kiosk Display Hotplug Handler
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/local/bin/kiosk-hotplug.sh
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
@@ -0,0 +1 @@
|
||||
SUBSYSTEM=="drm", ACTION=="change", TAG+="systemd", ENV{SYSTEMD_WANTS}="kiosk-hotplug.service"
|
||||
Executable
+143
@@ -0,0 +1,143 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Mirror any connected external display (e.g. HDMI-out to a monitor/TV) onto
|
||||
# the primary display, forcing it to the primary's exact resolution.
|
||||
/usr/local/bin/kiosk-mirror-display.sh
|
||||
|
||||
# AGGRESSIVE DPMS disable - multiple methods
|
||||
xset s off
|
||||
xset s noblank
|
||||
xset -dpms
|
||||
xset s 0 0
|
||||
xset dpms 0 0 0
|
||||
xset dpms force on
|
||||
|
||||
# Keep screen on forever - watchdog (schedule-aware)
|
||||
(
|
||||
while true; do
|
||||
sleep 300 # Every 5 minutes
|
||||
|
||||
# Check if display schedule is active
|
||||
schedule_active=false
|
||||
if systemctl is-active --quiet kiosk-display-off.timer && systemctl is-active --quiet kiosk-display-on.timer; then
|
||||
# Get display off and on times from systemd timers
|
||||
doff=$(systemctl cat kiosk-display-off.timer 2>/dev/null | grep "^OnCalendar=" | sed 's/.*\*-\*-\* //' | sed 's/:00$//')
|
||||
don=$(systemctl cat kiosk-display-on.timer 2>/dev/null | grep "^OnCalendar=" | sed 's/.*\*-\*-\* //' | sed 's/:00$//')
|
||||
|
||||
if [ -n "$doff" ] && [ -n "$don" ]; then
|
||||
# Get current time in HH:MM format
|
||||
current_time=$(date +%H:%M)
|
||||
|
||||
# Convert times to minutes since midnight for comparison
|
||||
# Using 10# prefix to force decimal interpretation (fixes octal bug for 08:xx and 09:xx times)
|
||||
current_mins=$(( 10#$(date +%H) * 60 + 10#$(date +%M) ))
|
||||
off_mins=$(( 10#$(echo "$doff" | cut -d: -f1) * 60 + 10#$(echo "$doff" | cut -d: -f2) ))
|
||||
on_mins=$(( 10#$(echo "$don" | cut -d: -f1) * 60 + 10#$(echo "$don" | cut -d: -f2) ))
|
||||
|
||||
# Check if we're in the "display off" window
|
||||
if [ "$off_mins" -lt "$on_mins" ]; then
|
||||
# Normal case: off time is before on time (e.g., 22:00 to 06:00 next day)
|
||||
if [ "$current_mins" -ge "$off_mins" ] && [ "$current_mins" -lt "$on_mins" ]; then
|
||||
schedule_active=true
|
||||
fi
|
||||
else
|
||||
# Overnight case: off time is after on time (e.g., 06:00 to 22:00)
|
||||
if [ "$current_mins" -ge "$off_mins" ] || [ "$current_mins" -lt "$on_mins" ]; then
|
||||
schedule_active=true
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Only force display on if NOT in scheduled off period
|
||||
if [ "$schedule_active" = "false" ]; then
|
||||
xset s reset 2>/dev/null
|
||||
xset dpms force on 2>/dev/null
|
||||
fi
|
||||
done
|
||||
) &
|
||||
|
||||
# Start PipeWire user services
|
||||
systemctl --user start pipewire pipewire-pulse wireplumber
|
||||
sleep 3
|
||||
|
||||
# Wait for PipeWire to be ready
|
||||
for i in {1..15}; do
|
||||
pactl info >/dev/null 2>&1 && break
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# Wait for ALSA devices
|
||||
for i in {1..10}; do
|
||||
pactl list sinks short | grep -q alsa && break
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# Route audio to HDMI if an external display is connected/mirrored, else built-in
|
||||
/usr/local/bin/kiosk-audio-route.sh
|
||||
|
||||
# Set audio levels (speakers 100%, mic 100%, mic unmuted)
|
||||
pactl set-sink-volume @DEFAULT_SINK@ 100%
|
||||
pactl set-source-volume @DEFAULT_SOURCE@ 100%
|
||||
pactl set-source-mute @DEFAULT_SOURCE@ 0
|
||||
|
||||
# Audio watchdog - checks every 30 seconds (quiet hours aware)
|
||||
(
|
||||
while true; do
|
||||
sleep 30
|
||||
|
||||
# Check if quiet hours is active
|
||||
quiet_active=false
|
||||
if systemctl is-active --quiet kiosk-quiet-start.timer && systemctl is-active --quiet kiosk-quiet-end.timer; then
|
||||
# Get quiet hours start and end times from systemd timers
|
||||
qstart=$(systemctl cat kiosk-quiet-start.timer 2>/dev/null | grep "^OnCalendar=" | sed 's/.*\*-\*-\* //' | sed 's/:00$//')
|
||||
qend=$(systemctl cat kiosk-quiet-end.timer 2>/dev/null | grep "^OnCalendar=" | sed 's/.*\*-\*-\* //' | sed 's/:00$//')
|
||||
|
||||
if [ -n "$qstart" ] && [ -n "$qend" ]; then
|
||||
# Convert times to minutes since midnight for comparison
|
||||
# Using 10# prefix to force decimal interpretation (fixes octal bug for 08:xx and 09:xx times)
|
||||
current_mins=$(( 10#$(date +%H) * 60 + 10#$(date +%M) ))
|
||||
start_mins=$(( 10#$(echo "$qstart" | cut -d: -f1) * 60 + 10#$(echo "$qstart" | cut -d: -f2) ))
|
||||
end_mins=$(( 10#$(echo "$qend" | cut -d: -f1) * 60 + 10#$(echo "$qend" | cut -d: -f2) ))
|
||||
|
||||
# Check if we're in quiet hours window
|
||||
if [ "$start_mins" -lt "$end_mins" ]; then
|
||||
# Normal case: start time is before end time (e.g., 08:00 to 17:00)
|
||||
if [ "$current_mins" -ge "$start_mins" ] && [ "$current_mins" -lt "$end_mins" ]; then
|
||||
quiet_active=true
|
||||
fi
|
||||
else
|
||||
# Overnight case: start time is after end time (e.g., 22:00 to 07:00)
|
||||
if [ "$current_mins" -ge "$start_mins" ] || [ "$current_mins" -lt "$end_mins" ]; then
|
||||
quiet_active=true
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check if PipeWire is running
|
||||
if ! pactl info >/dev/null 2>&1; then
|
||||
logger "KIOSK: Audio dead, restarting PipeWire"
|
||||
systemctl --user restart pipewire pipewire-pulse wireplumber
|
||||
sleep 5
|
||||
# Only restore audio levels if NOT in quiet hours
|
||||
if [ "$quiet_active" = "false" ]; then
|
||||
pactl set-sink-volume @DEFAULT_SINK@ 100%
|
||||
pactl set-source-volume @DEFAULT_SOURCE@ 100%
|
||||
pactl set-source-mute @DEFAULT_SOURCE@ 0
|
||||
fi
|
||||
fi
|
||||
|
||||
done
|
||||
) &
|
||||
|
||||
# Other services
|
||||
unclutter -idle 0.1 -root &
|
||||
XDG_RUNTIME_DIR=/run/user/$(id -u) xbindkeys &
|
||||
|
||||
# Create boot flag for password requirement on boot
|
||||
touch /home/kiosk/kiosk-app/.boot-flag
|
||||
|
||||
# Start kiosk app AFTER audio is ready
|
||||
sleep 2
|
||||
/home/kiosk/kiosk-app/start.sh &
|
||||
@@ -0,0 +1,33 @@
|
||||
# PipeWire noise cancellation configuration for kiosk microphone
|
||||
# This creates a virtual source with echo cancellation and noise suppression
|
||||
|
||||
context.modules = [
|
||||
{ name = libpipewire-module-echo-cancel
|
||||
args = {
|
||||
# audio.channels = 1
|
||||
# capture.props = {
|
||||
# node.name = "Echo Cancellation Capture"
|
||||
# }
|
||||
# source.props = {
|
||||
# node.name = "Echo Cancellation Source"
|
||||
# node.description = "Noise-Cancelled Microphone"
|
||||
# }
|
||||
# sink.props = {
|
||||
# node.name = "Echo Cancellation Sink"
|
||||
# }
|
||||
# playback.props = {
|
||||
# node.name = "Echo Cancellation Playback"
|
||||
# }
|
||||
aec.method = webrtc
|
||||
aec.args = {
|
||||
# WebRTC audio processing settings
|
||||
webrtc.gain_control = true
|
||||
webrtc.extended_filter = true
|
||||
webrtc.high_pass_filter = true
|
||||
webrtc.noise_suppression = true
|
||||
webrtc.noise_suppression_level = 3
|
||||
webrtc.voice_detection = true
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
#!/bin/bash
|
||||
# Assumes DISPLAY/XAUTHORITY are set (for xrandr) and pactl already has a
|
||||
# working PipeWire/pulse socket for the invoking context.
|
||||
|
||||
QUERY=$(xrandr --query 2>/dev/null)
|
||||
[ -z "$QUERY" ] && exit 0
|
||||
|
||||
PRIMARY_OUTPUT=$(echo "$QUERY" | awk '/ primary/{print $1; exit}')
|
||||
[ -z "$PRIMARY_OUTPUT" ] && exit 0
|
||||
|
||||
EXTERNAL_CONNECTED=$(echo "$QUERY" | awk -v p="$PRIMARY_OUTPUT" '/ connected/ && $1!=p{f=1} END{print (f==1)?"yes":"no"}')
|
||||
|
||||
HDMI_SINK=$(pactl list sinks short 2>/dev/null | awk 'tolower($2) ~ /hdmi/{print $2; exit}')
|
||||
NON_HDMI_SINK=$(pactl list sinks short 2>/dev/null | awk 'tolower($2) !~ /hdmi/{print $2; exit}')
|
||||
|
||||
route_to() {
|
||||
local sink="$1" label="$2"
|
||||
if [ -z "$sink" ]; then
|
||||
logger "KIOSK: no $label audio sink found, leaving routing unchanged"
|
||||
return
|
||||
fi
|
||||
pactl set-default-sink "$sink" 2>/dev/null \
|
||||
&& logger "KIOSK: audio routed to $label sink ($sink)" \
|
||||
|| logger "KIOSK: failed to route audio to $label sink ($sink)"
|
||||
pactl list sink-inputs short 2>/dev/null | awk '{print $1}' | while read -r sid; do
|
||||
pactl move-sink-input "$sid" "$sink" 2>/dev/null
|
||||
done
|
||||
pactl set-sink-volume "$sink" 100% 2>/dev/null
|
||||
pactl set-sink-mute "$sink" 0 2>/dev/null
|
||||
}
|
||||
|
||||
if [ "$EXTERNAL_CONNECTED" = "yes" ]; then
|
||||
route_to "$HDMI_SINK" "HDMI"
|
||||
else
|
||||
route_to "$NON_HDMI_SINK" "built-in"
|
||||
fi
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
#!/bin/bash
|
||||
# Give X a moment to finish enumerating the output after the hotplug event
|
||||
sleep 2
|
||||
sudo -u kiosk DISPLAY=:0 XAUTHORITY=/home/kiosk/.Xauthority /usr/local/bin/kiosk-mirror-display.sh
|
||||
|
||||
kiosk_uid=$(id -u kiosk)
|
||||
sudo -u kiosk DISPLAY=:0 XAUTHORITY=/home/kiosk/.Xauthority XDG_RUNTIME_DIR="/run/user/${kiosk_uid}" /usr/local/bin/kiosk-audio-route.sh
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
#!/bin/bash
|
||||
# Assumes it's run with DISPLAY/XAUTHORITY already set for the kiosk user's
|
||||
# X session (either inherited, as from Openbox autostart, or exported by the
|
||||
# caller). Mirrors every connected non-primary output at the primary's exact
|
||||
# current resolution so kiosk content isn't cropped/letterboxed/blank on a
|
||||
# TV/monitor with a different native resolution than the kiosk panel.
|
||||
|
||||
QUERY=$(xrandr --query 2>/dev/null)
|
||||
[ -z "$QUERY" ] && exit 0
|
||||
|
||||
PRIMARY_OUTPUT=$(echo "$QUERY" | awk '/ primary/{print $1; exit}')
|
||||
[ -z "$PRIMARY_OUTPUT" ] && exit 0
|
||||
|
||||
PRIMARY_RES=$(echo "$QUERY" | awk -v p="$PRIMARY_OUTPUT" '$1==p{for(i=1;i<=NF;i++) if ($i ~ /^[0-9]+x[0-9]+\+/){split($i,a,"+"); print a[1]; exit}}')
|
||||
[ -z "$PRIMARY_RES" ] && exit 0
|
||||
|
||||
for OUT in $(echo "$QUERY" | awk '/ connected/{print $1}'); do
|
||||
[ "$OUT" = "$PRIMARY_OUTPUT" ] && continue
|
||||
|
||||
HAS_NATIVE=$(echo "$QUERY" | awk -v out="$OUT" -v res="$PRIMARY_RES" '
|
||||
$0 ~ "^"out" " {infound=1; next}
|
||||
/^[^ \t]/ {infound=0}
|
||||
infound && $1==res {print "yes"; exit}
|
||||
')
|
||||
|
||||
if [ "$HAS_NATIVE" = "yes" ]; then
|
||||
xrandr --output "$OUT" --mode "$PRIMARY_RES" --same-as "$PRIMARY_OUTPUT" 2>/dev/null \
|
||||
&& logger "KIOSK: mirrored $OUT at native $PRIMARY_RES" \
|
||||
|| logger "KIOSK: mirror of $OUT at $PRIMARY_RES failed"
|
||||
continue
|
||||
fi
|
||||
|
||||
# $OUT doesn't natively list the primary's resolution - force a matching mode
|
||||
CVT_LINE=$(cvt "${PRIMARY_RES%x*}" "${PRIMARY_RES#*x}" 2>/dev/null | grep Modeline)
|
||||
MODENAME=$(echo "$CVT_LINE" | sed -n 's/^Modeline "\([^"]*\)".*/\1/p')
|
||||
TIMINGS=$(echo "$CVT_LINE" | sed -n 's/^Modeline "[^"]*" *//p')
|
||||
|
||||
if [ -z "$MODENAME" ] || [ -z "$TIMINGS" ]; then
|
||||
logger "KIOSK: could not generate a $PRIMARY_RES mode for $OUT (cvt failed or missing)"
|
||||
continue
|
||||
fi
|
||||
|
||||
xrandr --newmode "$MODENAME" $TIMINGS 2>/dev/null
|
||||
xrandr --addmode "$OUT" "$MODENAME" 2>/dev/null
|
||||
xrandr --output "$OUT" --mode "$MODENAME" --same-as "$PRIMARY_OUTPUT" 2>/dev/null \
|
||||
&& logger "KIOSK: mirrored $OUT at forced $PRIMARY_RES ($MODENAME)" \
|
||||
|| logger "KIOSK: mirror of $OUT at forced $PRIMARY_RES failed"
|
||||
done
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
#!/bin/bash
|
||||
# Power button handler - sends SIGUSR1 to Electron to show power menu
|
||||
# This runs as ROOT from acpid, so it can signal any process
|
||||
|
||||
logger "KIOSK POWER: Button pressed"
|
||||
|
||||
# Find the Electron main process (runs as kiosk user)
|
||||
PIDS=$(pgrep -u kiosk -f "electron" 2>/dev/null)
|
||||
|
||||
if [ -z "$PIDS" ]; then
|
||||
logger "KIOSK POWER: No Electron process found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Send SIGUSR1 to all Electron processes (the main one will handle it)
|
||||
for PID in $PIDS; do
|
||||
logger "KIOSK POWER: Sending SIGUSR1 to PID $PID"
|
||||
kill -USR1 $PID 2>/dev/null
|
||||
done
|
||||
|
||||
logger "KIOSK POWER: Signal sent"
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
CURRENT=$(pactl get-sink-volume @DEFAULT_SINK@ | grep -oE '[0-9]+%' | head -1 | tr -d '%')
|
||||
NEW=$((CURRENT - 5))
|
||||
[[ $NEW -lt 0 ]] && NEW=0
|
||||
pactl set-sink-volume @DEFAULT_SINK@ ${NEW}%
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
CURRENT=$(pactl get-sink-volume @DEFAULT_SINK@ | grep -oE '[0-9]+%' | head -1 | tr -d '%')
|
||||
NEW=$((CURRENT + 5))
|
||||
[[ $NEW -gt 100 ]] && NEW=100
|
||||
pactl set-sink-volume @DEFAULT_SINK@ ${NEW}%
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
#!/bin/bash
|
||||
echo "Testing power button configuration..."
|
||||
echo
|
||||
|
||||
echo "1. Checking acpid service..."
|
||||
if systemctl is-active --quiet acpid; then
|
||||
echo " ✓ acpid is running"
|
||||
else
|
||||
echo " ✗ acpid is NOT running"
|
||||
echo " Fix: sudo systemctl enable --now acpid"
|
||||
fi
|
||||
echo
|
||||
|
||||
echo "2. Checking ACPI event handler..."
|
||||
if [ -f /etc/acpi/events/kiosk-power-button ]; then
|
||||
echo " ✓ Event handler exists"
|
||||
cat /etc/acpi/events/kiosk-power-button
|
||||
else
|
||||
echo " ✗ Event handler not found"
|
||||
fi
|
||||
echo
|
||||
|
||||
echo "3. Checking power button script..."
|
||||
if [ -x /usr/local/bin/kiosk-power-button.sh ]; then
|
||||
echo " ✓ Script exists and is executable"
|
||||
else
|
||||
echo " ✗ Script not found or not executable"
|
||||
fi
|
||||
echo
|
||||
|
||||
echo "4. Checking Electron process..."
|
||||
PIDS=$(pgrep -u kiosk -f "electron" 2>/dev/null)
|
||||
if [ -n "$PIDS" ]; then
|
||||
echo " ✓ Found Electron PIDs: $PIDS"
|
||||
else
|
||||
echo " ✗ No Electron process found"
|
||||
fi
|
||||
echo
|
||||
|
||||
echo "5. Testing power button trigger NOW..."
|
||||
if [ -x /usr/local/bin/kiosk-power-button.sh ]; then
|
||||
echo " Running: /usr/local/bin/kiosk-power-button.sh"
|
||||
/usr/local/bin/kiosk-power-button.sh
|
||||
echo " Check if power menu appeared!"
|
||||
else
|
||||
echo " Script not found"
|
||||
fi
|
||||
echo
|
||||
|
||||
echo "6. To watch ACPI events: sudo acpi_listen"
|
||||
echo " Then press power button and look for 'button/power' events"
|
||||
+185
-2
@@ -1,8 +1,191 @@
|
||||
#!/bin/bash
|
||||
################################################################################
|
||||
### Ubuntu Based Kiosk v2.12.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
|
||||
# Addons menu, 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 can read/write config.json directly with normal filesystem
|
||||
# permissions. webui/lib/config.js re-implements lib/config.sh's exact
|
||||
# schema and merge-on-save contract in JS (kiosk-app/main.js already
|
||||
# reads the same file directly in JS - established precedent, not a
|
||||
# new pattern), so it can never silently clobber fields it doesn't
|
||||
# track (Authelia's credentials, 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, and 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. Direct LAN access with no proxy in front has no
|
||||
# authentication at all - treat it like SSH access to the kiosk.
|
||||
# - Deliberately narrow scope for this first pass: WiFi, Timezone,
|
||||
# Power/Display/Quiet Hours, Complete Uninstall, every other addon,
|
||||
# and everything in Advanced remain terminal-only - a network-facing
|
||||
# process shouldn't be handed sudo-level system mutation (netplan,
|
||||
# timedatectl, apt, systemd timers) without a lot more thought than
|
||||
# this pass gives it. A "restart kiosk display" action was left out
|
||||
# for the same reason - would need a narrow polkit grant, follow-up.
|
||||
# - Wired into Complete Uninstall (webui_do_uninstall) and Clone Settings
|
||||
# (webui addon-presence detection) the same way every other addon is.
|
||||
# - This is the single-kiosk piece of the planned web-based GUI
|
||||
# (mentioned in this repo's "Modular Management" notes for a while) -
|
||||
# a central multi-kiosk fleet dashboard is an intentional follow-up,
|
||||
# not part of this pass.
|
||||
#
|
||||
# RELEASE v2.15.0 - Upgrade Migrated to install.sh (Advanced -> Upgrade)
|
||||
# - New in ./install.sh's Advanced menu: Upgrade. Not a port of this
|
||||
# script's Upgrade - that one re-extracted main.js/preload.js/etc from
|
||||
# its own heredocs on every run, a mechanism that has no equivalent
|
||||
# here now that kiosk-app/ and provision/files/ are real files in the
|
||||
# git checkout. The modular Upgrade is `git pull` (only after
|
||||
# confirming the working tree is clean and the pull is a fast-forward
|
||||
# - never an automatic merge) followed by re-running the same
|
||||
# packages/kiosk-app/display/firewall/power-management steps
|
||||
# lib/provision.sh already has for a fresh install, reused rather than
|
||||
# reimplemented. Skips the interactive first-run settings wizard and
|
||||
# the "reboot now" prompt - those don't belong in a routine upgrade.
|
||||
# - Also offers an on-demand Electron version check/update regardless of
|
||||
# whether there was any code to pull, since Electron isn't versioned
|
||||
# by this repo - reuses the existing, already-tested
|
||||
# action_update_electron (menus/advanced_electron.sh) as-is.
|
||||
# - Requires a git checkout (not the no-git ZIP download option) and a
|
||||
# clean working tree; a diverged local history fails the pull cleanly
|
||||
# with a clear message instead of attempting an automatic merge.
|
||||
# - Full Reinstall dropped, not carried forward - it never worked
|
||||
# reliably in this script either, and the modular tool already covers
|
||||
# the same outcome more reliably as two already-tested pieces run back
|
||||
# to back: Complete Uninstall (Core Settings), then ./install.sh again
|
||||
# to provision fresh. No dedicated combined action needed.
|
||||
#
|
||||
# RELEASE v2.14.0 - install.sh Now Provisions a Kiosk From Scratch,
|
||||
# Not Just Manages an Existing One
|
||||
# - Until now, ./install.sh only worked against an already-installed
|
||||
# kiosk (this script was still the only path from a bare Ubuntu
|
||||
# Server box to a running one). It now provisions too: on a machine
|
||||
# with no kiosk-app directory yet, it installs packages, creates the
|
||||
# kiosk user, installs Node.js/Electron, sets up LightDM+Openbox
|
||||
# autologin, audio/video/HDMI/power-button hardware handling, the
|
||||
# firewall, then hands off to the same Core Settings menus below for
|
||||
# initial configuration - matching this script's own install-then-
|
||||
# configure flow, on the new modular codebase.
|
||||
# - New: lib/provision.sh (the provisioning steps, built almost
|
||||
# entirely by calling already-migrated menus - core_settings_menu,
|
||||
# action_configure_emergency_hotspot, action_disable_virtual_consoles
|
||||
# - rather than reimplementing that logic a third time), lib/electron.sh
|
||||
# (electron_install_binary, extracted out of menus/advanced_electron.sh
|
||||
# so both fresh provisioning and the existing "Fix blank screen"
|
||||
# action share one implementation), kiosk-app/ (the Electron app
|
||||
# source - main.js, preload.js, the dialog HTML files, package.json,
|
||||
# start.sh - extracted byte-for-byte out of this script's heredocs
|
||||
# into real files), provision/files/ (every other system template
|
||||
# file - X11 configs, udev rules, systemd units, the power-button and
|
||||
# HDMI-mirroring scripts, polkit rules - laid out mirroring their real
|
||||
# destination paths, e.g. provision/files/etc/X11/xorg.conf.d/foo.conf
|
||||
# installs to /etc/X11/xorg.conf.d/foo.conf).
|
||||
# - Reusing the already-migrated menus instead of reimplementing
|
||||
# first-time configuration cut lib/provision.sh down to roughly 300
|
||||
# lines against this script's ~4,000-line first_time_install().
|
||||
# - Fixed along the way: a bash `set -e` gotcha where testing a
|
||||
# multi-statement function as an if-condition (`if ! some_func; then`)
|
||||
# silently exempts everything inside that function from set -e for
|
||||
# the duration - found via direct testing while writing the new
|
||||
# provisioning code, then swept for elsewhere and also fixed in
|
||||
# menus/advanced_electron.sh's existing "Fix blank screen" action
|
||||
# (its electron_install_binary call had the same shape).
|
||||
# - Known, deliberate limitation carried over unchanged from this
|
||||
# script: a few of the extracted system scripts (start.sh,
|
||||
# kiosk-hotplug.sh, the power-button handler) hardcode the username
|
||||
# "kiosk" rather than substituting $KIOSK_USER, exactly as the
|
||||
# quoted heredocs here always did. Fine unless $KIOSK_USER is
|
||||
# overridden from its default, which in practice it almost never is.
|
||||
# - Still not ported to ./install.sh: Upgrade and Full Reinstall, both
|
||||
# coupled to this script's own heredoc self-extraction - a different
|
||||
# mechanism than the new provisioning (which copies real files from
|
||||
# kiosk-app/ and provision/files/, not heredocs). This script remains
|
||||
# the way to upgrade/reinstall an existing install for now.
|
||||
#
|
||||
# RELEASE v2.13.0 - Clone Settings: New MVP for Standing Up Several
|
||||
# Kiosks with the Same Settings
|
||||
# - New in ./install.sh's Advanced menu: Clone Settings
|
||||
# (menus/clone_settings.sh). Not a port of the legacy Export/Import
|
||||
# Settings - a narrower, deliberately-scoped feature for the "set up
|
||||
# one kiosk, then stamp out a dozen more like it" use case: export the
|
||||
# portable parts of config.json (sites, display/touch/navigation,
|
||||
# lockout, password protection) to a JSON file, apply that file to any
|
||||
# other already-installed kiosk.
|
||||
# - Explicitly does NOT copy machine-bound credentials, because copying
|
||||
# them would be actively wrong, not just incomplete: Authelia's
|
||||
# encrypted password is keyed off /etc/machine-id and decrypts to
|
||||
# garbage elsewhere; a WireGuard private key is a device identity and
|
||||
# reusing one across machines is a peer conflict; most Asterisk PBXes
|
||||
# reject two simultaneous registrations to the same extension. Apply
|
||||
# prints these as an explicit "needs a human" checklist instead of
|
||||
# silently skipping them or (worse) cloning them.
|
||||
# - Does not install missing addons - only records which addons were
|
||||
# present at export time and reports which of those are/aren't
|
||||
# present on the machine being applied to. Non-interactive addon
|
||||
# installation (so applying a profile needs zero prompts, scriptable
|
||||
# over SSH to a whole fleet) is deliberately left as a follow-up, not
|
||||
# bundled into this MVP.
|
||||
#
|
||||
# RELEASE v2.12.0 - Complete Uninstall Migrated (Last of the
|
||||
# "Destructive Trio"); Composed, Not Re-Implemented
|
||||
# - New in ./install.sh's Core Settings menu: Complete Uninstall
|
||||
@@ -450,7 +633,7 @@ set -euo pipefail
|
||||
### SECTION 1: CONSTANTS & GLOBALS
|
||||
################################################################################
|
||||
|
||||
SCRIPT_VERSION="2.12.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,
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
'use strict';
|
||||
|
||||
// webui/lib/actions.js - the web UI's allow-list of privileged actions,
|
||||
// and how to turn a web form's fields into the exact stdin sequence the
|
||||
// real bash `action_*` function expects.
|
||||
//
|
||||
// Mirrors menus/addon_webui.sh's ALLOWED_ACTIONS array (inside the
|
||||
// generated kiosk-webui-helper script) - kept in sync by hand rather
|
||||
// than shared/generated, since both lists are short and deliberately
|
||||
// curated. A mismatch between the two just means an action fails closed
|
||||
// on whichever side is missing it, never open on both: server.js checks
|
||||
// this list before spawning anything, and the helper script re-checks
|
||||
// its own list before dispatching regardless of what server.js sent.
|
||||
//
|
||||
// Every buildStdin() below was verified against the real menus/*.sh
|
||||
// source (prompt order, defaults, and which fields reject a blank
|
||||
// answer and re-prompt) - see webui/test/actions.test.js, which drives
|
||||
// the actual bash functions with this exact output and checks the real
|
||||
// resulting state (cups_is_installed, lms_is_installed, etc), not just
|
||||
// that the process exits 0.
|
||||
|
||||
const ACTIONS = {
|
||||
install_cups: {
|
||||
helperAction: 'action_install_cups',
|
||||
label: 'Install CUPS Printing',
|
||||
fields: [],
|
||||
// action_install_cups's only prompt is "Install CUPS printing?
|
||||
// (n)" - the web UI's own install button is the confirmation,
|
||||
// so this always answers yes. No pause() in this function.
|
||||
buildStdin() {
|
||||
return 'y\n';
|
||||
},
|
||||
},
|
||||
|
||||
reconfigure_cups: {
|
||||
helperAction: 'action_reconfigure_cups',
|
||||
label: 'Reconfigure CUPS for network access',
|
||||
fields: [],
|
||||
// No prompts at all, no pause().
|
||||
buildStdin() {
|
||||
return '';
|
||||
},
|
||||
},
|
||||
|
||||
install_lms: {
|
||||
helperAction: 'action_install_lms',
|
||||
label: 'Install / reconfigure LMS Server',
|
||||
// fields.alreadyInstalled must reflect real current state
|
||||
// (server.js fills this in from lms_is_installed via the
|
||||
// helper's own status check before offering the reconfigure
|
||||
// fields) - action_install_lms branches on it internally and a
|
||||
// wrong guess here desyncs the stdin sequence from what the
|
||||
// real function actually prompts for.
|
||||
fields: ['alreadyInstalled', 'reconfigurePort', 'newPort'],
|
||||
buildStdin(f) {
|
||||
if (f.alreadyInstalled) {
|
||||
if (!f.reconfigurePort) {
|
||||
return 'n\n\n'; // decline reconfigure, then pause()
|
||||
}
|
||||
const port = Number(f.newPort);
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
throw new Error('newPort must be an integer 1-65535');
|
||||
}
|
||||
return `y\n${port}\n\n`; // accept, new port, pause()
|
||||
}
|
||||
return '\n'; // fresh install: fully automated except pause()
|
||||
},
|
||||
},
|
||||
|
||||
install_squeezelite: {
|
||||
helperAction: 'action_install_squeezelite',
|
||||
label: 'Install / reconfigure Squeezelite Player',
|
||||
// If already installed, the real function first asks
|
||||
// "Reconfigure?" (default n) and, if declined, returns
|
||||
// immediately after just the pause() - it does NOT fall through
|
||||
// to the player-name/server prompts. f.reconfigure must be
|
||||
// explicit (not inferred from other fields) so the web UI can
|
||||
// offer "leave it as-is" without also having to resend the
|
||||
// current values.
|
||||
fields: ['alreadyInstalled', 'reconfigure', 'playerName', 'lmsServer'],
|
||||
buildStdin(f) {
|
||||
if (f.alreadyInstalled && !f.reconfigure) {
|
||||
return 'n\n\n'; // decline reconfigure, then pause()
|
||||
}
|
||||
const lines = [];
|
||||
if (f.alreadyInstalled) lines.push('y'); // "Reconfigure?"
|
||||
lines.push(f.playerName || ''); // blank -> "Kiosk" default
|
||||
lines.push(f.lmsServer || ''); // blank -> auto-discovery
|
||||
// "Reboot now?" is always answered "n" here regardless of
|
||||
// what the UI shows - triggering a real `sudo reboot` from
|
||||
// inside a one-click addon-install action is out of scope
|
||||
// for this pass (see webui phase-2 plan). The UI surfaces
|
||||
// "reboot required to start Squeezelite" as an info banner
|
||||
// instead of a real remote reboot trigger.
|
||||
lines.push('n');
|
||||
lines.push(''); // pause()
|
||||
return lines.join('\n') + '\n';
|
||||
},
|
||||
},
|
||||
|
||||
configure_asterisk_intercom: {
|
||||
helperAction: 'action_configure_asterisk_intercom',
|
||||
label: 'Configure Asterisk Intercom',
|
||||
// Same shape as Squeezelite's reconfigure gate: if already
|
||||
// installed, the real function asks "Reconfigure with a
|
||||
// different server/extension?" (default n) and returns after
|
||||
// just the pause() if declined - the rest of this sequence is
|
||||
// never reached in that case.
|
||||
fields: ['alreadyInstalled', 'reconfigure', 'serverIp', 'serverPort', 'extension', 'password', 'autoAnswer', 'useTls'],
|
||||
buildStdin(f) {
|
||||
if (f.alreadyInstalled && !f.reconfigure) {
|
||||
return 'n\n\n'; // decline reconfigure, then pause()
|
||||
}
|
||||
const lines = [];
|
||||
// Only present at all when baresip_is_installed is already
|
||||
// true - a fresh install has no "Reconfigure?" prompt.
|
||||
if (f.alreadyInstalled) lines.push('y');
|
||||
|
||||
// Server IP and extension reject a blank answer and
|
||||
// re-prompt (a `while [[ -z ... ]]` loop in the real
|
||||
// function) - sending an empty line here would desync the
|
||||
// rest of the sequence by consuming a second prompt cycle,
|
||||
// so these are validated up front instead.
|
||||
if (!f.serverIp || !String(f.serverIp).trim()) throw new Error('serverIp is required');
|
||||
lines.push(String(f.serverIp).trim());
|
||||
|
||||
lines.push(f.serverPort != null && f.serverPort !== '' ? String(f.serverPort) : '');
|
||||
|
||||
if (!f.extension || !String(f.extension).trim()) throw new Error('extension is required');
|
||||
lines.push(String(f.extension).trim());
|
||||
|
||||
// Password also rejects blank and re-prompts, same reason.
|
||||
if (!f.password) throw new Error('password is required');
|
||||
lines.push(f.password);
|
||||
|
||||
lines.push(f.autoAnswer ? 'y' : 'n');
|
||||
lines.push(f.useTls ? 'y' : 'n');
|
||||
// "Proceed with installation?" (default y) - already
|
||||
// confirmed by the web click that got us here.
|
||||
lines.push('y');
|
||||
lines.push(''); // pause()
|
||||
return lines.join('\n') + '\n';
|
||||
},
|
||||
},
|
||||
|
||||
upgrade: {
|
||||
helperAction: 'action_upgrade',
|
||||
label: 'Check for and apply updates',
|
||||
fields: [],
|
||||
buildStdin() {
|
||||
// action_upgrade's own flow: "Pull these changes...?" (y),
|
||||
// then - only if there was anything to pull -
|
||||
// "Restart kiosk display now...?" (y), then always
|
||||
// "Check for and install the latest Electron...?", answered
|
||||
// n here. That sub-flow's own prompts default to declining
|
||||
// and aren't a good fit for one-click automation yet (see
|
||||
// webui phase-2 plan, "Explicitly deferred"). Answering "n"
|
||||
// to a prompt that never actually gets shown (nothing to
|
||||
// pull, or the display-restart question) is harmless - a
|
||||
// synthesized line bash never reads is simply left unread,
|
||||
// not an error.
|
||||
return 'y\ny\nn\n';
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function getAction(name) {
|
||||
return Object.prototype.hasOwnProperty.call(ACTIONS, name) ? ACTIONS[name] : undefined;
|
||||
}
|
||||
|
||||
module.exports = { ACTIONS, getAction };
|
||||
@@ -0,0 +1,161 @@
|
||||
'use strict';
|
||||
|
||||
// webui/lib/config.js - config.json read/write for the web UI.
|
||||
//
|
||||
// Mirrors lib/config.sh's load_existing_config()/save_config() contract
|
||||
// exactly, in JS instead of jq, so the web UI and the terminal menus stay
|
||||
// in sync against the same file without either one going through the
|
||||
// other. kiosk-app/main.js already reads this same config.json directly
|
||||
// in JS (its own fs.readFileSync/JSON.parse, no bash involved) - this is
|
||||
// established precedent in this repo, not a new pattern.
|
||||
//
|
||||
// Tracks exactly the fields Sites & Page Timing (menus/sites.sh),
|
||||
// Display & Interaction (menus/display.sh), and Password Protection &
|
||||
// Lockout (menus/lockout.sh) track. lockoutActiveStart/lockoutActiveEnd
|
||||
// are deliberately excluded, matching lockout.sh's own header comment:
|
||||
// "the app doesn't act on them... lib/config.sh just carries whatever is
|
||||
// already in config.json through unchanged." Anything else present in an
|
||||
// existing file (autheliaURL, autheliaUsername,
|
||||
// autheliaEncryptedPassword, lockoutActiveStart/End, or any future field)
|
||||
// is opaque passthrough data - saveConfig() merges onto it, never
|
||||
// rebuilds from nothing, so none of it is ever silently deleted. That
|
||||
// exact failure mode was a real, previously-fixed bug in lib/config.sh's
|
||||
// own history (see its header/body comments) and must not be
|
||||
// reintroduced here.
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const CONFIG_PATH = process.env.CONFIG_PATH;
|
||||
if (!CONFIG_PATH) {
|
||||
throw new Error("CONFIG_PATH environment variable is required (path to the kiosk app's config.json)");
|
||||
}
|
||||
|
||||
const SCALAR_DEFAULTS = {
|
||||
swipeMode: 'dual',
|
||||
allowNavigation: 'same-origin',
|
||||
homeTabIndex: -1,
|
||||
inactivityTimeout: 120,
|
||||
enablePauseButton: true,
|
||||
enableKeyboardButton: true,
|
||||
enableNavButton: true,
|
||||
enablePasswordProtection: false,
|
||||
lockoutTimeout: 0,
|
||||
lockoutAtTime: '',
|
||||
requirePasswordOnBoot: false,
|
||||
};
|
||||
|
||||
function readExisting() {
|
||||
try {
|
||||
const raw = fs.readFileSync(CONFIG_PATH, 'utf8');
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed;
|
||||
} catch (e) {
|
||||
// Missing file or invalid JSON - same fallback save_config() uses
|
||||
// in lib/config.sh ("{}" when the file is absent/unparsable).
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function normalizeTab(t) {
|
||||
return {
|
||||
url: typeof t.url === 'string' ? t.url : '',
|
||||
duration: Number.isFinite(Number(t.duration)) ? Number(t.duration) : 0,
|
||||
username: typeof t.username === 'string' ? t.username : '',
|
||||
password: typeof t.password === 'string' ? t.password : '',
|
||||
name: typeof t.name === 'string' ? t.name : '',
|
||||
};
|
||||
}
|
||||
|
||||
function hashPassword(plaintext) {
|
||||
return crypto.createHash('sha256').update(plaintext, 'utf8').digest('hex');
|
||||
}
|
||||
|
||||
// Mirrors load_existing_config(). Never returns a site's Basic Auth
|
||||
// password or the lockout password hash - both are write-only from here,
|
||||
// same as the terminal menus, which never display a stored password back
|
||||
// either (sites.sh's edit_page_status only ever shows the username;
|
||||
// lockout.sh has no "show current password" path at all).
|
||||
function loadConfig() {
|
||||
const existing = readExisting();
|
||||
const tabs = Array.isArray(existing.tabs) ? existing.tabs.map(normalizeTab) : [];
|
||||
|
||||
const out = {
|
||||
tabs: tabs.map((t) => ({
|
||||
url: t.url,
|
||||
duration: t.duration,
|
||||
username: t.username,
|
||||
hasPassword: t.password.length > 0,
|
||||
name: t.name,
|
||||
})),
|
||||
};
|
||||
for (const [key, def] of Object.entries(SCALAR_DEFAULTS)) {
|
||||
out[key] = key in existing ? existing[key] : def;
|
||||
}
|
||||
out.hasLockoutPassword = typeof existing.lockoutPassword === 'string' && existing.lockoutPassword.length > 0;
|
||||
out.dualSwipe = out.swipeMode === 'dual';
|
||||
return out;
|
||||
}
|
||||
|
||||
// Mirrors save_config(): merge known fields onto whatever's already on
|
||||
// disk (see file header). `patch` fields are applied only when present -
|
||||
// omitting a field means "leave it as it is", so each frontend section
|
||||
// (Sites / Display / Lockout) can PUT just the fields it owns.
|
||||
function saveConfig(patch) {
|
||||
const existing = readExisting();
|
||||
const merged = { ...existing };
|
||||
|
||||
for (const [key, def] of Object.entries(SCALAR_DEFAULTS)) {
|
||||
if (!(key in merged)) merged[key] = def;
|
||||
}
|
||||
for (const key of Object.keys(SCALAR_DEFAULTS)) {
|
||||
if (key in patch) merged[key] = patch[key];
|
||||
}
|
||||
|
||||
// Password: only touched when the caller explicitly provides a new
|
||||
// plaintext value to hash. Disabling protection clears the whole
|
||||
// lockout state, mirroring lockout.sh's action_disable_protection()
|
||||
// exactly (not just the enabled flag - the password/timeout/daily
|
||||
// lock time too).
|
||||
if (!('lockoutPassword' in merged)) merged.lockoutPassword = '';
|
||||
if (typeof patch.newLockoutPassword === 'string' && patch.newLockoutPassword.length > 0) {
|
||||
merged.lockoutPassword = hashPassword(patch.newLockoutPassword);
|
||||
}
|
||||
if (patch.enablePasswordProtection === false) {
|
||||
merged.lockoutPassword = '';
|
||||
merged.lockoutTimeout = 0;
|
||||
merged.lockoutAtTime = '';
|
||||
merged.requirePasswordOnBoot = false;
|
||||
}
|
||||
|
||||
if (Array.isArray(patch.tabs)) {
|
||||
const existingTabs = Array.isArray(existing.tabs) ? existing.tabs.map(normalizeTab) : [];
|
||||
merged.tabs = patch.tabs.map((t, i) => {
|
||||
const norm = normalizeTab(t);
|
||||
if (typeof t.password !== 'string') {
|
||||
// No new password supplied for this tab - keep whatever
|
||||
// was already stored at this position (tabs are
|
||||
// positional, not ID-based, matching the bash arrays).
|
||||
norm.password = existingTabs[i] ? existingTabs[i].password : '';
|
||||
}
|
||||
return norm;
|
||||
});
|
||||
} else if (!Array.isArray(merged.tabs)) {
|
||||
merged.tabs = [];
|
||||
}
|
||||
|
||||
merged.autoswitch = true;
|
||||
merged.enableTouch = true;
|
||||
merged.dualSwipe = merged.swipeMode === 'dual';
|
||||
|
||||
const dir = path.dirname(CONFIG_PATH);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const tmp = path.join(dir, `.config.json.tmp-${process.pid}-${Date.now()}`);
|
||||
fs.writeFileSync(tmp, JSON.stringify(merged, null, 2) + '\n', { mode: 0o644 });
|
||||
fs.renameSync(tmp, CONFIG_PATH);
|
||||
|
||||
return loadConfig();
|
||||
}
|
||||
|
||||
module.exports = { loadConfig, saveConfig, CONFIG_PATH, SCALAR_DEFAULTS };
|
||||
@@ -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 };
|
||||
Generated
+828
@@ -0,0 +1,828 @@
|
||||
{
|
||||
"name": "kiosk-webui",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "kiosk-webui",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"express": "^4.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/accepts": {
|
||||
"version": "1.3.8",
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
||||
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-types": "~2.1.34",
|
||||
"negotiator": "0.6.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/array-flatten": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "1.20.6",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz",
|
||||
"integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "~3.1.2",
|
||||
"content-type": "~1.0.5",
|
||||
"debug": "2.6.9",
|
||||
"depd": "2.0.0",
|
||||
"destroy": "~1.2.0",
|
||||
"http-errors": "~2.0.1",
|
||||
"iconv-lite": "~0.4.24",
|
||||
"on-finished": "~2.4.1",
|
||||
"qs": "~6.15.1",
|
||||
"raw-body": "~2.5.3",
|
||||
"type-is": "~1.6.18",
|
||||
"unpipe": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8",
|
||||
"npm": "1.2.8000 || >= 1.4.16"
|
||||
}
|
||||
},
|
||||
"node_modules/bytes": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind-apply-helpers": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bound": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
|
||||
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"get-intrinsic": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/content-disposition": {
|
||||
"version": "0.5.4",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
||||
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "5.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/content-type": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
|
||||
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
||||
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie-signature": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
|
||||
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "2.6.9",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/depd": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/destroy": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
|
||||
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8",
|
||||
"npm": "1.2.8000 || >= 1.4.16"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"gopd": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/ee-first": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/encodeurl": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
||||
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/es-define-property": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-errors": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-object-atoms": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
|
||||
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/escape-html": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
||||
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/etag": {
|
||||
"version": "1.8.1",
|
||||
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
|
||||
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/express": {
|
||||
"version": "4.22.2",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
|
||||
"integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"accepts": "~1.3.8",
|
||||
"array-flatten": "1.1.1",
|
||||
"body-parser": "~1.20.5",
|
||||
"content-disposition": "~0.5.4",
|
||||
"content-type": "~1.0.4",
|
||||
"cookie": "~0.7.1",
|
||||
"cookie-signature": "~1.0.6",
|
||||
"debug": "2.6.9",
|
||||
"depd": "2.0.0",
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"etag": "~1.8.1",
|
||||
"finalhandler": "~1.3.1",
|
||||
"fresh": "~0.5.2",
|
||||
"http-errors": "~2.0.0",
|
||||
"merge-descriptors": "1.0.3",
|
||||
"methods": "~1.1.2",
|
||||
"on-finished": "~2.4.1",
|
||||
"parseurl": "~1.3.3",
|
||||
"path-to-regexp": "~0.1.12",
|
||||
"proxy-addr": "~2.0.7",
|
||||
"qs": "~6.15.1",
|
||||
"range-parser": "~1.2.1",
|
||||
"safe-buffer": "5.2.1",
|
||||
"send": "~0.19.0",
|
||||
"serve-static": "~1.16.2",
|
||||
"setprototypeof": "1.2.0",
|
||||
"statuses": "~2.0.1",
|
||||
"type-is": "~1.6.18",
|
||||
"utils-merge": "1.0.1",
|
||||
"vary": "~1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/finalhandler": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
|
||||
"integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "2.6.9",
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"on-finished": "~2.4.1",
|
||||
"parseurl": "~1.3.3",
|
||||
"statuses": "~2.0.2",
|
||||
"unpipe": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/forwarded": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/fresh": {
|
||||
"version": "0.5.2",
|
||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
|
||||
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"es-define-property": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"es-object-atoms": "^1.1.1",
|
||||
"function-bind": "^1.1.2",
|
||||
"get-proto": "^1.0.1",
|
||||
"gopd": "^1.2.0",
|
||||
"has-symbols": "^1.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"math-intrinsics": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dunder-proto": "^1.0.1",
|
||||
"es-object-atoms": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-symbols": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
|
||||
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/http-errors": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"depd": "~2.0.0",
|
||||
"inherits": "~2.0.4",
|
||||
"setprototypeof": "~1.2.0",
|
||||
"statuses": "~2.0.2",
|
||||
"toidentifier": "~1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.4.24",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
|
||||
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ipaddr.js": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/media-typer": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
|
||||
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/merge-descriptors": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
|
||||
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/methods": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
|
||||
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
|
||||
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"mime": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/negotiator": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
||||
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/object-inspect": {
|
||||
"version": "1.13.4",
|
||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/on-finished": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ee-first": "1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/parseurl": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-to-regexp": {
|
||||
"version": "0.1.13",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
|
||||
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/proxy-addr": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"forwarded": "0.2.0",
|
||||
"ipaddr.js": "1.9.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.15.3",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
|
||||
"integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"es-define-property": "^1.0.1",
|
||||
"side-channel": "^1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/range-parser": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
||||
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/raw-body": {
|
||||
"version": "2.5.3",
|
||||
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
|
||||
"integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "~3.1.2",
|
||||
"http-errors": "~2.0.1",
|
||||
"iconv-lite": "~0.4.24",
|
||||
"unpipe": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/send": {
|
||||
"version": "0.19.2",
|
||||
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
|
||||
"integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "2.6.9",
|
||||
"depd": "2.0.0",
|
||||
"destroy": "1.2.0",
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"etag": "~1.8.1",
|
||||
"fresh": "~0.5.2",
|
||||
"http-errors": "~2.0.1",
|
||||
"mime": "1.6.0",
|
||||
"ms": "2.1.3",
|
||||
"on-finished": "~2.4.1",
|
||||
"range-parser": "~1.2.1",
|
||||
"statuses": "~2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/send/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/serve-static": {
|
||||
"version": "1.16.3",
|
||||
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
|
||||
"integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"parseurl": "~1.3.3",
|
||||
"send": "~0.19.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/setprototypeof": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/side-channel": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
|
||||
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"object-inspect": "^1.13.4",
|
||||
"side-channel-list": "^1.0.1",
|
||||
"side-channel-map": "^1.0.1",
|
||||
"side-channel-weakmap": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel-list": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
|
||||
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"object-inspect": "^1.13.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel-map": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
|
||||
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.5",
|
||||
"object-inspect": "^1.13.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel-weakmap": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
|
||||
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.5",
|
||||
"object-inspect": "^1.13.3",
|
||||
"side-channel-map": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/statuses": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/toidentifier": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
||||
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/type-is": {
|
||||
"version": "1.6.18",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
||||
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"media-typer": "0.3.0",
|
||||
"mime-types": "~2.1.24"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/unpipe": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
||||
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/utils-merge": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
||||
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vary": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "kiosk-webui",
|
||||
"version": "1.0.0",
|
||||
"main": "server.js",
|
||||
"dependencies": {
|
||||
"express": "^4.19.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,622 @@
|
||||
'use strict';
|
||||
|
||||
// webui/public/app.js - vanilla JS, no framework/build step. Every
|
||||
// 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;
|
||||
|
||||
function showMessage(text, isError) {
|
||||
msgEl.textContent = text;
|
||||
msgEl.hidden = false;
|
||||
msgEl.className = 'banner ' + (isError ? 'error' : 'success');
|
||||
clearTimeout(showMessage._t);
|
||||
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');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function apiPut(patch) {
|
||||
const res = await fetch('/api/config', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || 'Save failed');
|
||||
return data;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------- */
|
||||
/* Sites & Page Timing */
|
||||
/* ---------------------------------------------------------------------- */
|
||||
|
||||
const sitesList = document.getElementById('sites-list');
|
||||
const siteRowTemplate = document.getElementById('site-row-template');
|
||||
|
||||
function renderSites(tabs) {
|
||||
sitesList.textContent = '';
|
||||
tabs.forEach((tab, idx) => sitesList.appendChild(buildSiteRow(tab, idx)));
|
||||
if (tabs.length === 0) {
|
||||
const p = document.createElement('p');
|
||||
p.className = 'hint';
|
||||
p.textContent = 'No pages configured yet.';
|
||||
sitesList.appendChild(p);
|
||||
}
|
||||
}
|
||||
|
||||
function buildSiteRow(tab, idx) {
|
||||
const node = siteRowTemplate.content.firstElementChild.cloneNode(true);
|
||||
node.dataset.index = String(idx);
|
||||
|
||||
node.querySelector('.site-url').value = tab.url || '';
|
||||
node.querySelector('.site-name').value = tab.name || '';
|
||||
node.querySelector('.site-duration').value = tab.duration ?? 180;
|
||||
|
||||
const authEnable = node.querySelector('.site-auth-enable');
|
||||
const authUser = node.querySelector('.site-auth-username');
|
||||
const authState = node.querySelector('.auth-state');
|
||||
const hasAuth = !!(tab.username || tab.hasPassword);
|
||||
authEnable.checked = hasAuth;
|
||||
authUser.value = tab.username || '';
|
||||
authState.textContent = hasAuth ? '(enabled)' : '(disabled)';
|
||||
|
||||
node.querySelector('.remove-site').addEventListener('click', () => {
|
||||
node.remove();
|
||||
if (!sitesList.querySelector('.site-row')) renderSites([]);
|
||||
});
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
document.getElementById('add-site').addEventListener('click', () => {
|
||||
if (sitesList.querySelector('.hint')) sitesList.textContent = '';
|
||||
sitesList.appendChild(buildSiteRow({ url: '', name: '', duration: 180, username: '', hasPassword: false }, sitesList.children.length));
|
||||
updateHomeTabOptions(collectTabs());
|
||||
});
|
||||
document.getElementById('save-sites').addEventListener('click', saveSites);
|
||||
|
||||
function collectTabs() {
|
||||
return Array.from(sitesList.querySelectorAll('.site-row')).map((row) => {
|
||||
const tab = {
|
||||
url: row.querySelector('.site-url').value.trim(),
|
||||
name: row.querySelector('.site-name').value.trim(),
|
||||
duration: parseInt(row.querySelector('.site-duration').value, 10),
|
||||
};
|
||||
const authEnabled = row.querySelector('.site-auth-enable').checked;
|
||||
if (authEnabled) {
|
||||
tab.username = row.querySelector('.site-auth-username').value;
|
||||
const newPass = row.querySelector('.site-auth-password').value;
|
||||
if (newPass) tab.password = newPass;
|
||||
} else {
|
||||
tab.username = '';
|
||||
tab.password = '';
|
||||
}
|
||||
return tab;
|
||||
});
|
||||
}
|
||||
|
||||
async function saveSites() {
|
||||
const tabs = collectTabs();
|
||||
if (tabs.some((t) => !t.url)) {
|
||||
showMessage('Every site needs a URL', true);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
currentConfig = await apiPut({ tabs });
|
||||
showMessage('Sites saved');
|
||||
renderSites(currentConfig.tabs);
|
||||
populateAll(currentConfig);
|
||||
} catch (e) {
|
||||
showMessage(e.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
sitesList.addEventListener('change', () => { updateHomeTabOptions(collectTabs()); });
|
||||
|
||||
/* ---------------------------------------------------------------------- */
|
||||
/* Display & Interaction */
|
||||
/* ---------------------------------------------------------------------- */
|
||||
|
||||
const displayForm = document.getElementById('display-form');
|
||||
const homeTabSelect = document.getElementById('home-tab-select');
|
||||
|
||||
function updateHomeTabOptions(tabs) {
|
||||
const previous = homeTabSelect.value;
|
||||
homeTabSelect.textContent = '';
|
||||
const disabledOpt = document.createElement('option');
|
||||
disabledOpt.value = '-1';
|
||||
disabledOpt.textContent = 'Disabled';
|
||||
homeTabSelect.appendChild(disabledOpt);
|
||||
tabs.forEach((tab, idx) => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = String(idx);
|
||||
opt.textContent = tab.name || tab.url || `Page ${idx + 1}`;
|
||||
homeTabSelect.appendChild(opt);
|
||||
});
|
||||
const stillValid = Array.from(homeTabSelect.options).some((o) => o.value === previous);
|
||||
homeTabSelect.value = stillValid ? previous : '-1';
|
||||
}
|
||||
|
||||
displayForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const fd = new FormData(displayForm);
|
||||
const patch = {
|
||||
swipeMode: fd.get('swipeMode'),
|
||||
allowNavigation: fd.get('allowNavigation'),
|
||||
enablePauseButton: fd.get('enablePauseButton') === 'on',
|
||||
enableKeyboardButton: fd.get('enableKeyboardButton') === 'on',
|
||||
enableNavButton: fd.get('enableNavButton') === 'on',
|
||||
homeTabIndex: parseInt(fd.get('homeTabIndex'), 10),
|
||||
inactivityTimeoutMinutes: parseInt(fd.get('inactivityTimeoutMinutes'), 10),
|
||||
};
|
||||
try {
|
||||
currentConfig = await apiPut(patch);
|
||||
showMessage('Display & Interaction saved');
|
||||
populateAll(currentConfig);
|
||||
} catch (err) {
|
||||
showMessage(err.message, true);
|
||||
}
|
||||
});
|
||||
|
||||
/* ---------------------------------------------------------------------- */
|
||||
/* Password Protection & Lockout */
|
||||
/* ---------------------------------------------------------------------- */
|
||||
|
||||
const lockoutForm = document.getElementById('lockout-form');
|
||||
const lockoutEnabled = document.getElementById('lockout-enabled');
|
||||
const lockoutFields = document.getElementById('lockout-fields');
|
||||
const dailyLockEnabled = document.getElementById('daily-lock-enabled');
|
||||
const lockoutAtTime = document.getElementById('lockout-at-time');
|
||||
const passwordLabel = document.getElementById('password-label');
|
||||
|
||||
function refreshLockoutFieldVisibility() {
|
||||
lockoutFields.hidden = !lockoutEnabled.checked;
|
||||
}
|
||||
lockoutEnabled.addEventListener('change', refreshLockoutFieldVisibility);
|
||||
|
||||
dailyLockEnabled.addEventListener('change', () => {
|
||||
lockoutAtTime.disabled = !dailyLockEnabled.checked;
|
||||
if (!dailyLockEnabled.checked) lockoutAtTime.value = '';
|
||||
});
|
||||
|
||||
lockoutForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const fd = new FormData(lockoutForm);
|
||||
const enable = fd.get('enablePasswordProtection') === 'on';
|
||||
const newPassword = fd.get('newLockoutPassword') || '';
|
||||
const confirmPassword = document.getElementById('lockout-password-confirm').value;
|
||||
|
||||
if (newPassword && newPassword !== confirmPassword) {
|
||||
showMessage("Passwords don't match", true);
|
||||
return;
|
||||
}
|
||||
if (enable && !newPassword && !(currentConfig && currentConfig.hasLockoutPassword)) {
|
||||
showMessage('Set a lockout password before enabling password protection', true);
|
||||
return;
|
||||
}
|
||||
|
||||
const patch = { enablePasswordProtection: enable };
|
||||
if (enable) {
|
||||
if (newPassword) patch.newLockoutPassword = newPassword;
|
||||
patch.lockoutTimeoutMinutes = parseInt(fd.get('lockoutTimeoutMinutes'), 10);
|
||||
patch.lockoutAtTime = dailyLockEnabled.checked ? fd.get('lockoutAtTime') : '';
|
||||
patch.requirePasswordOnBoot = fd.get('requirePasswordOnBoot') === 'on';
|
||||
}
|
||||
|
||||
try {
|
||||
currentConfig = await apiPut(patch);
|
||||
showMessage('Password Protection & Lockout saved');
|
||||
populateAll(currentConfig);
|
||||
lockoutForm.querySelector('[name=newLockoutPassword]').value = '';
|
||||
document.getElementById('lockout-password-confirm').value = '';
|
||||
} catch (err) {
|
||||
showMessage(err.message, true);
|
||||
}
|
||||
});
|
||||
|
||||
function populateAll(config) {
|
||||
displayForm.elements.swipeMode.value = config.swipeMode;
|
||||
displayForm.elements.allowNavigation.value = config.allowNavigation;
|
||||
displayForm.elements.enablePauseButton.checked = !!config.enablePauseButton;
|
||||
displayForm.elements.enableKeyboardButton.checked = !!config.enableKeyboardButton;
|
||||
displayForm.elements.enableNavButton.checked = !!config.enableNavButton;
|
||||
updateHomeTabOptions(config.tabs);
|
||||
homeTabSelect.value = String(config.homeTabIndex);
|
||||
displayForm.elements.inactivityTimeoutMinutes.value = Math.round(config.inactivityTimeout / 60);
|
||||
|
||||
lockoutEnabled.checked = !!config.enablePasswordProtection;
|
||||
passwordLabel.textContent = config.hasLockoutPassword ? 'New password (leave blank to keep the current one)' : 'Set lockout password';
|
||||
lockoutForm.elements.lockoutTimeoutMinutes.value = config.lockoutTimeout;
|
||||
dailyLockEnabled.checked = !!config.lockoutAtTime;
|
||||
lockoutAtTime.disabled = !config.lockoutAtTime;
|
||||
lockoutAtTime.value = config.lockoutAtTime || '';
|
||||
lockoutForm.elements.requirePasswordOnBoot.checked = !!config.requirePasswordOnBoot;
|
||||
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();
|
||||
renderSites(currentConfig.tabs);
|
||||
populateAll(currentConfig);
|
||||
} catch (e) {
|
||||
showMessage(e.message, true);
|
||||
}
|
||||
await loadAddonStatus();
|
||||
renderAddons();
|
||||
}
|
||||
|
||||
init();
|
||||
@@ -0,0 +1,240 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Kiosk Web UI</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<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>
|
||||
|
||||
<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 & 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 & 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 & Page Timing</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Display & Interaction -->
|
||||
<section class="page" id="page-display">
|
||||
<div class="page-header">
|
||||
<h1>Display & 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 & Interaction</button></div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Password Protection & Lockout -->
|
||||
<section class="page" id="page-lockout">
|
||||
<div class="page-header">
|
||||
<h1>Password Protection & 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 & 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">
|
||||
<div class="site-row-grid">
|
||||
<label>
|
||||
URL
|
||||
<input type="text" class="site-url" placeholder="example.com or https://example.com">
|
||||
</label>
|
||||
<label>
|
||||
Name (optional)
|
||||
<input type="text" class="site-name" placeholder="Shown instead of the URL">
|
||||
</label>
|
||||
<label>
|
||||
Duration (seconds; -1=hidden, 0=manual, >0=auto-rotate)
|
||||
<input type="number" class="site-duration" min="-1" max="86400" step="1" value="180">
|
||||
</label>
|
||||
</div>
|
||||
<details class="site-auth">
|
||||
<summary>HTTP Basic Auth <span class="auth-state"></span></summary>
|
||||
<label class="checkbox"><input type="checkbox" class="site-auth-enable"> Requires a username/password</label>
|
||||
<label>
|
||||
Username
|
||||
<input type="text" class="site-auth-username">
|
||||
</label>
|
||||
<label>
|
||||
New password <span class="hint">(leave blank to keep the current one)</span>
|
||||
<input type="password" class="site-auth-password" autocomplete="new-password">
|
||||
</label>
|
||||
</details>
|
||||
<button type="button" class="remove-site danger">Remove page</button>
|
||||
</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…</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>
|
||||
@@ -0,0 +1,395 @@
|
||||
/* 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 {
|
||||
--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;
|
||||
--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);
|
||||
font-family: var(--font);
|
||||
font-size: 14.5px;
|
||||
line-height: 1.55;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
a { color: var(--accent); }
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Shell: fixed sidebar + main content */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
.shell {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: var(--sidebar-w);
|
||||
flex-shrink: 0;
|
||||
background: var(--bg-elevated);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
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(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
padding: 20px 22px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.card h2 { margin: 0 0 14px; font-size: 14.5px; font-weight: 600; }
|
||||
|
||||
.card-inset {
|
||||
background: var(--bg-sunken);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
form { display: flex; flex-direction: column; gap: 14px; }
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
label.checkbox {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
}
|
||||
label.checkbox input { width: 16px; height: 16px; accent-color: var(--accent); }
|
||||
|
||||
.hint { color: var(--text-faint); font-size: 11.5px; font-weight: normal; }
|
||||
|
||||
input[type="text"],
|
||||
input[type="password"],
|
||||
input[type="number"],
|
||||
input[type="time"],
|
||||
select {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 8px;
|
||||
color: var(--text);
|
||||
padding: 8px 10px;
|
||||
font-size: 13.5px;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.12s ease;
|
||||
}
|
||||
input:focus, select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
}
|
||||
|
||||
fieldset {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 14px 16px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
margin: 0;
|
||||
}
|
||||
legend { padding: 0 6px; color: var(--text-dim); font-size: 12px; font-weight: 600; }
|
||||
|
||||
button {
|
||||
border: none;
|
||||
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-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-row { display: flex; gap: 8px; margin-top: 4px; flex-wrap: wrap; }
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* 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: 12px;
|
||||
}
|
||||
@media (max-width: 640px) { .site-row-grid { grid-template-columns: 1fr; } }
|
||||
|
||||
.site-auth {
|
||||
margin-top: 10px;
|
||||
border-top: 1px solid var(--border);
|
||||
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; }
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Toast banner */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
.banner {
|
||||
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); }
|
||||
|
||||
@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; }
|
||||
}
|
||||
+222
@@ -0,0 +1,222 @@
|
||||
'use strict';
|
||||
|
||||
// webui/server.js - Kiosk Web UI: browser-based config 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 (see menus/addon_webui.sh's
|
||||
// header for why the rest of Core Settings/Addons/Advanced aren't here).
|
||||
//
|
||||
// No login of its own by design: Authelia runs elsewhere, and the admin
|
||||
// site goes behind the user's own Caddy reverse proxy with Authelia
|
||||
// forward-auth in front of it, the same way every other self-hosted app
|
||||
// they run is protected. This process only binds where it's told to
|
||||
// (BIND_ADDR/PORT below) and trusts whatever's in front of it.
|
||||
//
|
||||
// Runs as $KIOSK_USER (see the systemd unit menus/addon_webui.sh
|
||||
// installs) - the same user Electron runs as, and the owner of
|
||||
// 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' }));
|
||||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
|
||||
const SWIPE_MODES = ['dual', 'standard'];
|
||||
const NAV_MODES = ['restricted', 'same-origin', 'open'];
|
||||
const TIME_RE = /^([01]\d|2[0-3]):[0-5]\d$/;
|
||||
|
||||
// Same normalization rule as menus/sites.sh's sites_parse_url(): bare
|
||||
// host -> https://, bare IPv4 -> http://, else passed through as-is.
|
||||
function parseUrl(raw) {
|
||||
if (/^https?:\/\//.test(raw)) return raw;
|
||||
if (/^\d+\.\d+\.\d+\.\d+/.test(raw)) return `http://${raw}`;
|
||||
return `https://${raw}`;
|
||||
}
|
||||
|
||||
function badRequest(res, message) {
|
||||
res.status(400).json({ error: message });
|
||||
}
|
||||
|
||||
app.get('/api/config', (req, res) => {
|
||||
res.json(loadConfig());
|
||||
});
|
||||
|
||||
app.put('/api/config', (req, res) => {
|
||||
const body = req.body && typeof req.body === 'object' ? req.body : {};
|
||||
const patch = {};
|
||||
|
||||
if (body.swipeMode !== undefined) {
|
||||
if (!SWIPE_MODES.includes(body.swipeMode)) return badRequest(res, 'swipeMode must be "dual" or "standard"');
|
||||
patch.swipeMode = body.swipeMode;
|
||||
}
|
||||
if (body.allowNavigation !== undefined) {
|
||||
if (!NAV_MODES.includes(body.allowNavigation)) {
|
||||
return badRequest(res, 'allowNavigation must be "restricted", "same-origin", or "open"');
|
||||
}
|
||||
patch.allowNavigation = body.allowNavigation;
|
||||
}
|
||||
if (body.enablePauseButton !== undefined) patch.enablePauseButton = !!body.enablePauseButton;
|
||||
if (body.enableKeyboardButton !== undefined) patch.enableKeyboardButton = !!body.enableKeyboardButton;
|
||||
if (body.enableNavButton !== undefined) patch.enableNavButton = !!body.enableNavButton;
|
||||
|
||||
let tabCount;
|
||||
if (body.tabs !== undefined) {
|
||||
if (!Array.isArray(body.tabs)) return badRequest(res, 'tabs must be an array');
|
||||
for (const t of body.tabs) {
|
||||
if (!t || typeof t.url !== 'string' || t.url.trim() === '') return badRequest(res, 'Every site needs a URL');
|
||||
const dur = Number(t.duration);
|
||||
if (!Number.isInteger(dur) || dur < -1 || dur > 86400) {
|
||||
return badRequest(res, 'Duration must be a whole number between -1 and 86400');
|
||||
}
|
||||
}
|
||||
patch.tabs = body.tabs.map((t) => ({ ...t, url: parseUrl(t.url.trim()) }));
|
||||
tabCount = patch.tabs.length;
|
||||
}
|
||||
|
||||
if (body.homeTabIndex !== undefined) {
|
||||
const idx = Number(body.homeTabIndex);
|
||||
const count = tabCount !== undefined ? tabCount : loadConfig().tabs.length;
|
||||
if (!Number.isInteger(idx) || idx < -1 || idx >= count) return badRequest(res, 'homeTabIndex is out of range');
|
||||
patch.homeTabIndex = idx;
|
||||
}
|
||||
|
||||
if (body.inactivityTimeoutMinutes !== undefined) {
|
||||
const min = Number(body.inactivityTimeoutMinutes);
|
||||
if (!Number.isInteger(min) || min < 1 || min > 240) return badRequest(res, 'Inactivity timeout must be 1-240 minutes');
|
||||
patch.inactivityTimeout = min * 60;
|
||||
}
|
||||
|
||||
if (body.enablePasswordProtection !== undefined) patch.enablePasswordProtection = !!body.enablePasswordProtection;
|
||||
|
||||
if (body.lockoutTimeoutMinutes !== undefined) {
|
||||
const min = Number(body.lockoutTimeoutMinutes);
|
||||
if (!Number.isInteger(min) || min < 0 || min > 1440) return badRequest(res, 'Lockout timeout must be 0-1440 minutes');
|
||||
patch.lockoutTimeout = min;
|
||||
}
|
||||
|
||||
if (body.lockoutAtTime !== undefined) {
|
||||
if (body.lockoutAtTime !== '' && !TIME_RE.test(body.lockoutAtTime)) {
|
||||
return badRequest(res, 'lockoutAtTime must be HH:MM (24-hour) or empty');
|
||||
}
|
||||
patch.lockoutAtTime = body.lockoutAtTime;
|
||||
}
|
||||
|
||||
if (body.requirePasswordOnBoot !== undefined) patch.requirePasswordOnBoot = !!body.requirePasswordOnBoot;
|
||||
|
||||
if (body.newLockoutPassword !== undefined) {
|
||||
if (typeof body.newLockoutPassword !== 'string' || body.newLockoutPassword.length === 0) {
|
||||
return badRequest(res, 'Password cannot be empty');
|
||||
}
|
||||
patch.newLockoutPassword = body.newLockoutPassword;
|
||||
}
|
||||
|
||||
// Mirrors action_enable_protection() always requiring a password up
|
||||
// front - lockout.sh has no path that enables protection without one.
|
||||
if (patch.enablePasswordProtection === true) {
|
||||
const hasNewPassword = typeof patch.newLockoutPassword === 'string' && patch.newLockoutPassword.length > 0;
|
||||
if (!hasNewPassword && !loadConfig().hasLockoutPassword) {
|
||||
return badRequest(res, 'Set a lockout password before enabling password protection');
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
res.json(saveConfig(patch));
|
||||
} catch (e) {
|
||||
console.error('saveConfig failed:', e);
|
||||
res.status(500).json({ error: 'Failed to save configuration' });
|
||||
}
|
||||
});
|
||||
|
||||
/* ---------------------------------------------------------------------- */
|
||||
/* 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';
|
||||
|
||||
if (require.main === module) {
|
||||
app.listen(PORT, BIND_ADDR, () => {
|
||||
console.log(`Kiosk Web UI listening on ${BIND_ADDR}:${PORT}`);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = app;
|
||||
@@ -0,0 +1,170 @@
|
||||
'use strict';
|
||||
|
||||
// webui/test/api.test.js - integration test: starts the real server.js
|
||||
// app on a random port against a scratch config.json and hits GET/PUT
|
||||
// /api/config with real HTTP requests (Node's built-in fetch). Run with:
|
||||
// node test/api.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-api-test-'));
|
||||
process.env.CONFIG_PATH = path.join(scratchDir, 'config.json');
|
||||
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
|
||||
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/config returns defaults on a fresh install', async () => {
|
||||
const res = await fetch(`${base}/api/config`);
|
||||
assert.strictEqual(res.status, 200);
|
||||
const body = await res.json();
|
||||
assert.deepStrictEqual(body.tabs, []);
|
||||
assert.strictEqual(body.swipeMode, 'dual');
|
||||
});
|
||||
|
||||
await check('PUT /api/config saves and round-trips display settings', async () => {
|
||||
const res = await fetch(`${base}/api/config`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ swipeMode: 'standard', allowNavigation: 'restricted', enableNavButton: false }),
|
||||
});
|
||||
assert.strictEqual(res.status, 200);
|
||||
const body = await res.json();
|
||||
assert.strictEqual(body.swipeMode, 'standard');
|
||||
assert.strictEqual(body.allowNavigation, 'restricted');
|
||||
assert.strictEqual(body.enableNavButton, false);
|
||||
});
|
||||
|
||||
await check('PUT rejects an invalid allowNavigation value', async () => {
|
||||
const res = await fetch(`${base}/api/config`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ allowNavigation: 'wide-open' }),
|
||||
});
|
||||
assert.strictEqual(res.status, 400);
|
||||
});
|
||||
|
||||
await check('PUT rejects a duration out of range', async () => {
|
||||
const res = await fetch(`${base}/api/config`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ tabs: [{ url: 'example.com', duration: 999999 }] }),
|
||||
});
|
||||
assert.strictEqual(res.status, 400);
|
||||
});
|
||||
|
||||
await check('PUT normalizes bare hostnames/IPs the same way sites_parse_url does', async () => {
|
||||
const res = await fetch(`${base}/api/config`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
tabs: [
|
||||
{ url: 'example.com', duration: 30, name: 'bare host' },
|
||||
{ url: '192.168.1.50', duration: 30, name: 'bare ip' },
|
||||
{ url: 'https://already.example.com', duration: 30, name: 'already a url' },
|
||||
],
|
||||
}),
|
||||
});
|
||||
assert.strictEqual(res.status, 200);
|
||||
const body = await res.json();
|
||||
assert.strictEqual(body.tabs[0].url, 'https://example.com');
|
||||
assert.strictEqual(body.tabs[1].url, 'http://192.168.1.50');
|
||||
assert.strictEqual(body.tabs[2].url, 'https://already.example.com');
|
||||
});
|
||||
|
||||
await check('PUT rejects homeTabIndex out of range', async () => {
|
||||
const res = await fetch(`${base}/api/config`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ homeTabIndex: 99 }),
|
||||
});
|
||||
assert.strictEqual(res.status, 400);
|
||||
});
|
||||
|
||||
await check('PUT accepts a valid homeTabIndex and converts inactivity minutes to stored seconds', async () => {
|
||||
const res = await fetch(`${base}/api/config`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ homeTabIndex: 1, inactivityTimeoutMinutes: 5 }),
|
||||
});
|
||||
assert.strictEqual(res.status, 200);
|
||||
const body = await res.json();
|
||||
assert.strictEqual(body.homeTabIndex, 1);
|
||||
assert.strictEqual(body.inactivityTimeout, 300);
|
||||
|
||||
const onDisk = JSON.parse(fs.readFileSync(process.env.CONFIG_PATH, 'utf8'));
|
||||
assert.strictEqual(onDisk.inactivityTimeout, 300);
|
||||
});
|
||||
|
||||
await check('PUT rejects enabling password protection with no password set', async () => {
|
||||
const res = await fetch(`${base}/api/config`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enablePasswordProtection: true }),
|
||||
});
|
||||
assert.strictEqual(res.status, 400);
|
||||
});
|
||||
|
||||
await check('PUT enables password protection when a new password is supplied, and never echoes it back', async () => {
|
||||
const res = await fetch(`${base}/api/config`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enablePasswordProtection: true, newLockoutPassword: 'hunter2', lockoutTimeoutMinutes: 15 }),
|
||||
});
|
||||
assert.strictEqual(res.status, 200);
|
||||
const body = await res.json();
|
||||
assert.strictEqual(body.enablePasswordProtection, true);
|
||||
assert.strictEqual(body.hasLockoutPassword, true);
|
||||
assert.strictEqual(body.lockoutPassword, undefined);
|
||||
assert.strictEqual(JSON.stringify(body).includes('hunter2'), false);
|
||||
});
|
||||
|
||||
await check('PUT rejects a malformed lockoutAtTime', async () => {
|
||||
const res = await fetch(`${base}/api/config`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ lockoutAtTime: '25:99' }),
|
||||
});
|
||||
assert.strictEqual(res.status, 400);
|
||||
});
|
||||
|
||||
await check('PUT re-enabling protection without a new password succeeds once one is already set', async () => {
|
||||
const res = await fetch(`${base}/api/config`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enablePasswordProtection: true, lockoutTimeoutMinutes: 20 }),
|
||||
});
|
||||
assert.strictEqual(res.status, 200);
|
||||
const body = await res.json();
|
||||
assert.strictEqual(body.lockoutTimeout, 20);
|
||||
});
|
||||
|
||||
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();
|
||||
@@ -0,0 +1,137 @@
|
||||
'use strict';
|
||||
|
||||
// webui/test/config.test.js - unit tests for lib/config.js against a
|
||||
// scratch config.json. Run with: node test/config.test.js
|
||||
//
|
||||
// Mirrors this project's bash test convention (PASS/FAIL lines, ALL DONE
|
||||
// at the end) rather than pulling in a test framework dependency.
|
||||
|
||||
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-config-test-'));
|
||||
process.env.CONFIG_PATH = path.join(scratchDir, 'config.json');
|
||||
|
||||
const { loadConfig, saveConfig } = require('../lib/config');
|
||||
|
||||
let failures = 0;
|
||||
function check(label, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(`PASS: ${label}`);
|
||||
} catch (e) {
|
||||
failures++;
|
||||
console.log(`FAIL: ${label} - ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
check('loadConfig on a missing file returns documented defaults', () => {
|
||||
const cfg = loadConfig();
|
||||
assert.deepStrictEqual(cfg.tabs, []);
|
||||
assert.strictEqual(cfg.swipeMode, 'dual');
|
||||
assert.strictEqual(cfg.allowNavigation, 'same-origin');
|
||||
assert.strictEqual(cfg.homeTabIndex, -1);
|
||||
assert.strictEqual(cfg.inactivityTimeout, 120);
|
||||
assert.strictEqual(cfg.enablePasswordProtection, false);
|
||||
assert.strictEqual(cfg.hasLockoutPassword, false);
|
||||
assert.strictEqual(cfg.dualSwipe, true);
|
||||
});
|
||||
|
||||
check('saveConfig creates the file and round-trips scalar fields', () => {
|
||||
const result = saveConfig({ swipeMode: 'standard', allowNavigation: 'open', enablePauseButton: false });
|
||||
assert.strictEqual(result.swipeMode, 'standard');
|
||||
assert.strictEqual(result.allowNavigation, 'open');
|
||||
assert.strictEqual(result.enablePauseButton, false);
|
||||
assert.strictEqual(result.dualSwipe, false);
|
||||
|
||||
const onDisk = JSON.parse(fs.readFileSync(process.env.CONFIG_PATH, 'utf8'));
|
||||
assert.strictEqual(onDisk.swipeMode, 'standard');
|
||||
assert.strictEqual(onDisk.autoswitch, true);
|
||||
assert.strictEqual(onDisk.enableTouch, true);
|
||||
});
|
||||
|
||||
check('saveConfig merge preserves fields this app never tracks (the previously-fixed clobber bug)', () => {
|
||||
// Simulate a file with Authelia + quiet-hours fields already set, the
|
||||
// way the terminal addon/menus would have written them - config.js
|
||||
// must never know these exist and must never delete them.
|
||||
const existing = JSON.parse(fs.readFileSync(process.env.CONFIG_PATH, 'utf8'));
|
||||
existing.autheliaURL = 'https://auth.example.com';
|
||||
existing.autheliaUsername = 'kiosk';
|
||||
existing.autheliaEncryptedPassword = 'deadbeef';
|
||||
existing.lockoutActiveStart = '22:00';
|
||||
existing.lockoutActiveEnd = '06:00';
|
||||
fs.writeFileSync(process.env.CONFIG_PATH, JSON.stringify(existing));
|
||||
|
||||
saveConfig({ enableNavButton: false });
|
||||
|
||||
const onDisk = JSON.parse(fs.readFileSync(process.env.CONFIG_PATH, 'utf8'));
|
||||
assert.strictEqual(onDisk.autheliaURL, 'https://auth.example.com');
|
||||
assert.strictEqual(onDisk.autheliaUsername, 'kiosk');
|
||||
assert.strictEqual(onDisk.autheliaEncryptedPassword, 'deadbeef');
|
||||
assert.strictEqual(onDisk.lockoutActiveStart, '22:00');
|
||||
assert.strictEqual(onDisk.lockoutActiveEnd, '06:00');
|
||||
assert.strictEqual(onDisk.enableNavButton, false);
|
||||
});
|
||||
|
||||
check('saveConfig tabs: new password gets hashed, never stored/returned as plaintext', () => {
|
||||
const result = saveConfig({
|
||||
tabs: [{ url: 'https://a.example.com', duration: 30, name: 'A', username: 'bob', password: 'hunter2' }],
|
||||
});
|
||||
assert.strictEqual(result.tabs[0].hasPassword, true);
|
||||
assert.strictEqual(result.tabs[0].username, 'bob');
|
||||
assert.strictEqual(result.tabs[0].password, undefined);
|
||||
|
||||
const onDisk = JSON.parse(fs.readFileSync(process.env.CONFIG_PATH, 'utf8'));
|
||||
assert.strictEqual(onDisk.tabs[0].password, 'hunter2'); // stored plaintext by design, matches lib/config.sh's own PASSES/USERS handling for Basic Auth (not the lockout password)
|
||||
});
|
||||
|
||||
check('saveConfig tabs: omitting password on an existing tab keeps the stored one (positional identity)', () => {
|
||||
saveConfig({
|
||||
tabs: [{ url: 'https://a.example.com', duration: 45, name: 'A renamed', username: 'bob' }],
|
||||
});
|
||||
const onDisk = JSON.parse(fs.readFileSync(process.env.CONFIG_PATH, 'utf8'));
|
||||
assert.strictEqual(onDisk.tabs[0].password, 'hunter2');
|
||||
assert.strictEqual(onDisk.tabs[0].duration, 45);
|
||||
assert.strictEqual(onDisk.tabs[0].name, 'A renamed');
|
||||
});
|
||||
|
||||
check('saveConfig lockout password is SHA-256 hashed, matching lockout.sh/main.js', () => {
|
||||
const crypto = require('crypto');
|
||||
saveConfig({ enablePasswordProtection: true, newLockoutPassword: 'correcthorse' });
|
||||
const onDisk = JSON.parse(fs.readFileSync(process.env.CONFIG_PATH, 'utf8'));
|
||||
const expected = crypto.createHash('sha256').update('correcthorse', 'utf8').digest('hex');
|
||||
assert.strictEqual(onDisk.lockoutPassword, expected);
|
||||
|
||||
const result = loadConfig();
|
||||
assert.strictEqual(result.hasLockoutPassword, true);
|
||||
assert.strictEqual(result.lockoutPassword, undefined);
|
||||
});
|
||||
|
||||
check('saveConfig disabling password protection clears the whole lockout state (matches action_disable_protection)', () => {
|
||||
saveConfig({ enablePasswordProtection: true, newLockoutPassword: 'x', lockoutTimeout: 30 });
|
||||
const before = loadConfig();
|
||||
assert.strictEqual(before.hasLockoutPassword, true);
|
||||
|
||||
saveConfig({ enablePasswordProtection: false });
|
||||
const onDisk = JSON.parse(fs.readFileSync(process.env.CONFIG_PATH, 'utf8'));
|
||||
assert.strictEqual(onDisk.lockoutPassword, '');
|
||||
assert.strictEqual(onDisk.lockoutTimeout, 0);
|
||||
assert.strictEqual(onDisk.lockoutAtTime, '');
|
||||
assert.strictEqual(onDisk.requirePasswordOnBoot, false);
|
||||
});
|
||||
|
||||
check('saveConfig with invalid JSON already on disk falls back to {} rather than crashing', () => {
|
||||
fs.writeFileSync(process.env.CONFIG_PATH, '{not valid json');
|
||||
const result = saveConfig({ swipeMode: 'dual' });
|
||||
assert.strictEqual(result.swipeMode, 'dual');
|
||||
});
|
||||
|
||||
fs.rmSync(scratchDir, { recursive: true, force: true });
|
||||
|
||||
if (failures > 0) {
|
||||
console.log(`${failures} FAILURE(S)`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('ALL DONE');
|
||||
+30
@@ -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
@@ -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}"
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user