Compare commits
10
Commits
5d56c667da
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08b33ef823 | ||
|
|
d6d6177dd9 | ||
|
|
ff67e422a4 | ||
|
|
e607e8cea0 | ||
|
|
b6ed4aad9c | ||
|
|
1fad3393ac | ||
|
|
d0b76dc6cf | ||
|
|
d411b05cae | ||
|
|
037a62e008 | ||
|
|
1c9447bc5d |
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
webui/test/fixtures/.fake-state/
|
||||
@@ -1,6 +1,6 @@
|
||||
# Ubuntu Based Kiosk
|
||||
|
||||
**Current Version:** 2.13.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,38 +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/fleet_profile.sh` — **Fleet Profile** (Advanced): export/apply
|
||||
- `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, 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). The legacy Export/
|
||||
Import Settings is also staying as-is; Fleet Profile 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.
|
||||
**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"`)
|
||||
@@ -1313,10 +1357,42 @@ full migration pass.
|
||||
|
||||
## Project Status & Future Plans
|
||||
|
||||
**Current Version:** 2.13.0
|
||||
**Current Version:** 2.17.0
|
||||
|
||||
**Recent Updates (v2.13.0):**
|
||||
- **New: Fleet Profile** (Advanced → Fleet Profile) — 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.
|
||||
**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.
|
||||
|
||||
|
||||
+81
-37
@@ -1,41 +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), Fleet Profile
|
||||
# (menus/fleet_profile.sh - export/apply portable settings across
|
||||
# (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).
|
||||
# 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
|
||||
@@ -49,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
|
||||
@@ -75,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
|
||||
@@ -84,12 +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/fleet_profile.sh
|
||||
# shellcheck source=menus/clone_settings.sh
|
||||
# Also composes detection helpers (*_is_installed) from every addon above.
|
||||
source "$SCRIPT_DIR/menus/fleet_profile.sh"
|
||||
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
|
||||
@@ -110,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
|
||||
@@ -155,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() {
|
||||
@@ -164,8 +183,8 @@ addons_menu() {
|
||||
}
|
||||
|
||||
advanced_menu_builder() {
|
||||
MENU_LABELS=("Diagnostics" "Electron Maintenance" "Factory Reset" "Virtual Consoles" "Emergency Hotspot" "Fleet Profile")
|
||||
MENU_HANDLERS=(diagnostics_menu advanced_electron_menu advanced_factory_reset_menu advanced_virtual_consoles_menu advanced_emergency_hotspot_menu fleet_profile_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() {
|
||||
@@ -181,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
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/bin/bash
|
||||
################################################################################
|
||||
# menus/fleet_profile.sh - "Fleet Profile" (Advanced): export the portable
|
||||
# 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.
|
||||
@@ -32,20 +32,20 @@
|
||||
# being sourced first (for the *_is_installed detection helpers).
|
||||
################################################################################
|
||||
|
||||
fleet_profile_status() {
|
||||
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."
|
||||
}
|
||||
|
||||
fleet_profile_menu_builder() {
|
||||
MENU_LABELS=("Export fleet profile" "Apply fleet profile")
|
||||
MENU_HANDLERS=(action_export_fleet_profile action_apply_fleet_profile)
|
||||
clone_settings_menu_builder() {
|
||||
MENU_LABELS=("Export settings" "Apply settings (clone)")
|
||||
MENU_HANDLERS=(action_export_clone_settings action_apply_clone_settings)
|
||||
}
|
||||
|
||||
fleet_profile_menu() {
|
||||
run_menu "FLEET PROFILE" fleet_profile_menu_builder fleet_profile_status
|
||||
clone_settings_menu() {
|
||||
run_menu "CLONE SETTINGS" clone_settings_menu_builder clone_settings_status
|
||||
}
|
||||
|
||||
################################################################################
|
||||
@@ -53,7 +53,7 @@ fleet_profile_menu() {
|
||||
################################################################################
|
||||
|
||||
# JSON array of addon identifiers currently present on this machine.
|
||||
fleet_detect_addons() {
|
||||
clone_detect_addons() {
|
||||
local addons=()
|
||||
|
||||
cups_is_installed 2>/dev/null && addons+=("cups")
|
||||
@@ -64,6 +64,7 @@ fleet_detect_addons() {
|
||||
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
|
||||
@@ -82,7 +83,7 @@ fleet_detect_addons() {
|
||||
# Actions
|
||||
################################################################################
|
||||
|
||||
action_export_fleet_profile() {
|
||||
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"
|
||||
@@ -91,7 +92,7 @@ action_export_fleet_profile() {
|
||||
fi
|
||||
|
||||
local out_path
|
||||
out_path=$(ask_text "Export profile to" "$HOME/kiosk-fleet-profile.json")
|
||||
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)
|
||||
@@ -105,7 +106,7 @@ action_export_fleet_profile() {
|
||||
settings=$(echo "$raw_config" | jq 'del(.autheliaURL, .autheliaUsername, .autheliaEncryptedPassword)')
|
||||
|
||||
local addons_present
|
||||
addons_present=$(fleet_detect_addons)
|
||||
addons_present=$(clone_detect_addons)
|
||||
|
||||
jq -n \
|
||||
--argjson settings "$settings" \
|
||||
@@ -114,7 +115,7 @@ action_export_fleet_profile() {
|
||||
'{profile_version: 1, script_version: $script_version, settings: $settings, addons_present: $addons}' \
|
||||
> "$out_path"
|
||||
|
||||
log_success "Fleet profile exported to $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)."
|
||||
@@ -127,7 +128,7 @@ action_export_fleet_profile() {
|
||||
pause
|
||||
}
|
||||
|
||||
action_apply_fleet_profile() {
|
||||
action_apply_clone_settings() {
|
||||
echo
|
||||
local in_path
|
||||
in_path=$(ask_text "Profile file to apply" "")
|
||||
@@ -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"
|
||||
+164
-5
@@ -1,12 +1,171 @@
|
||||
#!/bin/bash
|
||||
################################################################################
|
||||
### Ubuntu Based Kiosk v2.13.0 ###
|
||||
### Ubuntu Based Kiosk v2.17.0 ###
|
||||
################################################################################
|
||||
#
|
||||
# RELEASE v2.13.0 - Fleet Profile: New MVP for Standing Up Several
|
||||
# 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: Fleet Profile
|
||||
# (menus/fleet_profile.sh). Not a port of the legacy Export/Import
|
||||
# - 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,
|
||||
@@ -474,7 +633,7 @@ set -euo pipefail
|
||||
### SECTION 1: CONSTANTS & GLOBALS
|
||||
################################################################################
|
||||
|
||||
SCRIPT_VERSION="2.13.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