205 Commits
Author SHA1 Message Date
Outis 08b33ef823 Merge pull request #113 from outis1one/claude/authelia-login-field-clearing-8ymlmn
Fix on-screen keyboard clearing fields on frameworks like Authelia's …
2026-09-07 20:55:27 -04:00
Claude d6d6177dd9 Fix on-screen keyboard clearing fields on frameworks like Authelia's React UI
The keyboard-type IPC handler set input.value directly and dispatched a
plain "input" event. React (and similar frameworks) patch the value
setter on input/textarea instances to track the last value they set;
setting el.value directly updates that tracker too, so React never
detects a real change and its controlled state stays empty. The next
re-render (moving focus to another field, toggling a checkbox, etc.)
then redraws the input from that stale empty state, wiping out
whatever was typed. Route the writes through the native value setter
instead so the tracker stays out of sync and the dispatched event
actually reaches the framework's handler.

Also give the "Tab" key its own handler that moves focus to the next
focusable element, instead of falling through to the generic branch
that typed the literal word "Tab" into the field.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V1D2cv1KyFoYGnwSdszeXm
2026-09-08 00:54:01 +00:00
Outis ff67e422a4 Merge pull request #112 from outis1one/claude/kiosk-web-management-5rl5zr
Claude/kiosk web management 5rl5zr
2026-08-20 11:41:41 -04:00
Claude e607e8cea0 Web UI: install/reconfigure addons, default-on install, visual redesign (v2.17.0)
Web UI now installs by default during first-time provisioning (fixed
port 8090, no prompt) instead of being opt-in, and can install/
reconfigure CUPS Printing, LMS Server, Squeezelite Player, and Asterisk
Intercom, and check for updates - the addons and Update action asked
for by name.

Privilege model: the web service itself still runs as $KIOSK_USER with
zero ambient sudo. A new narrow, allow-listed root helper
(menus/addon_webui.sh's webui_write_helper_script) is the only way it
ever gains privilege, reachable only via a single-path passwordless
sudo rule generated and validated with `visudo -c -f` before being
installed, and it re-checks its own fixed action allow-list before
dispatching anything. Each allow-listed action is the exact same
interactive action_* function the terminal menu already uses, driven
by piping the right answers on stdin - the same technique this
project's own bash tests already use, so no prompt/mutation refactor
of any addon file was needed. webui/lib/actions.js's stdin sequences
were cross-validated against the real bash functions (not just read),
which caught two real bugs (Squeezelite and Asterisk Intercom both
silently lost their "decline reconfigure" path).

Long-running installs stream live output via Server-Sent Events
(webui/lib/jobs.js), one action at a time.

Full visual redesign: a sidebar shell (Sites/Display/Lockout/Addons/
Update) replacing the single scrolling page, light+dark themes via
prefers-color-scheme, no external font/CDN dependency. Actually driving
the redesigned UI in a headless browser (not just reading the code)
caught a real bug: refreshing an addon's pill/button after a successful
install used to rebuild the whole card, racing (and usually losing to)
the success status/log that job had just written. Fixed to update
pill/buttons in place.

Uninstall-via-web is deliberately still not offered, for any addon.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VfsFSoRqfbRG7XAg5RoE7e
2026-08-19 17:51:07 +00:00
Claude b6ed4aad9c Add Web UI addon: browser-based config editor (v2.16.0)
New Addons -> Web UI: a small Node/Express app (webui/) installed as a
systemd service running as $KIOSK_USER, giving a browser-based editor
for Sites & Page Timing, Display & Interaction, and Password Protection
& Lockout - the three Core Settings menus that are pure config.json
read/write with no privileged system mutation involved. Runs with no
sudo at all, since config.json is already owned by $KIOSK_USER.

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
config.json directly in JS, so this isn't a new pattern), so it can
never silently clobber fields it doesn't track - the same bug
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 -
Authelia integration is explicitly out of scope for this repo.

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, since a network-facing
process shouldn't be handed sudo-level system mutation without a lot
more thought than this pass gives it. 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 this repo's
"Modular Management" notes have mentioned for a while - a central
multi-kiosk fleet dashboard is an intentional follow-up.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VfsFSoRqfbRG7XAg5RoE7e
2026-08-19 16:01:20 +00:00
Outis 1fad3393ac Merge pull request #111 from outis1one/claude/kiosk-web-management-5rl5zr
Claude/kiosk web management 5rl5zr
2026-08-19 11:37:43 -04:00
Claude d0b76dc6cf Drop Full Reinstall from the migration plan (docs only)
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 code changes - nothing was
ever built for it in the modular tool, this just stops carrying it in
docs/comments as a pending gap.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VfsFSoRqfbRG7XAg5RoE7e
2026-08-19 14:38:17 +00:00
Claude d411b05cae Add Upgrade to install.sh (v2.15.0)
The legacy Upgrade re-extracted main.js/preload.js/etc from its own
heredocs on every run - a mechanism the modular tool has no equivalent
of now that kiosk-app/ and provision/files/ are real files in the git
checkout. The new Advanced -> Upgrade is `git pull` (only after a
clean-tree check, 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. Also offers an
on-demand Electron version check via the existing action_update_electron,
since Electron isn't versioned by this repo.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VfsFSoRqfbRG7XAg5RoE7e
2026-08-19 13:50:25 +00:00
Claude 037a62e008 Add real first-time provisioning to install.sh (v2.14.0)
Until now ./install.sh only managed an already-installed kiosk;
ubuntu-based-kiosk.sh was still the only path from a bare Ubuntu Server
box to a running one. install.sh now provisions from scratch too:
packages, kiosk user, Node.js/Electron, LightDM+Openbox autologin,
audio/video/HDMI/power-button hardware setup, and the firewall, then
hands off to the already-migrated Core Settings/Advanced menus for
initial configuration instead of reimplementing that logic again.

- lib/provision.sh: the new provisioning flow, built mostly by calling
  existing menus (core_settings_menu, emergency hotspot, virtual
  consoles) - cuts it to ~300 lines against the legacy script's
  ~4,000-line first_time_install().
- lib/electron.sh: electron_install_binary() extracted out of
  menus/advanced_electron.sh so provisioning and the existing "Fix
  blank screen" action share one implementation.
- kiosk-app/ and provision/files/: the Electron app source and every
  system template file, extracted byte-for-byte out of
  ubuntu-based-kiosk.sh's heredocs into real files.
- Found and fixed a bash set -e gotcha along the way: 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. Fixed in the new provisioning code and in
  menus/advanced_electron.sh's pre-existing repair action, which had
  the same shape.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VfsFSoRqfbRG7XAg5RoE7e
2026-08-19 05:24:32 +00:00
Claude 1c9447bc5d Rename Fleet Profile to Clone Settings per feedback
menus/fleet_profile.sh -> menus/clone_settings.sh. Same functionality
(export/apply portable config.json settings between kiosks); renamed
the file, every function, the menu label, and the on-disk default
profile filename to match. No behavior change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VfsFSoRqfbRG7XAg5RoE7e
2026-08-19 04:29:13 +00:00
Claude 5d56c667da Add Fleet Profile MVP for replicating settings across kiosks; bump to v2.13.0
New menus/fleet_profile.sh (Advanced), for the "set up one kiosk, then
stand up a dozen more like it" use case. Not a port of the legacy
Export/Import Settings - a narrower, deliberately-scoped feature:

- Export: writes config.json's portable fields (sites, display/touch/
  navigation, lockout, password protection) plus a list of addons
  present at export time to a JSON profile file.
- Apply: merges those fields onto a target kiosk's config.json (same
  merge-not-replace pattern as save_config, so the target's own fields
  survive untouched) and prints a checklist of which listed addons are/
  aren't installed on the target.

Deliberately excludes machine-bound credentials rather than silently
mishandling them: Authelia's encrypted password is keyed off
/etc/machine-id and decrypts to garbage elsewhere; a WireGuard private
key is a device identity, reusing one across machines is a peer
conflict; most Asterisk PBXes reject duplicate registrations to the
same extension. Apply prints all three as an explicit "needs a human"
checklist. Non-interactive addon installation (for a fully scriptable
fleet rollout) is a deliberate follow-up, not part of this MVP.

Full command-level stubbed test suite covering export (site/setting
content, Authelia stripped, addon detection) and apply (merge
correctness, target's own Authelia preserved, bad path/invalid JSON
handled cleanly). Full 20-suite regression + real end-to-end menu
navigation via install.sh all pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VfsFSoRqfbRG7XAg5RoE7e
2026-08-19 03:45:36 +00:00
Claude b0b558f015 Migrate Complete Uninstall, composed from each addon's own uninstall helper; bump to v2.12.0
New menus/complete_uninstall.sh (Core Settings), the last of the
"destructive trio". Rather than re-implementing every addon's teardown
a second time (the legacy shape), it composes the *_do_uninstall
helpers each addon already has - if an addon's removal logic changes,
Complete Uninstall picks it up automatically.

Every addon menu with an uninstall action (CUPS, VNC, WireGuard,
Tailscale, Netbird, LMS, Squeezelite, Asterisk Intercom) plus
power_schedule's "remove all schedules" and Emergency Hotspot's disable
action were each split into a confirm-and-call wrapper (unchanged from
the user's perspective) and a silent do-the-removal helper that both
the wrapper and Complete Uninstall call.

Bug fix found while composing these: several *_do_uninstall helpers
(CUPS's apt autoremove/apt clean, VNC/WireGuard/Tailscale/Netbird's apt
remove) had a bare, unguarded apt call as their second-to-last
statement. Previously this only risked aborting that one menu action if
the package was already gone. Composed together as sequential calls
inside Complete Uninstall, the same failure would have silently
truncated the entire uninstall sequence partway through. Guarded all of
them with `|| true`.

Non-addon teardown (kiosk user/files, Node.js, LightDM/Openbox,
remaining systemd units/scripts, polkit rules, re-enabling virtual
consoles, final package cleanup) stays inline in
menus/complete_uninstall.sh, since no single addon owns those paths.

Upgrade and Full Reinstall stay in ubuntu-based-kiosk.sh only - both
are coupled to its own heredoc self-extraction of main.js/preload.js/
etc, which has no modular equivalent yet.

Full command-level stubbed test suite exercising the full 12-step
teardown, confirmation-text validation, and reboot prompt. Full
19-suite regression + real end-to-end menu navigation via install.sh
all pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VfsFSoRqfbRG7XAg5RoE7e
2026-08-19 03:03:50 +00:00
Claude a3313aa9b8 Migrate 4 more Advanced items (Electron, Factory Reset, Virtual Consoles, Emergency Hotspot); bump to v2.11.0
New in install.sh's Advanced menu, alongside Diagnostics:
- menus/advanced_electron.sh: "Electron Maintenance" - the legacy
  "Manual Electron Update" and "Fix Blank Screen" combined into one
  submenu, sharing the binary-repair logic (electron_install_binary).
- menus/advanced_factory_reset.sh: "Factory Reset" - wipes config.json
  back to defaults only; addons are untouched.
- menus/advanced_virtual_consoles.sh: "Virtual Consoles" - toggles
  Ctrl+Alt+F1-F8 terminal login access.
- menus/advanced_emergency_hotspot.sh: "Emergency Hotspot" - auto-starts
  a WiFi hotspot if no internet is detected 60 seconds after boot. Its
  own runtime script and systemd unit now go through $BIN_DIR/
  $SYSTEMD_DIR like every other addon's own files, instead of the
  legacy's hardcoded /usr/local/bin and /etc/systemd/system.

That covers 8 of the legacy Advanced menu's 12 entries. Not migrated
this round: Export/Import Settings (pending a decision on rebuilding it
around actual paths vs. a hardcoded step list, or whether the future
web UI replaces the need for it) and Fix Squeezelite Audio (small
enough it may fold into the LMS addon instead of staying standalone).

Full command-level stubbed test suite per file, including set -e safety
checks (declined/failed paths never crash the session) and content
verification for every written file. Full 18-suite regression + real
end-to-end menu navigation via install.sh all pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VfsFSoRqfbRG7XAg5RoE7e
2026-08-19 02:52:43 +00:00
Claude 3eadcdb584 Migrate Asterisk Intercom, redesigned as SIP-extension-only; bump to v2.10.0
New menus/addon_asterisk_intercom.sh, wired into install.sh's Addons
menu. The legacy addon offered Client Only (Baresip SIP client), Server
Only, and Full (server + client), where Server/Full downloaded and ran
a third-party installer from a separate "Easy Asterisk" repository to
stand up a whole Asterisk PBX. That repository has since gone through a
major rework upstream, so this migration drops the PBX-install path
entirely: the addon now only installs Baresip and registers this kiosk
as a SIP extension against an Asterisk server the user already has
running elsewhere. It never installs or manages Asterisk itself. The
legacy script's own three-option version is untouched, same as every
other migrated menu.

Dropped the legacy client path's dependency on the Easy Asterisk repo's
GitHub API for version tracking - now reads the real installed baresip
package version via dpkg instead. Added an uninstall option, which the
legacy addon never had at all.

Bug fix found while testing: an unguarded `ver=$(baresip_installed_version)`
assignment crashed the whole session under set -e the first time status
was checked before Baresip was installed (dpkg-query legitimately fails
when the package isn't there). Guarded with `|| true`.

Full command-level stubbed test suite covering configure (manual/auto-
answer, TLS port bump, apt-install failure) and uninstall (keep/purge
config) for both fresh and already-configured states. Full 13-suite
regression + real end-to-end menu navigation via install.sh all pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VfsFSoRqfbRG7XAg5RoE7e
2026-08-19 02:28:05 +00:00
Claude 2bf4efe2b1 Migrate LMS/Squeezelite addon; fix is_service_enabled dead pre-check; bump to v2.9.0
New menus/addon_lms_squeezelite.sh: install/reconfigure/uninstall for an
LMS (Lyrion/Logitech Media Server) server and a Squeezelite player,
wired into install.sh's Addons menu. Squeezelite's own start script and
systemd unit now go through $BIN_DIR/$SYSTEMD_DIR like every other
addon instead of hardcoded /usr/local/bin and /etc/systemd/system; LMS's
own apt repo/GPG key/ufw rules stay at their real fixed system paths,
same approach as CUPS.

Fixed a real unguarded-pipeline bug from the legacy install_lms():
`sudo systemctl enable "$service_name" 2>&1 | tee ...` made the exit
status depend on tee (always 0) instead of systemctl enable, silently
swallowing real enable/start failures. Now uses enable_and_start_units().

Fixed is_service_enabled() (shared helper, backported into the legacy
script too): its list-unit-files pre-check never matched a bare service
name, so it always fell through to "not enabled" regardless of the real
state. Dropped the dead pre-check.

Full command-level stubbed test suite covering install/reconfigure/
uninstall for both LMS and Squeezelite, including the repo-vs-fallback-
download path, undetectable-service-name path, and enable/start-failure
path. Full 12-suite regression + real end-to-end menu navigation via
install.sh all pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VfsFSoRqfbRG7XAg5RoE7e
2026-08-19 02:11:05 +00:00
Claude 0454a259fa Migrate Remote Access addon; fix status-function crash gap in run_menu; bump to v2.8.0
Third and biggest Addon migrated: menus/addon_remote_access.sh - VNC
(x11vnc), WireGuard, Tailscale, and Netbird, each with its own install/
connect/status/uninstall flow. Same risk class as CUPS (real apt
packages, real system state) but broader in scope: Tailscale and
Netbird install via the vendors' own documented `curl -fsSL <url> | sh`
method, preserved exactly as-is rather than redesigned.

- lib/config.sh: new $WIREGUARD_DIR, same pattern as $SYSTEMD_DIR/
  $BIN_DIR/etc - nothing in this file hardcodes /etc/wireguard.
- lib/menu.sh: promoted power_schedule.sh's enable_and_start_timers()
  to a shared enable_and_start_units() (works for services now too, not
  just timers) - Remote Access needed the identical enable+start-with-
  graceful-failure-reporting pattern for x11vnc and wg-quick@, so this
  is fixed once and reused rather than duplicated a second time.
  power_schedule.sh's four call sites renamed to match.

Found and fixed a real framework-level bug while building this file:
run_menu()'s *handler* call has been `|| true`-guarded since v2.1.0,
but the *status function* call (`"$status_func"` on its own line) was
still completely bare. A status function's entire job is read-only
display, but if it contains so much as a pipeline whose grep matches
nothing - which pipefail turns into a pipeline failure even though the
actual last command in it (e.g. sed) succeeds - that bare call would
crash the *entire session*, not just fail to show status text. Found
while writing wireguard_status()'s `sudo wg show | grep ... | sed ...`
and deliberately verifying its exact failure mode rather than assuming
run_menu already covered it. Fixed once in run_menu() itself
(lib/menu.sh), protecting every status function across every menu -
present and future - the same "fix once at the framework level"
pattern as the v2.1.0 handler fix.

Given the framework fix meant this class of bug had been silently
possible since v2.1.0, audited every existing status function across
every already-migrated menu for the same specific shape (a bare
`var=$(...)` assignment from a grep-based pipeline, not embedded in an
echo and not already guarded - embedded substitutions and if-condition
contexts are both already safe on their own). Found and fixed one real
instance in power_schedule_status(). menus/addon_remote_access.sh's own
two equivalent pipelines (wireguard_status, netbird_status) were
written with `|| true` from the start once the pattern was identified.

Verified:
- New scratch/stub test for addon_remote_access.sh, with curl stubbed
  separately from sudo (Tailscale/Netbird's install scripts must never
  reach the real network regardless of what sudo intercepts) and a
  belt-and-suspenders `sh` stub in case anything got past curl: full
  status/menu-builder coverage for all four sub-areas in their real,
  unstubbed "not installed" state (none of the four tools exist in this
  sandbox); VNC install/change-password/uninstall with systemd unit
  content verified (correct $KIOSK_USER/$KIOSK_HOME substitution);
  WireGuard install, paste-config (content written correctly to scratch
  $WIREGUARD_DIR), and uninstall - including documenting a genuine
  cat-until-EOF test-harness limitation (a redirected pipe's EOF is
  permanent for the whole stream, unlike a real terminal's per-read
  Ctrl+D, so only the config's *default* name is testable through
  simple stdin redirection - inherent to the design, matches the legacy
  script's identical `cat`-based approach, not a bug); Tailscale and
  Netbird install/connect-interactive/connect-with-key/uninstall; and
  all four cancel paths confirmed to make zero sudo calls.
- Full regression: re-ran all 11 prior scratch/stub suites after the
  lib/menu.sh and power_schedule.sh changes - all still clean.
- End-to-end: ran the real install.sh as a genuine non-root, non-
  "kiosk" user through Addons -> Remote Access -> all four sub-menus in
  turn, each showing accurate real (unstubbed) "not installed" status,
  selecting Install, declining the confirmation, and returning cleanly
  - zero invalid-choice errors, clean exit code 0.
2026-08-19 01:35:15 +00:00
Claude cdd6f5adcf Backport save_config merge fix into legacy ubuntu-based-kiosk.sh; bump to v2.7.0
Per user decision: backport just the config-clobbering fix from v2.6.0
(lib/config.sh) into the legacy single-file installer's own
save_config(), independent of migrating the rest of that menu into
./install.sh.

The bug: save_config() rebuilt config.json from a fixed list of known
fields via `jq -n`, silently deleting anything it didn't know about -
specifically autheliaURL/autheliaUsername/autheliaEncryptedPassword,
written by configure_authelia()'s own careful `. + {...}` merge.
Configuring Authelia and then visiting Sites, Touch Controls,
Navigation, or Password Protection (all of which call save_config)
silently deleted the Authelia credentials. Real, currently-shipping
credential-loss bug, unrelated to whether the rest of that menu is ever
migrated - didn't need to wait for a full pass.

Fixed the same way as lib/config.sh: merge the known fields onto
whatever's already in config.json (`. + {...}`) instead of rebuilding
from nothing, with a `jq empty` validity check falling back to `{}` if
the existing file is missing or corrupt. This is a standalone fix to
one function only - nothing else about Sites/Touch/Navigation/Authelia
changed, and none of that is migrated by this commit.

Verified before touching the shipping copy: extracted the exact
save_config() function (now lines 3682-3805) into an isolated test
harness with stubbed dependencies (kiosk_user_exists, is_service_active,
log_success/warning), seeded a stub config.json with Authelia-style
fields via the same `. + {...}` merge configure_authelia() uses, called
save_config() a second time simulating a visit to an unrelated menu,
and confirmed the Authelia fields survive while an actual settings
change (duration 60 -> 90) still correctly takes effect. Also verified
the corrupt-JSON and missing-file edge cases don't crash the function.
Full syntax check on the whole 12,000+ line script, and the entire
modular test suite (11 scratch/stub suites), both still clean.
2026-08-19 01:21:43 +00:00
Claude 6c68897935 Migrate Authelia addon; fix real config-clobbering bug in save_config; bump to v2.6.0
Second Addon migrated: menus/addon_authelia.sh (encrypted SSO
credentials, AES-256-CBC with a key derived from /etc/machine-id via
scrypt - same algorithm main.js decrypts with - plus the full
Dockerized server-side setup instructions, now viewable again later
without reconfiguring).

Investigating how to wire its three config.json fields (autheliaURL/
autheliaUsername/autheliaEncryptedPassword) into lib/config.sh surfaced
a real, currently-shipping bug that has nothing to do with Authelia
specifically: save_config() did a full `jq -n` rebuild of config.json
from a fixed list of known fields - identical to what the legacy
script's own save_config still does. The legacy configure_authelia()
writes its three fields via a careful `. + {...}` merge that preserves
everything else already in the file, but neither save_config knew those
fields existed - so the next time a user visited Sites, Touch Controls,
Navigation, or Password Protection (all of which call save_config),
their Authelia credentials were silently deleted. This bug already
existed in the shipped single-file installer; it was ported faithfully
into lib/config.sh's first version because no test happened to set an
untracked field before calling save_config.

Fixed in lib/config.sh: save_config now merges its known fields onto
whatever's already in config.json (jq `. + {...}`) instead of rebuilding
the file from nothing, with a `jq empty` validity check falling back to
`{}` if the existing file is missing or corrupt. Any field this tool
doesn't track - Authelia's three today, anything else a future addon
adds tomorrow - now survives automatically. autheliaURL/
autheliaUsername/autheliaEncryptedPassword are also tracked fields in
their own right now (load_existing_config/save_config), consistent with
every other config.json field this tool manages, giving Authelia both a
direct fix and the general safety net.

The equivalent bug still exists, unfixed, in ubuntu-based-kiosk.sh's own
save_config - noted in both that script's changelog and the Readme's
"Modular Management" section as an open question: whether to backport
just that one fix into the legacy script now, independent of the wider
migration, given it's a real credential-loss bug affecting the
currently-shipping installer today.

Verified:
- New dedicated test (test_save_merge.sh) proving the save_config fix
  itself: seeded config.json with a simulated untracked field via the
  same `. + {...}` merge Authelia's own code uses, called save_config
  from an unrelated context (Sites deleting a tab), and confirmed the
  untracked field survived while the tab deletion still correctly took
  effect (not undone by the merge) - plus corrupt-JSON and
  missing-file edge cases both handled without crashing.
- New scratch-config test for addon_authelia.sh using REAL encryption
  (this sandbox has both Node and /etc/machine-id): configured with a
  real password, then decrypted the stored ciphertext using main.js's
  exact algorithm (independently reproduced in the test) and confirmed
  it recovers the original password exactly - true interoperability,
  not just "some ciphertext was produced." Also covered cancel paths,
  clearing the configuration, the encryption-unavailable failure path,
  and confirmed Authelia's config survives an unrelated Sites save.
- Full regression: re-ran all 9 prior scratch/stub test suites after
  both the lib/config.sh changes - all still clean.
- End-to-end: ran the real install.sh as a genuine non-root, non-
  "kiosk" user with a seeded minimal config.json, through Addons ->
  Authelia -> Configure with a real URL/username/password -> confirmed
  the resulting config.json on disk, and independently decrypted the
  stored password for real using main.js's algorithm to confirm it
  matches exactly. Clean exit code 0 throughout.
2026-08-18 21:56:21 +00:00
Claude 1b16bcf3ee Migrate CUPS Printing addon; restructure install.sh into Core Settings/Addons/Advanced; bump to v2.5.0
First Addon migrated: menus/addon_cups.sh (install, reconfigure for
network access, complete uninstall/purge). Different risk profile from
everything migrated so far - it genuinely mutates real system state
(apt install/remove --purge, /etc/cups, ufw) at fixed paths CUPS itself
doesn't let us relocate, unlike the systemd/cron/bin paths this project
already controls via $SYSTEMD_DIR etc. Only the polkit rule's directory
is parameterized ($POLKIT_DIR, lib/config.sh, since that one is ours to
place); everything else gets full command-level `sudo` stubbing in
every test - there is no scratch equivalent for a real apt-managed
subsystem's own file layout. Also added $BUILD_USER (the admin account
actually running the tool, as opposed to $KIOSK_USER) since CUPS needs
to grant it lpadmin group membership.

Restructured install.sh's top-level menu into Core Settings / Addons /
Advanced (matching the legacy tool) instead of one flat list, now that
Addons exists as its own category - cheap to do with one item in it,
much more annoying to retrofit once the flat list has fifteen.

Two bugs caught and fixed before they shipped:

- A "wait for CUPS to start" retry loop used a bare `cmd1 && cmd2 &&
  break` as its body while "simplifying" the legacy script's `if cmd1
  && cmd2; then break; fi`. Being inside a loop doesn't protect a bare
  &&/|| list from set -e - only if/while/until conditions and the
  protected side of &&/|| do that - so the first command failing on an
  early iteration (near-certain right after a fresh install, before
  CUPS has actually started) would have crashed the entire session.
  Restored the `if` form; noted the lesson in the file's own header
  comment since it's a general trap, not CUPS-specific.

- Resolved real uncertainty, rather than assuming: how far does
  run_menu's `handler || true` guard (v2.1.0) actually protect? Wrote a
  minimal isolated test (a bare `false` three function calls deep,
  called via `outer || true` at the top) and confirmed bash's errexit
  exemption for the left side of `||` covers the *entire* evaluation,
  arbitrarily deep through function calls - not just the immediately
  invoked function. So the session-crash risk this project has been
  chasing since v2.1.0 is already covered end-to-end by that one fix.
  Per-statement guards (`|| true`, explicit `if`) still earn their keep
  for a different reason: without them a deep failure bubbles silently
  past the menu actually responsible for it to wherever the nearest
  `|| true` happens to sit, which can be several menu levels above
  where the user actually was - not a crash, but a confusing jump.

Verified:
- Full regression: re-ran every existing scratch-config/stub test suite
  after the lib/config.sh change (new $BUILD_USER/$POLKIT_DIR) and
  after the install.sh restructuring - all still clean.
- New scratch/stub test for addon_cups.sh: full state-machine coverage
  (not installed -> decline -> install -> running -> reconfigure ->
  stopped -> start -> uninstall decline -> uninstall confirm -> not
  installed again) with every `sudo` call intercepted and only `rm`
  targeting the scratch $POLKIT_DIR ever actually executed; confirmed
  the polkit rule's content and that declining install makes zero sudo
  calls. Added both apt-failure paths (update fails, install fails)
  and confirmed the tool reports clearly and returns to the menu
  instead of dying, exercising the exact bug class just fixed.
- End-to-end: ran the real install.sh as a genuine non-root, non-
  "kiosk" user through the full new three-level structure - Core
  Settings -> Sites -> back -> back, Addons -> CUPS -> declined install
  (using this container's real, unstubbed dpkg check, correctly
  reporting "not installed" and making no apt/systemctl calls) -> back
  -> back, Advanced -> Diagnostics -> System status -> back -> back ->
  Exit. Zero invalid-choice errors, clean exit code 0 throughout.
2026-08-18 19:10:43 +00:00
Claude 459da53182 Migrate Diagnostics menu; bump to v2.4.0
Deliberately skipped Upgrade/Full Reinstall/Complete Uninstall for now:
all three are large (130-250 lines), genuinely destructive (wipe/
reinstall the kiosk), and Upgrade specifically is coupled to the legacy
script's own self-extraction mechanism (it greps its own running source
for embedded heredocs to pull out main.js/preload.js) - there's no
modular equivalent to migrate it to yet, since those files don't exist
as separate assets outside the monolith. Migrated Diagnostics instead:
4 of the legacy Advanced menu's 12 items (System Status, View Logs,
Audio Diagnostics, Network Test), all read-only except one optional
"play a test sound?" prompt - a deliberate change of pace with no
destructive-action risk to design around, after Sites/WiFi/Power.

- lib/menu.sh: ported get_vpn_ips alongside get_ip_address.
- menus/diagnostics.sh: straight port, using $KIOSK_USER/$KIOSK_HOME
  throughout instead of the legacy code's mix of the variable and a
  hardcoded "kiosk" literal.

Bug fixed, same set -e-safety class as v2.1.0's run_menu fix and
v2.3.0's netplan/systemctl fixes, but a bigger batch this time: nearly
every diagnostic command here was a bare unguarded statement whose
*expected, common* failure - no lightdm running, no audio hardware, no
network, missing log files, ping/nslookup not even installed - would
have crashed the entire session instead of reporting "not found" and
continuing. A diagnostics tool has to be the most crash-proof code in
the project, since it exists to run when something is already broken.
Fixed at every call site: systemctl status | head, tail on lightdm's
log, journalctl, ping, nslookup, and three pactl-backed variable
assignments.

Also noted for future menus in this migration: writing `local var;` and
`var=$(cmd)` as separate statements (good practice, and how several
earlier real bugs were caught) removes an accidental safety net -
`local x=$(cmd)` on one line masks the substitution's exit code with
`local`'s own always-success status. Splitting them is correct, but
each split assignment needs an explicit `|| true` (or real fallback)
where failure is expected and non-fatal, rather than relying on that
masking by accident. Caught three instances of exactly this while
writing this file fresh, not just porting old bugs.

Verified:
- Full regression: re-ran every existing scratch-config/stub test suite
  (sites, display, timezone/pin, lockout, power schedule + RTC, wifi) -
  all still clean after the lib/menu.sh change.
- New test for diagnostics.sh, exercised mostly for real (no
  destructive-mutation risk here, so minimal stubbing needed): system
  status, all three log views (including the "no such file" paths for
  lightdm log and electron log), full 8-step audio diagnostic with test
  sound declined, and network test - all report gracefully instead of
  crashing, confirmed by re-running after each fix until every bare
  unguarded statement was accounted for.
- End-to-end: ran the real install.sh as a genuine non-root, non-
  "kiosk" user, navigating Diagnostics -> System status -> View Logs ->
  System journal -> Audio diagnostics (declined test sound) -> Network
  test -> exit. Confirmed every diagnostic path completes and returns
  to its menu cleanly (exit code 0) even with ping/nslookup missing and
  no audio hardware/network present in this environment.
2026-08-18 18:48:26 +00:00
Claude 2375bf5eab Migrate WiFi and Power/Display/Quiet Hours menus; bump to v2.3.0
By far the riskiest menus migrated so far. Both can affect real system
state outside config.json in ways that are hard to reverse: WiFi
rewrites live netplan config and, over SSH, can disconnect the very
session configuring it; power scheduling can shut the physical machine
down and wake it via RTC.

- lib/config.sh: new $SYSTEMD_DIR/$CRON_D_DIR/$BIN_DIR/$NETPLAN_DIR,
  same `: "${VAR:=default}"` pattern as $KIOSK_DIR. Nothing under
  menus/ hardcodes /etc/systemd/system, /etc/cron.d, /usr/local/bin, or
  /etc/netplan directly, so every test in this change points them at
  scratch space instead of ever touching this sandbox's real systemd
  units, cron, or network config.
- lib/menu.sh: ported get_ip_address (also fixing its "No IP" fallback,
  which never actually fired before - `hostname -I | awk` always exits
  0 even on empty output).
- menus/wifi.sh: apply_wifi_config split out from wifi_menu specifically
  so tests can drive the netplan-writing logic without needing real
  scan hardware. Preserves the legacy netplan backup, 60s SSH watchdog,
  and restore-on-failure behavior exactly.
- menus/power_schedule.sh: power schedule (+ RTC wake), display
  schedule, quiet hours, and an Electron reload timer (with its own
  nested run_menu, mirroring the legacy configured/not-configured
  dispatch), plus remove-all. Deliberately excludes the legacy
  dispatcher's "Test schedules & system" - a shared diagnostics submenu
  (audio/network/keyboard tests) that isn't specific to scheduling and
  belongs with a future Advanced/Diagnostics migration instead.

Bugs found and fixed along the way, none papered over:
- The legacy dispatcher refused to open "Configure power schedule" at
  all without RTC hardware, even though shutdown-only mode never needed
  RTC. Now always available.
- None of the six HH:MM prompts across these menus (shutdown, wake,
  display off/on, quiet start/end, custom Electron reload time) were
  validated before - plain `read`, no format check. All now go through
  ask_time.
- set -e safety (same class as the v2.1.0 run_menu fix), three more
  instances: `ls *.yaml` when no netplan file exists still fails under
  pipefail even with stderr silenced (masked in practice by cloud-init
  usually leaving a file behind); the restore-and-reapply `netplan
  apply` after an initial failure was a bare unguarded statement; and
  `systemctl enable`/`start` after writing each of the four timer pairs
  was unguarded too - caught only by testing in an environment without
  a live systemd, but a real enable/start failure on actual hardware
  (bad unit, daemon-reload skipped, ...) would hit the exact same crash.
  Added a shared enable_and_start_timers() helper used at all four call
  sites; all now report a clear warning and return to the menu instead
  of taking the session down.

Testing discipline for this round, given the risk:
- No automated test calls the real netplan/nmcli/iw/wpa_cli/systemctl -
  confirmed no WiFi tools or `wl*` interface exist in this sandbox, so
  wifi_menu's own tools-check safely short-circuits before touching
  anything; apply_wifi_config's actual YAML/backup/failure-recovery
  logic is tested with sudo/netplan/get_ip_address stubbed instead.
- One stubbing pitfall caught and fixed in the test itself: `nohup sudo
  bash "$watchdog" ... &` execs nohup as a real external binary, which
  then execs the real sudo - a bash function stub named `sudo` does NOT
  intercept that, only stubbing `nohup` itself does. Verified via pgrep
  that no real watchdog process or `sleep 60` was ever spawned.
- power_schedule.sh tested with SYSTEMD_DIR/CRON_D_DIR/BIN_DIR pointed
  at scratch dirs and only `sudo systemctl` stubbed (tee/rm/chmod/cp
  left real, since they only ever touch scratch paths): full lifecycle
  for all four schedule types plus remove-all, the RTC-available branch
  (including the wake-time-before-shutdown-time hour/day wraparound
  arithmetic) via a stubbed rtc_wake_available, and the new
  enable_and_start_timers failure path via a stub that fails `enable`
  specifically.
- End-to-end: ran the real install.sh as a genuine non-root, non-
  "kiosk" user for both menus. WiFi correctly short-circuits on missing
  tools without crashing. Power/Display/Quiet Hours (SYSTEMD_DIR/
  CRON_D_DIR/BIN_DIR redirected to scratch space) configured all four
  schedule types in sequence including the nested Electron Reload menu,
  survived four consecutive real "systemctl enable/start failed"
  warnings (this container has no live systemd) without the session
  dying, then removed everything - confirmed the scratch dirs ended up
  empty and config.json was never touched (correctly out of scope for
  this menu).
2026-08-18 18:28:49 +00:00
Claude 8672c15469 Migrate Password Protection & Lockout menu; add missing ask_time helper; bump to v2.2.0
Fifth menu migrated onto lib/menu.sh + lib/config.sh: menus/lockout.sh
covers enable/disable, changing the password, inactivity timeout, daily
lock time, and boot password. The password is SHA-256 hashed before
it's ever assigned to LOCKOUT_PASSWORD (matching main.js's comparison
logic) - verified by test that the stored value is the correct hash and
never plaintext.

Rewrote the legacy configure_password_protection's linear "ask
everything, confirm save at the end" wizard as the same immediate-save
pattern used by every other migrated menu: each action (change
password, change timeout, toggle boot password, ...) is a complete,
standalone change, consistent with Sites/Display/Timezone/Hidden PIN.
LOCKOUT_ACTIVE_START/END are deliberately left untouched - per the
Readme they're inert leftover fields the app ignores, so lib/config.sh
just carries whatever is already in config.json through unchanged.

Testing this menu surfaced a real gap before it ever shipped: lib/menu.sh
never had ask_time/validate_time at all (only validate_integer/ask_integer,
ask_url, etc were ported when the framework was first built) - "set a
daily lock time" would have failed for every single user with
"ask_time: command not found". Ported both from the legacy script.

Also promoted the ON/OFF toggle-label helper (previously private to
menus/display.sh as display_onoff) to a shared onoff() in lib/menu.sh,
since menus/lockout.sh needed the same thing and menu files should only
ever depend on lib/, never on each other.

Bumped SCRIPT_VERSION to 2.2.0 with matching changelog entries in the
script header and Readme, and updated "Modular Management" to list the
new menu and drop Password Protection & Lockout from the "not yet
migrated" list.

Verified:
- Full regression: re-ran the Sites, Display, Timezone/PIN scratch-config
  suites after every change in this round (the onoff refactor, and again
  after adding ask_time) - all still clean.
- New scratch-config test for lockout.sh: enable (password+timeout+daily
  lock+boot toggle), independently recomputed the expected SHA-256 hash
  and confirmed it matches config.json exactly, change password, change
  timeout, clear daily lock, toggle boot password, disable (confirmed
  every field clears), and that the menu builder's options correctly
  differ between the enabled and disabled states.
- End-to-end: ran the real install.sh as a genuine non-root, non-"kiosk"
  user - Lockout menu -> enable protection with a real password entered
  via the masked prompt -> set 20m timeout, 23:00 daily lock, boot
  password on -> confirmed the menu redraws with the new state -> clean
  exit (code 0). Checked the resulting config.json and file permissions
  on disk.
2026-08-18 16:48:30 +00:00
Claude 074b2ec2e3 Migrate Timezone and Hidden Site PIN menus; harden menu framework against set -e; bump to v2.1.0
Two more menus migrated onto lib/menu.sh + lib/config.sh, chosen
specifically because neither touches config.json - a third and fourth
shape for the framework (a system command via timedatectl, and a flat
PIN file), on top of Sites' list CRUD and Display's JSON toggles.

- menus/timezone.sh: also replaces the legacy script's hand-numbered
  18-entry case statement with a plain data list (TIMEZONE_COMMON_ZONES)
  plus one handler that reads the number run_menu hands it - adding or
  removing a zone never touches numbering anywhere else. Required a
  small run_menu addition: handlers now receive the chosen 1-based
  number as $1, so one handler can serve a whole data-driven list
  instead of needing a wrapper function per entry.
- menus/hidden_pin.sh: set/disable/reset the PIN gating hidden pages.

Testing menus/timezone.sh surfaced a real bug before it ever shipped:
this whole tool runs under `set -e`, and set_timezone() rejecting an
invalid zone via a bare `return 1` as its last statement took down the
*entire* install.sh session, not just that one action - a single typo
would silently drop the user back to their shell. Fixed at the
framework level in lib/menu.sh (run_menu now absorbs a failed handler's
exit code) rather than patching set_timezone alone, since any future
menu could hit the same trap. Verified against the real install.sh as a
genuine non-root user: an invalid timezone now logs an error and
redraws the Timezone menu instead of killing the session (confirmed
exit code 0 at the end of the run). Note this specific hazard was
introduced by this session's own return-1 idiom, not inherited from the
legacy script, which never uses a bare return 1 in these functions.

Also per the user: left the old configure_sites/configure_touch_controls/
configure_navigation_security/configure_optional_features functions in
ubuntu-based-kiosk.sh untouched for now (still carrying the v2.0.0
settings-clobber and reorder bugs) rather than removing them - they'll
be retired in one pass once enough of Core Settings/Addons/Advanced is
migrated. Bumped SCRIPT_VERSION to 2.1.0 with matching changelog entries
in the script header and Readme, and updated the Readme's "Modular
Management" section to state plainly what is and isn't migrated yet.

Verified:
- Full regression: re-ran the Sites and Display scratch-config test
  suites against the updated run_menu signature - both still clean.
- New scratch-config tests for hidden_pin.sh (set/mismatch/reject/
  disable/reset, correct file permissions) and timezone.sh (builder
  entry count, common-zone pick by index, manual entry with legacy
  US/* alias normalization, region search + cancel, invalid-zone
  rejection) - all correct, with timedatectl/sudo stubbed only where
  needed to avoid mutating this sandbox's real system clock/timezone.
- End-to-end: ran the real install.sh as a genuine non-root, non-"kiosk"
  user, navigating Timezone -> manual entry -> invalid zone -> confirmed
  no crash and a normal return to the menu, then Hidden Site PIN -> set
  a PIN -> confirmed the file on disk (mode 600, correct content) ->
  clean exit (code 0).
2026-08-18 16:33:12 +00:00
Claude c1370edc3e Migrate Display & Interaction menu; drop version number from installer filename; bump to v2.0.0
- menus/display.sh: second menu migrated onto lib/menu.sh + lib/config.sh,
  covering touch gesture mode, link navigation security, and the
  pause/keyboard/navigation button toggles (previously three separate
  Core Settings entries). Deliberately a different shape from Sites
  (toggle list vs. list CRUD) to exercise the framework more broadly.
  Wired into install.sh's top-level menu alongside Sites.

- Renamed ubuntu-based-kiosk-v1.0.3.sh -> ubuntu-based-kiosk.sh so the
  installer can be updated in place instead of growing a new
  version-numbered filename every release; released versions are now
  tracked via git history and the in-script changelog. Updated all
  Readme download/re-run commands accordingly. Older versioned files
  (ubuntu-based-kiosk-v*.sh, install_kiosk_*.sh) are left in place as
  archived releases.

- Bumped SCRIPT_VERSION to 2.0.0 (new script-level changelog entry) and
  the Readme version/changelog to match, given the new modular
  management path, the rename, and the two real bugs fixed along the
  way (settings clobbered on save, off-by-one in reorder).

Verified before moving on to the web admin work:
- Regression: re-ran the full Sites scratch-config test suite (add,
  edit, delete, reorder, home) - still clean, no invalid-input paths hit.
- New: scratch-config test for every display.sh action (touch mode,
  navigation security, all three toggles), confirming values persist
  through save/reload and that a previously-added site survives
  untouched across Display-menu saves.
- End-to-end: ran the real install.sh (not just sourced functions) as a
  genuine non-root, non-"kiosk" user with real sudo, driving actual menu
  input through Sites -> add a page -> Display -> toggle a setting ->
  exit. Confirmed final config.json on disk matches every action taken,
  and both guard clauses (run as root; no installed kiosk found) fire
  correctly.
2026-08-18 15:48:21 +00:00
Claude 21a0768c8c Add modular menu framework, migrate Sites & Page Timing onto it
Start of pulling the menu system out of the 12k-line single-file
installer so individual menus can change without risking the rest of
the script (network, VNC, addons, etc). This is groundwork for the
planned web-based management UI, which will share the same lib/config.sh
read/write layer instead of duplicating it.

- lib/menu.sh: generic numbered-menu framework (auto-numbered entries,
  "0" always exits/returns) plus the validated input helpers menus need.
- lib/config.sh: single load/save for config.json. Fixes a latent bug
  where the old Sites menu wrote config.json without first loading
  swipe/navigation/lockout settings, silently resetting them to
  defaults on save.
- menus/sites.sh: Sites & Page Timing fully migrated - add/edit/delete/
  reorder pages, set duration (auto-rotate/manual/hidden) and home page.
  Also fixes an off-by-one in the ported reorder logic (moving an item
  landed one slot short of the requested position) caught by testing.
- install.sh: new entry point for managing an already-installed kiosk
  via `git clone` + `./install.sh`, wired to the Sites menu. Does not
  yet replace first-time provisioning, which still uses the existing
  single-file installer.

All new site CRUD/reorder/home-page paths were exercised against a
scratch config.json (add with/without basic auth, edit duration, set
home + timeout, 2- and 3-item reorders in both directions, delete) to
confirm the resulting config.json matches expectations.
2026-08-18 15:26:16 +00:00
Outis ccf73d41bd Merge pull request #110 from outis1one/claude/offline-install-readme-kfpr5u
Claude/offline install readme kfpr5u
2026-08-02 19:46:41 -04:00
Claude 10c4c22f44 Route audio to HDMI automatically when an external display is connected
Previously nothing switched PipeWire's default sink, so audio always stayed on the laptop/built-in speakers regardless of HDMI mirroring. Adds /usr/local/bin/kiosk-audio-route.sh, which finds a sink whose name contains 'hdmi' and makes it the default (moving any already-playing streams onto it) whenever an external display is connected, falling back to the non-HDMI sink when it isn't. Called from autostart once PipeWire is confirmed ready, and from kiosk-hotplug.sh alongside the existing display mirroring on every plug/unplug event. Documents the behavior and manual fallback in the README.
2026-08-02 22:54:23 +00:00
Claude 6f6d34512e Mirror external display at the kiosk's exact resolution, not its own native mode
Previously mirroring used 'xrandr --auto', which picks the external output's own native resolution (e.g. a TV's 1920x1080) rather than matching the kiosk/laptop panel's resolution. Extracts the shared mirroring logic (previously duplicated between autostart and the hotplug handler) into /usr/local/bin/kiosk-mirror-display.sh, which now checks whether the external output natively lists the primary's resolution and uses it directly, or generates a matching mode with cvt and forces it via --newmode/--addmode when it doesn't. Documents the behavior and a manual fallback for displays that reject non-native CVT timings in the README.
2026-08-02 22:51:08 +00:00
Claude f01781de41 Add live HDMI/display hotplug handling via udev + systemd
Adds a udev rule (DRM 'change' events) that triggers a new kiosk-hotplug.service, which re-runs the same xrandr mirroring logic as the Openbox autostart script. External displays plugged in after boot now get mirrored without requiring a login or lightdm restart. Named kiosk-hotplug (not kiosk-display-*) to avoid collision with the existing kiosk-display-* wildcard cleanup in remove_all_schedules(). Also removes the udev rule during Complete Uninstall and documents the behavior in the README.
2026-08-02 22:37:39 +00:00
Claude 68a7345f5b Mirror external HDMI/monitor output onto primary display automatically
Openbox autostart now detects any connected output beyond the primary and mirrors the kiosk content onto it via xrandr, instead of leaving detected-but-unused external displays dark. Also documents the behavior, its limitation (applies at session start, not live hotplug), and adds an HDMI troubleshooting section to the README.
2026-08-02 22:34:19 +00:00
Claude b5d37fa646 Install net-tools and ncdu; curl/git already covered
apt install already includes curl and git; adds net-tools and ncdu to the same idempotent apt install so they're present on fresh installs without duplicating already-installed packages.
2026-08-02 22:15:15 +00:00
Outis de9c97aa06 Merge pull request #109 from outis1one/claude/offline-install-readme-kfpr5u
Add offline/air-gapped download instructions to README
2026-08-02 18:11:50 -04:00
Claude ca00674e4c Add offline/air-gapped download instructions to README
Clarifies that the installer script can be downloaded on another machine and transferred via USB, while noting the kiosk machine still needs internet access during install for apt/npm packages.
2026-08-02 22:06:48 +00:00
Outis 0c809d7e53 Merge pull request #108 from outis1one/claude/brave-tesla-hwf1m8
Claude/brave tesla hwf1m8
2026-06-16 14:17:35 -04:00
Claude 29b5783052 Fix LightDM autologin on fresh server installs (nopasswdlogin group + missing config)
Two bugs caused the login screen to appear on new Ubuntu 24.04 server hardware:

1. Ubuntu 24.04's PAM config checks 'user ingroup nopasswdlogin', not 'autologin'.
   Add kiosk user to nopasswdlogin group (and autologin for older versions).

2. The upgrade path never wrote /etc/lightdm/lightdm.conf.d/10-kiosk.conf,
   so on new hardware running through upgrade the file simply didn't exist.

Refactor: extract configure_lightdm_autologin() shared helper called from
both fresh install (step 19/27) and upgrade, so both paths are consistent.
Also use [Seat:*] instead of [SeatDefaults] for forward compatibility.

https://claude.ai/code/session_01VQ13Fwq4MXxwThLfCXBeGr
2026-06-16 18:15:05 +00:00
Claude 91d905f45a Fix LightDM autologin on Ubuntu 22.04+ (new hardware)
[SeatDefaults] is silently ignored by LightDM on newer Ubuntu versions.
Replace with [Seat:*] which is the correct section name for Ubuntu 22.04+.

Also add the kiosk user to the autologin group, which newer Ubuntu
requires for passwordless autologin to work.

Without these fixes, LightDM shows the login screen instead of
auto-logging in and launching the kiosk app.

https://claude.ai/code/session_01VQ13Fwq4MXxwThLfCXBeGr
2026-06-16 18:04:36 +00:00
Outis de197f77d3 Merge pull request #107 from outis1one/claude/brave-tesla-hwf1m8
Fix all file existence checks to run as kiosk user
2026-06-16 13:40:01 -04:00
Claude 879e2ecd14 Fix all file existence checks to run as kiosk user
/home/kiosk is mode 700 so root cannot traverse it. Every [[ -f ]] or
[[ ! -f ]] check on paths inside /home/kiosk was silently returning
'not found' even after the kiosk user had successfully written the file.

Replace all three [[ ! -f "$electron_bin" ]] checks and the
[[ -f "$sandbox" ]] check in install_electron_binary with
sudo -u "$KIOSK_USER" test -f so they run in the kiosk user's
security context and can actually see the files.

https://claude.ai/code/session_01VQ13Fwq4MXxwThLfCXBeGr
2026-06-16 17:32:32 +00:00
Outis 8116fdf258 Merge pull request #106 from outis1one/claude/brave-tesla-hwf1m8
Restore chmod 644 on tmp zip so kiosk user can read it for unzip
2026-06-16 13:26:25 -04:00
Claude 56e3d17459 Restore chmod 644 on tmp zip so kiosk user can read it for unzip
Accidentally dropped this line when rewriting the extraction block.
mktemp creates the file as root:root 600, so sudo -u kiosk unzip
gets 'Permission denied' trying to open the zipfile.

https://claude.ai/code/session_01VQ13Fwq4MXxwThLfCXBeGr
2026-06-16 17:25:36 +00:00
Outis 35350d9908 Merge pull request #105 from outis1one/claude/brave-tesla-hwf1m8
Fix Electron binary extraction: chown dist/ before extracting as kios…
2026-06-16 13:21:17 -04:00
Claude 43266a4cf0 Fix Electron binary extraction: chown dist/ before extracting as kiosk user
The previous fix incorrectly ran unzip as root, which fails because
/home/kiosk is not accessible to root. The kiosk user is the right
actor for the extraction, but two things blocked it:

1. node_modules/electron/dist/ can be owned by root when npm's electron
   postinstall runs with --unsafe-perm, so the kiosk user gets
   'Permission denied' trying to write there. Fix: sudo chown -R the
   electron directory to the kiosk user before extracting.

2. With set -euo pipefail active (upgrade call had no || guard), a
   failed unzip or chmod would abort the script silently before the
   diagnostic error messages could print. Fix: add || true to both
   commands so the function always reaches the explicit -f check which
   prints the real error and returns 1. The upgrade call already has
   || { log_error ...; return 1; } from the previous commit.

https://claude.ai/code/session_01VQ13Fwq4MXxwThLfCXBeGr
2026-06-16 17:17:37 +00:00
Outis 6621f9221b Merge pull request #104 from outis1one/claude/brave-tesla-hwf1m8
Fix Electron binary extraction failing silently during upgrade
2026-06-16 13:13:09 -04:00
Claude 670b32f53c Fix Electron binary extraction failing silently during upgrade
Two bugs combined to cause the 'Electron binary download failed' error
even though the zip downloaded and unzip reported inflating all files:

1. The upgrade path called install_electron_binary bare (no ||), so
   set -euo pipefail was active inside the function. Any failing command
   (e.g. chmod on a file that wasn't written) killed the script before
   the error messages printed. Fresh install used || exit 1, which
   disables set -e inside the function body. Upgrade now uses
   || { log_error ...; return 1; } to match.

2. The unzip ran as the kiosk user, but node_modules/electron/dist/ can
   be owned by root when npm's electron postinstall script runs with
   --unsafe-perm. The kiosk user can't write there, so unzip's write
   errors go to stderr (not visible in the log) while inflating: lines
   still appear on stdout. The binary is never actually written.
   Fix: run mkdir/unzip/chmod as root, then chown -R to kiosk.

https://claude.ai/code/session_01VQ13Fwq4MXxwThLfCXBeGr
2026-06-16 17:11:56 +00:00
Outis c3ea52dda0 Merge pull request #103 from outis1one/claude/gifted-bohr-dfzdig
Fix permission denied on Electron zip: chmod 644 before kiosk-user unzip
2026-06-16 12:56:55 -04:00
Claude 174bbe46ae Fix permission denied on Electron zip: chmod 644 before kiosk-user unzip
mktemp creates the tmp zip owned by root with mode 600.
sudo -u kiosk unzip then fails with "Permission denied".
Add chmod 644 immediately after download so the kiosk user can read it.

https://claude.ai/code/session_01EyjEQLWbTXcZgbMDarf7NU
2026-06-16 16:54:51 +00:00
Outis 572f0ed01b Merge pull request #102 from outis1one/claude/gifted-bohr-dfzdig
Detect pipe execution; show clear upgrade error instead of cryptic pa…
2026-06-16 12:50:00 -04:00
Claude b7e35c84c9 Detect pipe execution; show clear upgrade error instead of cryptic path failure
When the script is run via curl|bash or wget|bash, BASH_SOURCE[0] is a
pipe descriptor, not a real file. The upgrade function grep-extracts
heredocs from the script file, so it fails with a confusing path error.

Fixes:
- Set SCRIPT_FILE global at startup (empty string when piped)
- upgrade_kiosk() checks SCRIPT_FILE before asking "Continue?" and shows
  a clear message explaining how to download the script to a file first
- Removes the silent failure path (no more cryptic "Cannot find script at
  /proc/.../pipe:[...]" error)

https://claude.ai/code/session_01EyjEQLWbTXcZgbMDarf7NU
2026-06-16 16:45:58 +00:00
Outis 9a9352a9c2 Merge pull request #101 from outis1one/claude/gifted-bohr-dfzdig
Add unzip to apt packages; guard install_electron_binary against miss…
2026-06-16 12:40:25 -04:00
Claude 440b074245 Add unzip to apt packages; guard install_electron_binary against missing unzip
Fresh Ubuntu 24.04 minimal installs don't include unzip. The wget fallback
in install_electron_binary() downloaded the 120MB Electron zip successfully
but then failed on the unzip call. Two fixes:
1. Add unzip to the main apt install step so it's always present.
2. Auto-install unzip inside install_electron_binary() as a safety net for
   upgrades on existing systems that may not have it.

https://claude.ai/code/session_01EyjEQLWbTXcZgbMDarf7NU
2026-06-16 16:39:36 +00:00
Outis 46d42b46c6 Merge pull request #100 from outis1one/claude/gifted-bohr-dfzdig
feat: match any touch screen for libinput (universal touch support)
2026-06-16 11:05:53 -04:00
Claude 27aaf5a15c feat: match any touch screen for libinput, not just Wacom "Finger" devices
Drop the MatchProduct "Finger" restriction from the xorg libinput rule,
leaving only MatchIsTouchscreen "on". MatchIsTouchscreen is set by udev
from hardware capabilities, so it matches finger touch screens of any
brand (ELAN, Goodix, eGalax, Wacom, etc.) while never matching keyboards,
mice, or pen/stylus digitizers (which are tagged as tablets, not
touchscreens). This makes the script work on any touch hardware without
hardcoding device names. Behavior on existing Wacom machines is unchanged
since their finger device matched either way.

https://claude.ai/code/session_01EyjEQLWbTXcZgbMDarf7NU
2026-06-16 15:05:28 +00:00
Outis 981aaf2b6b Merge pull request #99 from outis1one/claude/gifted-bohr-dfzdig
fix: touchscreen input — keyring grab + libinput multitouch driver
2026-06-16 10:53:13 -04:00
Claude 66a633b22c fix: resolve touch input with keyring flag + libinput driver
Two genuinely separate root causes were behind the dead touchscreen:

1. GNOME keyring grab — under LightDM autologin the keyring stays locked.
   When Chromium accessed it, the gcr-prompter unlock dialog grabbed all
   keyboard and touch input at the X level. The app rendered (timers ran)
   but ignored every tap and keypress. Fix: --password-store=basic stops
   Electron from using the keyring, so the dialog never appears.

2. Wacom driver single-touch emulation — the wacom X driver only does
   single-touch pointer emulation and never passes real multitouch to
   Chromium, so 1-finger and 2-finger swipe gestures could not fire.
   Fix: force the finger touch device to the libinput driver via
   /etc/X11/xorg.conf.d/99-finger-libinput.conf. libinput delivers proper
   XI2 multitouch which Chromium turns into real JS touch events. The
   pen/stylus stays on the wacom driver.

Removed the earlier dead-end attempts (xsetwacom MapToOutput / CTM reset,
Wacom Enable Touch Gesture, 99-wacom-touch.conf) which were all chasing the
wrong cause while the keyring grab masked any real testing. The upgrade path
removes the stale 99-wacom-touch.conf so it can't override libinput.

https://claude.ai/code/session_01EyjEQLWbTXcZgbMDarf7NU
2026-06-16 14:52:31 +00:00
Claude 4e7fbe497c fix: use xsetwacom MapToOutput instead of xinput set-prop for CTM
The Wacom driver owns the Coordinate Transformation Matrix and silently
overrides any xinput set-prop changes. xsetwacom MapToOutput tells the
driver to recalculate the CTM for the primary connected output, which is
the correct API and persists across driver resets.

Dynamically detects the primary output (eDP1, HDMI1, DP1, etc.) so the
fix works on any machine without hardcoding a display name.

https://claude.ai/code/session_01EyjEQLWbTXcZgbMDarf7NU
2026-06-16 13:55:09 +00:00
Outis f7f1624e02 Merge pull request #98 from outis1one/claude/gifted-bohr-dfzdig
fix: reset Coordinate Transformation Matrix so touches land on correct screen coordinates
2026-06-16 09:45:08 -04:00
Claude 3315ccc44a fix: reset Coordinate Transformation Matrix to identity in start.sh
The Wacom driver can initialise the CTM to all-zeros, which maps every
touch event to screen coordinate (0,0). The touchscreen appears completely
dead even though the hardware and kernel are working correctly.

Reset the CTM to the identity matrix for every touch/finger device at
startup, before launching Electron, so coordinates are always correct.

https://claude.ai/code/session_01EyjEQLWbTXcZgbMDarf7NU
2026-06-16 13:44:50 +00:00
Outis c58addcecb Merge pull request #97 from outis1one/claude/gifted-bohr-dfzdig
fix: correct xorg MatchProduct glob pattern for Wacom touch config
2026-06-16 09:13:53 -04:00
Claude 14172782c9 fix: correct xorg MatchProduct glob — Wacom*Finger* not Wacom.*Finger
xorg uses fnmatch (shell glob) for MatchProduct, where . is a literal
dot. Wacom.*Finger never matched "Wacom HID 48E3 Finger touch" because
there is no literal dot in that string. Wacom*Finger* matches correctly.

https://claude.ai/code/session_01EyjEQLWbTXcZgbMDarf7NU
2026-06-16 13:13:41 +00:00
Outis 279dcd2b66 Merge pull request #96 from outis1one/claude/gifted-bohr-dfzdig
fix: xorg.conf.d Wacom touch mode — driver starts in XI2 touch event mode
2026-06-16 09:08:50 -04:00
Claude 5cbade03d7 fix: add xorg.conf.d Wacom touch config so driver starts in touch event mode
Without /etc/X11/xorg.conf.d/99-wacom-touch.conf the Wacom driver initialises
the finger touch device in pointer emulation mode (generating RawButtonPress/
RawButtonRelease/RawMotion). Electron never sees TouchBegin/TouchEnd events so
touchstart/pointerdown(touch) never fire in the renderer.

Setting Option "Gesture" "on" and Option "Touch" "on" at the driver level means
the device initialises in XI2 touch mode on every X server start, regardless of
any post-init xinput set-prop calls.

Added to both fresh install (step 18) and upgrade function.

https://claude.ai/code/session_01EyjEQLWbTXcZgbMDarf7NU
2026-06-16 13:08:38 +00:00
Outis 73eed4a123 Merge pull request #95 from outis1one/claude/gifted-bohr-dfzdig
fix: export XAUTHORITY in start.sh so touch init never fails with auth error
2026-06-16 08:57:35 -04:00
Claude 95027fe6fe fix: export XAUTHORITY in start.sh so xinput touch init never hits auth error
Without XAUTHORITY set, xinput can fail with "Authorization required"
if the display manager doesn't propagate it through the session environment.
Hardcode the kiosk user's .Xauthority path to guarantee xinput works.

https://claude.ai/code/session_01EyjEQLWbTXcZgbMDarf7NU
2026-06-16 12:57:21 +00:00
Outis 8b9d688993 Merge pull request #94 from outis1one/claude/gifted-bohr-dfzdig
chore: restore original ubuntu-based-kiosk-v1.0.2.sh alongside v1.0.3
2026-06-15 22:29:29 -04:00
Claude 55039c510b chore: restore original ubuntu-based-kiosk-v1.0.2.sh alongside v1.0.3
Keep v1.0.2 in the repo as a stable fallback while v1.0.3 touch screen
support is being validated on different hardware.

https://claude.ai/code/session_01EyjEQLWbTXcZgbMDarf7NU
2026-06-16 02:27:13 +00:00
Outis 7f54944ad8 Merge pull request #93 from outis1one/claude/gifted-bohr-dfzdig
Claude/gifted bohr dfzdig
2026-06-15 22:24:24 -04:00
Claude 00dd806e9c chore: bump version to v1.0.3 and rename script file
Update all version strings (header, SCRIPT_VERSION, JS VERSION constant)
and rename ubuntu-based-kiosk-v1.0.2.sh → ubuntu-based-kiosk-v1.0.3.sh.
Update README with v1.0.3 change log and archive v1.0.2 as previous.

https://claude.ai/code/session_01EyjEQLWbTXcZgbMDarf7NU
2026-06-16 02:23:43 +00:00
Claude 6f18a2f897 fix: detect touch screens dynamically instead of hardcoding Wacom device name
The xinput loop now scans all input devices at startup, matches anything
with "touch" or "finger" in the name (excluding touchpads/trackpads), and
attempts to enable Wacom touch gestures on each match. Non-Wacom devices
silently ignore the set-prop call, so the loop is safe on any hardware.

https://claude.ai/code/session_01EyjEQLWbTXcZgbMDarf7NU
2026-06-16 02:21:09 +00:00
Outis 93b944fdb8 Merge pull request #92 from outis1one/claude/gifted-bohr-dfzdig
fix: enable Wacom touch gestures on startup and update start.sh on up…
2026-06-15 22:20:24 -04:00
Claude c18615a78e fix: enable Wacom touch gestures on startup and update start.sh on upgrade
Add xinput call to start.sh so Wacom HID 48E3 touch gesture support is
initialized every time the kiosk starts, not just after lightdm restarts.
Also add start.sh to the upgrade extraction list so it is updated in place
instead of keeping the stale version from the original install.

https://claude.ai/code/session_01EyjEQLWbTXcZgbMDarf7NU
2026-06-16 02:19:03 +00:00
Outis dcf3ba11c7 Merge pull request #91 from outis1one/claude/gifted-bohr-dfzdig
fix: add PointerEvent fallback for swipe detection
2026-06-15 22:02:00 -04:00
Claude 37663be91b fix: add PointerEvent fallback for swipe detection
touchstart/touchend never fire on this device (confirmed by zero [TOUCH]
log entries). The activity tracker already uses pointerdown/pointerup and
works fine, proving PointerEvents reach the preload. Added pointer event
handlers that mirror the touch handlers for all gestures (2-finger swipe,
3-finger toggle, 1-finger arrow keys). A 500ms debounce on the IPC send
prevents double-firing on devices where both event types fire.

https://claude.ai/code/session_01EyjEQLWbTXcZgbMDarf7NU
2026-06-16 01:59:25 +00:00
Outis 690f2daf3c Merge pull request #90 from outis1one/claude/gifted-bohr-dfzdig
revert: restore v1.0.0 touch handler approach for two-finger swipe
2026-06-15 21:55:10 -04:00
Claude 8f886854f2 revert: restore v1.0.0 touch handler approach for two-finger swipe
capture:true and --touch-events=enabled were added to handle Authelia's
login page blocking touch events. Authelia now auto-logs in on startup
so the login page never shows. Reverting to the v1.0.0 approach (passive:true
only, no --touch-events flag) which had working two-finger swipe.

https://claude.ai/code/session_01EyjEQLWbTXcZgbMDarf7NU
2026-06-16 01:52:46 +00:00
Outis 22fb77581c Merge pull request #89 from outis1one/claude/gifted-bohr-dfzdig
fix: add --touch-events=enabled to Electron launch flags
2026-06-15 21:41:57 -04:00
Claude f40d8e337c fix: add --touch-events=enabled to Electron launch flags
With --ozone-platform=x11, Chromium defaults touch event detection to
'auto' and may not identify the hardware as a touchscreen, so touchstart/
touchend never fire in the renderer. --touch-events=enabled forces W3C
touch events on unconditionally, restoring two-finger swipe navigation.

https://claude.ai/code/session_01EyjEQLWbTXcZgbMDarf7NU
2026-06-16 01:16:58 +00:00
Outis b92180c4b2 Merge pull request #88 from outis1one/claude/gifted-bohr-dfzdig
fix: preserve node_modules during upgrade to avoid re-downloading Ele…
2026-06-15 20:56:00 -04:00
Claude d595464400 fix: preserve node_modules during upgrade to avoid re-downloading Electron
The upgrade was wiping node_modules then relying on npm to re-download the
~120MB Electron binary. npm returns exit 0 even when the download times out,
leaving the kiosk with no Electron binary and a blank screen on next boot.

node_modules only needs to be deleted on a fresh install or when explicitly
changing Electron version. For a JS-file-only upgrade, npm install without
a wipe is either a no-op (no changes) or applies dependency updates cleanly.
The install_electron_binary fallback remains as a safety net.

https://claude.ai/code/session_01EyjEQLWbTXcZgbMDarf7NU
2026-06-16 00:52:22 +00:00
Outis 69be816dfe Merge pull request #87 from outis1one/claude/gifted-bohr-dfzdig
fix: use sudo to remove root-owned config backup after upgrade
2026-06-15 20:40:52 -04:00
Claude 543a442775 fix: use sudo to remove root-owned config backup after upgrade
Backup is created with sudo cp (owned by root), so the cleanup rm -f
fails with "Operation not permitted" when run without sudo.

https://claude.ai/code/session_01EyjEQLWbTXcZgbMDarf7NU
2026-06-16 00:39:53 +00:00
Outis 3f1984f9fa Merge pull request #86 from outis1one/claude/gifted-bohr-dfzdig
docs: update Authelia setup instructions to use MERGE workflow
2026-06-15 20:32:36 -04:00
Claude 3d5a5839b3 fix: use capture:true on touch event listeners to prevent page JS interference
Touch handlers used bubble phase (no capture:true), so any page script that
called stopPropagation() on touchstart/touchend — e.g. Authelia's login form
or scroll containers — silently blocked the preload's swipe detection.

Using capture:true fires the preload's listeners in the capture phase (before
any element-level handlers), so swipe works even on pages with their own
touch handling. Applied to both preloads (standard and auto-show keyboard).

Also adds missing [TOUCH] 2-finger HORIZONTAL console.log to the standard
preload so swipe events are visible in electron.log for debugging.

https://claude.ai/code/session_01EyjEQLWbTXcZgbMDarf7NU
2026-06-16 00:31:14 +00:00
Claude 31eb750514 docs: clarify Authelia access_control merge with before/after example
The duplicate-block pitfall (YAML silently ignores duplicate keys, causing
a white screen) is now called out explicitly in both the script's printed
output and the README. Added a before/after example showing the correct
merged result with the kiosk one_factor rule above the two_factor wildcard.
Also explains why one_factor is required (TOTP/WebAuthn need interactive
second step, impossible via API).

https://claude.ai/code/session_01EyjEQLWbTXcZgbMDarf7NU
2026-06-16 00:25:11 +00:00
Claude effb726979 fix: add 10-second timeout to Authelia fetch to prevent white screen
Without a timeout, session.defaultSession.fetch() hangs for 1-2 minutes
on TCP timeout when Authelia is unreachable (wrong URL, server down,
firewall). Since createWindow() awaits autheliaAuthenticate(), the main
window is visible but no BrowserView is attached during that wait —
causing a persistent white screen with ibeam cursor.

https://claude.ai/code/session_01EyjEQLWbTXcZgbMDarf7NU
2026-06-16 00:15:18 +00:00
Claude 0eef318e78 docs: update Authelia setup instructions to use MERGE workflow
- Step 3 now says MERGE (not replace/append) with clear warning to keep existing config
- access_control: kiosk one_factor rule must go ABOVE any existing two_factor rule,
  with explanation that Authelia applies rules top-down (first match wins)
- session block: keep existing values; only add the block if none exists yet
- Kiosk can only do one_factor — TOTP/WebAuthn via API is not possible
- Updated in both configure_authelia() printed output and README Authentication section

https://claude.ai/code/session_01EyjEQLWbTXcZgbMDarf7NU
2026-06-15 23:55:53 +00:00
Outis 09dfcd40af Merge pull request #85 from outis1one/claude/gifted-bohr-dfzdig
fix: shared install_electron_binary() covers both fresh install and u…
2026-06-15 19:39:31 -04:00
Claude 0b52ec27fb fix: shared install_electron_binary() covers both fresh install and upgrade
The upgrade_kiosk() path deleted node_modules then ran npm install but
never checked the binary or set chrome-sandbox permissions — so every
upgrade produced a blank screen.

Changes:
- Extract electron binary verification, fallback downloads, and
  chrome-sandbox chmod 4755 into a shared install_electron_binary()
  function called by both step 17/27 (fresh install) and step 5/6
  (upgrade_kiosk) so neither path can silently skip the permission fix
- Add repair_electron() function: stops display, re-runs
  install_electron_binary, restarts lightdm — no SSH needed
- Wire repair_electron as Advanced menu option 12 "Fix Blank Screen"

https://claude.ai/code/session_01EyjEQLWbTXcZgbMDarf7NU
2026-06-15 23:38:37 +00:00
Outis b003daba85 Merge pull request #84 from outis1one/claude/gifted-bohr-dfzdig
fix: robust Electron binary download with timeouts, retry, and wget f…
2026-06-15 19:35:57 -04:00
Claude bd4d4cadc8 fix: robust Electron binary download with timeouts, retry, and wget fallback
The ~120MB Electron binary download was silently failing because npm's
default 60s fetch timeout is too short on slower connections.

Changes to step 17/27:
- Set npm fetch-timeout to 600s and retries to 5 before running npm install
- If binary still missing after npm install, retry via install.js with
  ELECTRON_FORCE_DOWNLOAD=true
- If still missing, fall back to direct wget download of the exact
  versioned zip from GitHub releases (300s timeout, 3 tries, shows progress)
- Exit 1 with clear message if all three attempts fail
- Log chrome-sandbox permission step for visibility

https://claude.ai/code/session_01EyjEQLWbTXcZgbMDarf7NU
2026-06-15 23:34:00 +00:00
Outis 66ec66362f Merge pull request #83 from outis1one/claude/gifted-bohr-dfzdig
Claude/gifted bohr dfzdig
2026-06-15 19:20:56 -04:00
Claude 2556863129 fix: use sudo for all config.json access in configure_authelia
/home/kiosk/ has 750 permissions so the install user can't read inside
it. The -f check and both jq reads were running as the current user and
failing silently, causing the false "config.json not found" error.

Changed:
  [[ ! -f "$config_file" ]]  →  sudo test -f "$config_file"
  jq -r ... "$config_file"   →  sudo -u kiosk jq -r ... "$config_file"
  jq ... > "$tmp"            →  sudo -u kiosk jq ... > "$tmp" && sudo mv

https://claude.ai/code/session_01EyjEQLWbTXcZgbMDarf7NU
2026-06-15 23:20:14 +00:00
Claude 4c3f5576b9 fix: detect and retry Electron binary download when npm install silently fails
npm install returns 0 even when Electron's postinstall binary download
fails, leaving node_modules/electron/dist/electron missing and causing
a blank screen with no useful error.

After npm install, explicitly check for the binary. If absent, retry
via ELECTRON_FORCE_DOWNLOAD=true node install.js. If still missing,
print a clear error and exit 1 instead of silently continuing to a
broken install.

https://claude.ai/code/session_01EyjEQLWbTXcZgbMDarf7NU
2026-06-15 22:52:49 +00:00
Outis 7634ddbaaf Merge pull request #82 from outis1one/claude/gifted-bohr-dfzdig
Claude/gifted bohr dfzdig
2026-06-15 11:56:26 -04:00
Claude f1febbd914 release: v1.0.2 - Authelia auto-login, pipewire fix, dynamic README
New file ubuntu-based-kiosk-v1.0.2.sh containing all changes made
since v1.0.1:
- Authelia auto-login addon (Addons → 5): AES-256 encrypted credentials,
  startup API auth, full Dockerized server-side setup printed on save
- Fix: PipeWire .config dirs created as root caused Permission denied
  at step [5.5/27] on fresh Ubuntu 24.04 minimal installs
- README install commands now pull latest script dynamically via
  GitHub contents API (no more hardcoded version numbers)

SCRIPT_VERSION and VERSION constants updated to 1.0.2.
README changelog and current version updated to 1.0.2.

https://claude.ai/code/session_01EyjEQLWbTXcZgbMDarf7NU
2026-06-15 14:57:51 +00:00
Claude 05538611cf docs: add Authelia server-side setup guide to script output and README
After configure_authelia() saves credentials, it now prints the full
Dockerized Authelia server-side checklist: argon2 hash generation
command, users.yml kiosk user template, configuration.yml session
duration and access_control rules, and a docker compose restart step.

README gains a new Authentication section under Optional Add-ons
covering the same steps in Markdown with a table comparing Authelia
SSO vs HTTP Basic Auth (both can coexist).

Also clarifies that the Authelia password is encrypted at rest and
not stored in plain text.

https://claude.ai/code/session_01EyjEQLWbTXcZgbMDarf7NU
2026-06-15 14:49:32 +00:00
Claude 2a5fb6188b feat: Authelia auto-login with machine-ID-bound AES-256 encryption
Adds an Authelia Auto-Login addon (Addons menu → 5) that:
- Prompts for Authelia URL, username, and password
- Encrypts the password with AES-256-CBC keyed from /etc/machine-id
  via scrypt (the encrypted blob is machine-specific and useless elsewhere)
- Stores autheliaURL, autheliaUsername, autheliaEncryptedPassword in config.json

On every kiosk startup, main.js decrypts the password and calls
Authelia's /api/firstfactor with keepMeLoggedIn:true before any
BrowserViews are created. Electron's session.defaultSession handles
the Set-Cookie response automatically, so all sites load already
authenticated.

To set up credentials via SSH:
  ssh user@kiosk
  ./ubuntu-based-kiosk-v*.sh  →  Addons → 5. Authelia Auto-Login

https://claude.ai/code/session_01EyjEQLWbTXcZgbMDarf7NU
2026-06-15 14:35:41 +00:00
Outis b956af05e2 Merge pull request #81 from outis1one/claude/gifted-bohr-dfzdig
fix: create pipewire config dirs as kiosk user to avoid permission de…
2026-06-15 10:12:29 -04:00
Claude 42aafff219 fix: create pipewire config dirs as kiosk user to avoid permission denied
sudo mkdir -p created the .config/pipewire/pipewire.conf.d directories
owned by root, causing the subsequent sudo -u kiosk tee to fail with
"Permission denied" at step [5.5/27] on a fresh install.

Switching to sudo -u kiosk mkdir -p ensures the directories are owned
by the kiosk user before the tee writes into them.

https://claude.ai/code/session_01EyjEQLWbTXcZgbMDarf7NU
2026-06-15 14:11:56 +00:00
Outis 44b04067ce Merge pull request #80 from outis1one/claude/gifted-bohr-dfzdig
docs: remove hardcoded version numbers from install instructions
2026-06-15 09:47:03 -04:00
Claude c2d9b34d53 docs: remove hardcoded version numbers from install instructions
Quick Install section now queries the GitHub contents API to find and
download the latest ubuntu-based-kiosk-v*.sh script dynamically, so the
README never needs a manual version bump when a new release is pushed.

Post-install "run again" references use `ls ubuntu-based-kiosk-v*.sh | sort -V | tail -1`
for the same reason.

Also bumps version references from 1.0.0 → 1.0.1, Electron 41 → 42,
Node.js 20 → 22, and adds the v1.0.1 changelog entry.

https://claude.ai/code/session_01EyjEQLWbTXcZgbMDarf7NU
2026-06-15 13:45:26 +00:00
Outis 91515e5dff Merge pull request #79 from outis1one/claude/fix-kiosk-upgrade-script-zHQ5X
Claude/fix kiosk upgrade script z hq5 x
2026-06-02 22:53:16 -04:00
Outis 8b13426215 Merge pull request #78 from outis1one/claude/ecstatic-bohr-SERdJ
Add v1.0.1: update Node.js 20→22 LTS and Electron 39→42
2026-06-02 21:18:01 -04:00
Claude dc8f2b3a9c Add v1.0.1: update Node.js 20→22 LTS and Electron 39→42
Node.js 20 reached EOL April 2026; bumps nodesource setup to 22.x.
Electron pin updated from ^39.2.4 to ^42.0.0 (current stable).

https://claude.ai/code/session_0143uHgfTvF3Pv1oDinkhJRX
2026-06-03 01:16:43 +00:00
Claude 11173bbcda Add --allow-server-ssh --disable-ssh-auth to netbird up commands
NetBird v0.60 changed SSH to JWT/IdP-based auth by default.
Using --disable-ssh-auth keeps access controlled purely by NetBird
ACL policies (machine-level, like pre-v0.60 behaviour) without
requiring an identity provider or OIDC flow.

https://claude.ai/code/session_01M3tiofbGfmTddeMcXr8nXr
2026-04-21 02:55:48 +00:00
Claude a7894ede51 v1.0.1-beta: replace nodeIntegration/contextIsolation:false with contextBridge preload
All 6 popup BrowserWindows (lockout, prompt, pause, pin, keyboard ×2)
now use contextIsolation:true + popup-preload.js instead of the
deprecated nodeIntegration:true pattern. A new popup-preload.js file
exposes crypto.hashPassword, fs.readPin, ipcRenderer.send/on/once to
the renderer via contextBridge. All affected HTML files updated to use
window.electronAPI.* instead of direct require('electron') calls.
The popup-preload.js heredoc is also added to the upgrade extract_file
list so upgrades re-extract it correctly.

https://claude.ai/code/session_01M3tiofbGfmTddeMcXr8nXr
2026-04-21 01:29:27 +00:00
Claude 589a65bc55 feat: add v1.0.1-beta with BrowserView → WebContentsView migration
BrowserView has been deprecated since Electron 29 and will be removed
in a future major release. This beta migrates all usage to the
WebContentsView API introduced in Electron 28.

Changes in main.js:
- Import WebContentsView instead of BrowserView
- Add bringViewToTop() helper (remove+re-add as last child = on top)
- createWindow: new WebContentsView / contentView.addChildView
- attachView: contentView.removeChildView + bringViewToTop
- showLockoutScreen: contentView.removeChildView for all views
- unlockScreen: bringViewToTop to restore hidden view
- returnToTabs: bringViewToTop instead of setTopBrowserView
- showHiddenTab: bringViewToTop instead of setTopBrowserView

v1.0.0 is kept unchanged. The legacy update_mainjs_keyboard() patch
function is guarded by a grep check that prevents it running against
the new WebContentsView-based main.js.

https://claude.ai/code/session_01M3tiofbGfmTddeMcXr8nXr
2026-04-21 01:12:22 +00:00
Claude 5085a19805 chore: upgrade Electron from v39.2.4 to v41.2.1
No breaking changes affecting the kiosk app between these versions:
- BrowserView still present (deprecated but not removed until future release)
- WebContentsView destroyed-event change does not apply (app uses BrowserView)
- Session.clearStorageData quotas removal not used
- PDF OOPIF change not relevant

https://claude.ai/code/session_01M3tiofbGfmTddeMcXr8nXr
2026-04-21 01:04:54 +00:00
Claude ae65853f5a docs: update README for v1.0.0
- Bump version references from 0.9.9.1/0.9.8 to 1.0.0
- Update script filename to ubuntu-based-kiosk-v1.0.0.sh throughout
- Add v1.0.0 changelog entries (upgrade fix, sudo/timezone fixes)
- Preserve prior version history (v0.9.9.1, v0.9.8) as changelog
- Update Claude model reference to Sonnet 4.6
- Update last-updated date

https://claude.ai/code/session_01M3tiofbGfmTddeMcXr8nXr
2026-04-21 01:02:08 +00:00
Outis 502baaed44 Merge pull request #77 from outis1one/claude/fix-kiosk-upgrade-script-zHQ5X
Claude/fix kiosk upgrade script z hq5 x
2026-04-20 21:00:18 -04:00
Claude 145320998e chore: bump version to 1.0.0 and rename script file
https://claude.ai/code/session_01M3tiofbGfmTddeMcXr8nXr
2026-04-21 00:59:09 +00:00
Claude 4b7ab7c0be fix: prevent sudo cache expiry during install and add timezone fallback
Two issues caused the timezone step to fail on first run:

1. Sudo credential cache (default 15 min) can expire during the long
   apt install step before configure_timezone runs. Added `sudo -v`
   immediately after the install confirmation prompt to prime the cache
   as late as possible, just before the first long-running step.

2. `sudo timedatectl set-timezone` can fail with "Access denied" if
   polkit/D-Bus is not yet fully ready in the install environment.
   Added a direct fallback (ln -sf localtime + tee /etc/timezone)
   that bypasses D-Bus entirely.

https://claude.ai/code/session_01M3tiofbGfmTddeMcXr8nXr
2026-04-21 00:16:55 +00:00
Claude 07823423e5 fix: use sudo test -s to verify extracted files in upgrade_kiosk
On Ubuntu 22.04+, useradd creates home directories with 750 permissions,
so the non-root user running the script cannot traverse /home/kiosk to
check file existence with [[ -s ]]. sudo tee (running as root) writes the
files successfully, but the bash test always returned false, falsely
reporting all extractions as failed.

Switch to `sudo test -s` to match the pattern already used elsewhere in
the script (line ~10320) when checking files under /home/kiosk.

https://claude.ai/code/session_01M3tiofbGfmTddeMcXr8nXr
2026-04-20 23:10:47 +00:00
outis1one 06c00a5944 Update Readme.md 2025-12-30 08:27:23 -05:00
outis1one 44991d307b Rename ubuntu-based-kiosk.sh to ubuntu-based-kiosk-v0.9.9.1.sh 2025-12-30 08:26:55 -05:00
outis1one 2700f27d1c Merge pull request #76 from outis1one/claude/rename-ubk-ubuntu-kiosk-QoSy9
Rename UBK to Ubuntu Based Kiosk and rename main install script
2025-12-30 08:25:43 -05:00
Claude 96747fa411 Rename UBK to Ubuntu Based Kiosk and rename main install script
- Rename install_kiosk_v0.9.9.1.sh to ubuntu-based-kiosk.sh
- Update all repository URLs from ubk to ubuntu-based-kiosk
- Remove UBK abbreviation from documentation and script headers
- Update installation instructions in Readme.md
2025-12-30 05:28:48 +00:00
outis1one d42bd363be Delete working-I-think.zip 2025-12-28 19:15:18 -05:00
outis1one eba2da9de8 Merge pull request #75 from outis1one/outis1one-patch-1
Add files via upload
2025-12-28 16:51:51 -05:00
outis1one 8098d8d831 Add files via upload 2025-12-28 16:51:38 -05:00
outis1one e8ffe6393f Merge pull request #74 from outis1one/claude/fix-kiosk-issues-qlxTc
Fix power menu IPC and export/import permissions
2025-12-28 16:36:57 -05:00
Claude eb2f31f818 Fix power menu IPC and export/import permissions
- Power menu: Send IPC to views[] instead of mainWindow.webContents
  (preload.js runs in BrowserViews, not mainWindow)
- Export: Add chmod 777 to temp dir, use sudo for all file operations,
  use sudo tar and fix archive ownership
- Import: Use sudo tar, add proper permissions to temp directory
2025-12-28 21:35:37 +00:00
outis1one f02b8341a3 Merge pull request #73 from outis1one/claude/fix-kiosk-issues-qlxTc
Add 30-second auto-dismiss timeout to secondary screens
2025-12-28 15:32:47 -05:00
Claude 79d112794b Add 30-second auto-dismiss timeout to secondary screens
- PIN entry window: auto-closes after 30 seconds of inactivity
- Pause dialog: auto-closes after 30 seconds of inactivity
- Power menu: converted from native dialog to custom overlay with
  30-second timeout (lockout mode still uses native dialog)
- Nav menu already had 30-second timeout

All modal windows and overlays now automatically dismiss after
30 seconds to prevent screens being left open indefinitely.
2025-12-28 18:05:21 +00:00
outis1one d13137278d Merge pull request #72 from outis1one/claude/fix-kiosk-issues-qlxTc
Claude/fix kiosk issues qlx tc
2025-12-28 12:30:57 -05:00
Claude 4ac9676ef5 Power button follows same show/hide logic as nav button
- Power button now starts hidden and appears on user interaction
- Auto-hides after 5 seconds of inactivity (matching nav button)
- Increased size to 60px with 3px border for consistency
- Removed hover transitions for simpler, consistent behavior
2025-12-28 17:29:27 +00:00
Claude c4f93571a9 Add power button, fix EPIPE errors, fix display schedule
- Add red power icon button in top-right corner of UI
- Click power button triggers showPowerMenu via IPC
- Add EPIPE error suppression for stdout/stderr (no more error dialogs)
- Fix display on/off scripts: add XAUTHORITY, hardcode kiosk user
- Add logging to display scripts for debugging
2025-12-28 16:41:36 +00:00
outis1one f47d7d54c9 Merge pull request #71 from outis1one/claude/fix-kiosk-issues-qlxTc
Add error handling to SIGUSR1 power button handler
2025-12-28 11:18:09 -05:00
Claude 27de108579 Add error handling to SIGUSR1 power button handler 2025-12-28 15:58:13 +00:00
outis1one b926d00cf4 Merge pull request #70 from outis1one/claude/fix-kiosk-issues-qlxTc
Claude/fix kiosk issues qlx tc
2025-12-27 20:45:50 -05:00
Claude 7ed5faa2dc Simplify power button: direct SIGUSR1 from root ACPI handler
- Simplified power button script runs as root from acpid
- No longer needs DISPLAY/XAUTHORITY (just sends signal)
- Finds all Electron processes and sends SIGUSR1 to each
- Updated ACPI event handlers to call /usr/local/bin/kiosk-power-button.sh
- Updated test-power-button to actually trigger and test
- Upgrade function now installs simplified handler
2025-12-27 14:23:40 +00:00
Claude e04fc42a85 Fix on-screen keyboard: ignore Ctrl/Alt modifier keys
When pressing Ctrl or Alt on the virtual keyboard, the literal text
"Control" or "Alt" was being inserted into text fields. Now these
modifier keys are properly ignored since they don't function as
standalone keys in input fields.
2025-12-27 03:24:43 +00:00
outis1one 4332ad1b26 Merge pull request #69 from outis1one/claude/fix-kiosk-issues-qlxTc
Fix upgrade: ensure dir exists, use sudo tee directly
2025-12-24 13:55:23 -05:00
Claude 966709547f Fix upgrade: ensure dir exists, use sudo tee directly
- Create kiosk directory if it doesn't exist before extraction
- Use 'sudo tee' instead of 'sudo -u kiosk tee' for reliable writes
- Add line numbers to output for debugging
- chown at end fixes permissions
2025-12-24 18:48:38 +00:00
outis1one 8e98f66215 Merge pull request #68 from outis1one/claude/fix-kiosk-issues-qlxTc
Fix upgrade extraction using grep+sed line numbers
2025-12-24 13:44:26 -05:00
Claude bb82108809 Fix upgrade extraction using grep+sed line numbers
The awk-based extraction had variable scoping issues when run
as a nested function. Switch to grep for finding line numbers
and sed for extraction - more reliable approach.
2025-12-24 18:42:53 +00:00
outis1one 37fbe07eab Merge pull request #67 from outis1one/claude/fix-kiosk-issues-qlxTc
Fix upgrade: use awk extraction, restart lightdm not kiosk
2025-12-24 13:38:44 -05:00
Claude b17adbde2b Fix upgrade: use awk extraction, restart lightdm not kiosk
- Replace sed with awk for more reliable heredoc extraction
- Add debug output showing script path
- Fix restart to use lightdm instead of non-existent kiosk.service
- Kill electron process before upgrade, check electron after
- Add script path validation before extraction
2025-12-24 18:36:20 +00:00
outis1one 6742d8dec6 Merge pull request #66 from outis1one/claude/fix-kiosk-issues-qlxTc
Fix sed patterns in upgrade - remove strict anchors
2025-12-24 13:31:20 -05:00
Claude ec0d3ee633 Fix sed patterns in upgrade - remove strict anchors
The sed patterns were too strict with ^ and $ anchors, causing
extraction to fail on lines with leading whitespace.
2025-12-24 18:27:17 +00:00
outis1one 3896614ffe Merge pull request #65 from outis1one/claude/fix-kiosk-issues-qlxTc
v0.9.9.1: Silent upgrade, import number selection, power button fix
2025-12-24 13:19:45 -05:00
Claude 703339c5e9 v0.9.9.1: Silent upgrade, import number selection, power button fix
- Silent upgrade: extracts app files from script without user input
- Import now shows numbered list, user can select by number
- Improved power button handler with better Electron process detection
- SIGUSR1 now primary method for power menu (more reliable)
- Upgrade automatically regenerates power button handler
- Added XAUTHORITY export for X11 authentication
2025-12-24 18:17:11 +00:00
outis1one 3b35d381f7 Merge pull request #64 from outis1one/claude/fix-kiosk-issues-qlxTc
Claude/fix kiosk issues qlx tc
2025-12-24 09:46:02 -05:00
Claude 2ba980ce94 Add upgrade feature that preserves all settings
- New upgrade_kiosk() function in Core Settings menu (option 10)
- Auto-exports config, timers, and addon configs before reinstall
- Auto-imports everything after reinstall completes
- User just needs to press Enter through installer prompts
- Also fixed SUDO_USER unbound variable issues
2025-12-24 13:36:52 +00:00
Claude da99774c44 Fix SUDO_USER unbound variable in export/import
- Use ${SUDO_USER:-} to avoid unbound variable error with set -u
- Fall back to USER, then /tmp if neither is set
- Validate home_dir exists and is writable
- Use whoami instead of $USER in scp hint
2025-12-24 02:34:52 +00:00
Claude 113adeb119 Fix export/import exit on counter increment with set -e
The script uses 'set -euo pipefail' which causes ((var++)) to exit
when var is 0, since the expression evaluates to 0 (falsey).
Changed to var=$((var + 1)) which always succeeds.
2025-12-24 02:31:13 +00:00
outis1one 0081394796 Merge pull request #63 from outis1one/claude/fix-kiosk-issues-qlxTc
Claude/fix kiosk issues qlx tc
2025-12-23 21:25:14 -05:00
Claude f2f645b7b8 Add VPN configs to settings export/import
Export now includes:
- WireGuard: /etc/wireguard/*.conf files
- Netbird: config.json, state directory (machine keys), user config
- OpenVPN: /etc/openvpn/ directory
- Tailscale: notes installation status (requires re-auth)

Import restores all VPN configs and auto-enables services.
Warns if VPN software not installed after restore.
2025-12-24 02:22:02 +00:00
Claude 2acc8aaa1d Add settings export/import feature for easy backup/restore
- Added export_settings: backs up all config to timestamped .tar.gz
  - Core config (sites, touch controls, passwords)
  - Schedule timers (power, display, quiet hours)
  - Addon configs (Squeezelite, VNC, Easy Asterisk)
- Added import_settings: restores from backup file
  - Lists available backups in home directory
  - Enables timers and sets permissions automatically
- Added to Advanced menu (options 8 and 9)
- Bump version to 0.9.9
2025-12-24 02:16:30 +00:00
outis1one 1a0b5d9f68 Merge pull request #62 from outis1one/claude/fix-kiosk-issues-qlxTc
Fix kiosk power button, pause dialog timeout, and mic static
2025-12-23 21:03:05 -05:00
Claude 5ec37d8457 Fix kiosk power button, pause dialog timeout, and mic static
- Add SIGUSR1 signal handler for power button trigger script fallback
- Add 30-second auto-close timeout to pause dialog popup
- Configure PipeWire noise cancellation/echo suppression for microphone
- Bump version to 0.9.8.1
2025-12-24 01:43:11 +00:00
outis1one 88831e3491 Merge pull request #61 from outis1one/claude/add-asterisk-client-install-EfDNf
Add Easy Asterisk client-only installation option
2025-12-17 01:33:00 -05:00
Claude 20cb5d0a5e Add Easy Asterisk client-only installation option
- Add sub-menu to Easy Asterisk addon with Server/Client/Both options
- Implement Baresip SIP client installation for client-only mode
- Add configuration prompts for server connection details (IP, port, extension, password)
- Create systemd user service for automatic Baresip startup
- Support TLS encryption and auto-answer mode options
- Update status display to show both server and client installation status
2025-12-17 06:19:58 +00:00
outis1one 238018c0ba Delete install_kiosk_v0.9.9.sh 2025-12-10 19:57:03 -05:00
outis1one 9ecfd0f0d5 Merge pull request #59 from outis1one/claude/add-intercom-menu-item-01Gdr4tWRVvDKzdMBG2qekcP
Claude/add intercom menu item 01 gdr4t wr vv d kzd mbg2qekc p
2025-12-10 19:56:16 -05:00
Claude 2048ac7bc8 Fix version regex to support 4-part version numbers (e.g., 0.9.8.7) 2025-12-11 00:53:11 +00:00
Claude 234dd900f6 Integrate Easy Asterisk Intercom into v0.9.8 installer
Added to install_kiosk_v0.9.8.sh:
- SECTION 14.5: Easy Asterisk Intercom addon functions
- get_latest_easy_asterisk_version() - Fetches latest version from GitHub API
- get_installed_easy_asterisk_version() - Checks installed version
- backup_easy_asterisk_configs() - Backs up configs before updates
- restore_easy_asterisk_configs() - Restores configs after install
- download_and_install_easy_asterisk() - Downloads and runs installer
- addon_easy_asterisk_intercom() - Main menu function with update logic

- Added option 4 "Easy Asterisk Intercom" to Addons menu
- Updated show_addon_status() to display Intercom installation status
- Integration uses stable v0.9.8 as base (v0.9.9 was broken)

README updates:
- Fixed all references from v0.9.9 to v0.9.8
- Updated menu paths (2) Addons → (4) Easy Asterisk Intercom
- Corrected version number throughout
- Updated Quick Install section

Features:
- Downloads latest easy-asterisk-v*.sh from GitHub repo
- Automatic version detection and update checking
- Configuration preservation during updates/reruns
- Safe to run multiple times
- User prompts for install/update/rerun decisions
2025-12-10 22:32:22 +00:00
Claude 38e732b976 Remove broken Copilot code that would install wrong Asterisk version
Removed:
- install_easy_asterisk() function that installed Ubuntu Asterisk packages
- configure_easy_asterisk() function for non-existent addon
- Menu option 1 "Install Easy Asterisk" (broken implementation)
- Menu option 5 "Configure Easy Asterisk" (broken implementation)

The removed code would have:
- Installed default Ubuntu Asterisk (apt-get install asterisk)
- Created fake local config files
- Conflicted with actual Easy Asterisk from GitHub repo

Updated:
- Menu renumbered: Intercom is now option 1 (was option 2)
- Configure Intercom is now option 4 (was option 6)
- Updated README to reflect new menu numbers
- Updated error messages with correct option references

Result: Clean implementation with only the working GitHub-based
Easy Asterisk Intercom installer that properly manages versions
and preserves configurations.
2025-12-10 12:52:26 +00:00
outis1one 505e90551f Merge pull request #58 from outis1one/claude/add-intercom-menu-item-01Gdr4tWRVvDKzdMBG2qekcP
Add Easy Asterisk Intercom addon with automatic update management
2025-12-10 07:49:36 -05:00
Claude 62e4a906b9 Add Easy Asterisk Intercom addon with automatic update management
Features:
- Download and install latest Easy Asterisk from GitHub repository
- Automatic version detection using GitHub API
- Smart update checking with user confirmation
- Configuration preservation during updates and reruns
- Backup and restore functionality for configs
- Safe re-run capability without breaking existing setup

Menu changes:
- Added "Install/Update Intercom (Easy Asterisk)" option
- Updated configure_intercom to work with Easy Asterisk installation
- Enhanced configuration interface with file editing support

README updates:
- Added Communication section with Easy Asterisk Intercom
- Documented installation, update, and configuration workflows
- Updated version to 0.9.9
- Added installation locations and management commands

The intercom addon integrates with outis1one/easy-asterisk repository
and follows the pattern of easy-asterisk-v*.sh version files.
2025-12-10 12:21:18 +00:00
outis1one 77eaf19636 Update Readme.md 2025-12-09 23:26:49 -05:00
outis1one 144ff1271a Add install_kiosk_v0.9.9.sh with Easy Asterisk addon integration and intercom option 2025-12-09 16:31:57 -05:00
outis1one d12307b5d2 Add Asterisk-Easy addon installation module 2025-12-09 14:13:34 -05:00
outis1one 0c5e070ed0 Create LICENSE 2025-12-09 11:28:31 -05:00
outis1one c8c8e71acf Update Readme.md 2025-12-09 08:27:08 -05:00
outis1one 85d8396aed Rename install_kiosk_0.9.8.sh to install_kiosk_v0.9.8.sh 2025-12-06 18:39:06 -05:00
outis1one cf612cc7ac Merge pull request #57 from outis1one/claude/fix-power-menu-vpn-01Bfone8eTWK9fQxkvFe8TDk
Fix power menu VPN display and navigation rendering issues
2025-12-04 13:15:28 -05:00
Claude 160c39a47d Fix power menu VPN display and navigation rendering issues
- Fixed power menu showing VPN address twice by excluding VPN interfaces
  (tailscale, wg, netbird, tun, wt) when detecting local IP
- Fixed site bleeding through during navigation by removing all other
  BrowserViews before attaching new one
- Fixed dim navigation popup by forcing reflow and adding fade-in effect
- Added webContents.invalidate() to ensure proper view rendering after switch

These changes improve the visual clarity and reliability of the navigation
system and power menu display.
2025-12-04 18:06:10 +00:00
outis1one c549d0f1e0 Merge pull request #56 from outis1one/claude/fix-install-script-01Ct3jRxEga7ar2MMGzqzam3
Claude/fix install script 01 ct3j rx ega7ar2 mm gzqzam3
2025-12-02 21:36:02 -05:00
Claude 08638045d5 Improve navigation menu UX and button behavior
Fixed three navigation menu issues:
1. Nav button timeout - now auto-hides after 5 seconds like pause/keyboard buttons
2. Better site selection visual feedback - bolder text, white border, lift effect on hover
3. Independent column scrolling - only sites list scrolls, cheat sheet stays fixed

Navigation button changes:
- Added navButtonHideTimer and NAV_BUTTON_HIDE_DELAY (5 seconds)
- Updated showNavButton() to set auto-hide timer
- Updated hideNavButton() to clear timer
- Matches pause button behavior for consistent UX

Site button styling improvements:
- Hover: bold text, bright white border, lifts up 2px, brighter background
- Click: even darker background, pressed down effect
- Default: subtle border and shadow
- Smooth 0.3s transitions for modern feel
- Much more obvious visual feedback when hovering/selecting

Column scroll changes:
- Content overflow changed from auto to hidden
- Sites column: overflow-y auto, padding for scrollbar
- Cheat sheet column: overflow-y hidden (stays fixed)
- Columns container: max-height 70vh
- Only URL list scrolls, gestures/shortcuts remain visible
2025-12-03 01:55:30 +00:00
Claude 27c8adf040 Remove auto-rotation stop from navigation menu
Navigation menu now jumps to selected site but allows rotation to continue.
The pause button remains the only control for stopping rotation.

Changes:
- Removed manualNavigationMode=true from navigate-to-tab handler
- Removed inactivityExtensionUntil=0 (related to manual mode)
- Updated logging to reflect that rotation continues

Users can now use the navigation menu to quickly jump to any site while
keeping rotation active, or use the pause button if they want to stop on
a specific site.
2025-12-02 21:45:59 +00:00
Claude 31f8d2f973 Fix navigation menu tab switching function name
Resolved ReferenceError: showView is not defined when clicking sites in nav menu.

Changes:
- Changed showView() to attachView() - the correct function name
- Added manualNavigationMode=true to stop auto-rotation on manual navigation
- Added markActivity() to reset inactivity timer
- Added inactivityExtensionUntil=0 to clear extensions
- Added additional logging for debugging
- Added error logging for invalid view indices

This makes navigation menu behavior consistent with nextTab() and
previousTab() functions throughout the codebase.
2025-12-02 21:39:08 +00:00
outis1one 7c14a5b407 Merge pull request #55 from outis1one/claude/fix-install-script-01Ct3jRxEga7ar2MMGzqzam3
Claude/fix install script 01 ct3j rx ega7ar2 mm gzqzam3
2025-12-02 16:19:01 -05:00
Claude 5db15802a0 Fix navigation menu config IPC handler error
Resolved ReferenceError: config is not defined in the 'get-config' IPC handler.

Changes:
- Modified IPC handler to read config.json directly using fs.readFileSync
- Added proper error handling with try/catch block
- Added fallback to send empty config if file doesn't exist or can't be read
- Added console logging for debugging config transmission

This fixes the JavaScript error that was displaying on screen and prevents
the navigation menu from loading sites properly.
2025-12-02 20:19:25 +00:00
Claude 08594601ec Fix navigation menu bugs in 0.9.8
Fixed Issues:
1. Icon rendering - Replaced emoji 🔑 with SVG key icon for better compatibility
2. JavaScript errors - Added try/catch blocks throughout navigation menu code
3. Sites not loading - Fixed IPC communication with extensive error logging
4. Menu becoming part of rotation - Ensured proper overlay with z-index and pointer-events
5. Missing auto-dismiss - Added 30-second timeout that auto-closes menu
6. Improved close button positioning - Moved to top-right with better visibility

Technical Changes:
- Changed navButton.innerHTML from emoji to SVG path for key icon
- Added NAV_MENU_TIMEOUT constant (30000ms)
- Added navMenuTimer variable for timeout management
- Enhanced createNavMenu() with console logging and error handling
- Fixed content positioning with 'position:relative'
- Added pointer-events:auto to ensure menu captures events
- Enhanced showNavMenu() with try/catch and 30-second auto-dismiss timer
- Enhanced hideNavMenu() with timer cleanup and error handling
- Enhanced toggleNavMenu() with logging
- Fixed loadSitesIntoNav() with error handling
- Enhanced config-data IPC handler with extensive logging and validation
- Changed siteBtn.innerHTML to siteBtn.textContent to prevent XSS
- Added user-select:none to prevent text selection on buttons
- Added stopPropagation to content to prevent background clicks from closing
- Fixed close button event handler with proper logging

Console Output:
- All navigation menu actions now log to console with [NAV] prefix
- Helps diagnose issues: button clicks, menu show/hide, config requests, site loading
- Error messages clearly identify failure points

This should resolve all reported issues with the navigation menu.
2025-12-02 19:00:30 +00:00
outis1one 40052ea3f0 Update Readme.md 2025-12-02 13:29:29 -05:00
outis1one 89ec3e7871 Merge pull request #54 from outis1one/claude/fix-install-script-01Ct3jRxEga7ar2MMGzqzam3
Claude/fix install script 01 ct3j rx ega7ar2 mm gzqzam3
2025-12-02 13:21:07 -05:00
outis1one a380f8e705 Merge branch 'main' into claude/fix-install-script-01Ct3jRxEga7ar2MMGzqzam3 2025-12-02 13:20:57 -05:00
Claude a6994601c4 Complete version 0.9.8: Named Sites & Navigation Menu
Bug Fixes:
- Fixed virtual console menu display (now checks both getty and X11 DontVTSwitch)
- Fixed complete_uninstall to properly remove LMS/Squeezelite services
- Fixed full_reinstall to clean all addons and settings (except saved VPN/VNC)

Named Websites Feature:
- Added 'name' field to config.json schema for all sites
- Added NAMES array throughout codebase for site name management
- Added update_site_names() function with menu option (Sites → option 3)
- Names prompted during site addition (both new installs and adding sites)
- Site listings now display as "Name" - URL when name is provided
- All management functions (add, update, delete, reorder) handle names properly
- Names fully integrated with save/load config operations

Navigation Menu Feature:
- Added enableNavButton config option (defaults to true)
- Added navigation button at top-left (purple key icon 🔑)
- Button shows on user interaction (same logic as pause/keyboard buttons)
- Navigation menu overlay with 2-column layout:
  * Column 1: Clickable list of all non-hidden sites (uses names if available)
  * Column 2: Touch gesture cheat sheet and keyboard shortcuts reference
- Menu accessible via key icon click
- IPC handlers added: get-config and navigate-to-tab
- Menu excludes hidden sites (duration === -1) as requested
- Optional feature configurable via Optional Buttons menu

README Updates:
- Updated all version references to 0.9.8
- Updated installation commands to use install_kiosk_0.9.8.sh
- Added comprehensive "Why Use Named Sites?" section with use cases:
  * Home/Family kiosks examples
  * Business kiosks examples
  * Digital signage examples
  * Multi-location setups examples
- Updated Multi-Site Management section to include named sites and navigation menu
- Updated Touch Controls section to reflect correct gesture (3-finger DOWN toggle)
- Updated Project Status to version 0.9.8
- Updated menu access commands throughout document

Technical Implementation:
- preload.js: Added nav button/menu variables, create/show/hide functions
- preload.js: Added IPC listener for 'nav-button-enabled'
- preload.js: Integration with user interaction handlers
- main.js: Added enableNavButton variable and config loading
- main.js: Added 'get-config' and 'navigate-to-tab' IPC handlers
- main.js: Send nav-button-enabled state on page load
- Bash script: Added site name prompts in add_new_sites() and add_new_sites_simple()
- Bash script: Added update_site_names() function for updating existing site names
- Bash script: Updated configure_optional_buttons() to include navigation button
- Bash script: Updated all save/load config operations to handle NAMES array

Script now at 9607 lines (242 lines added for navigation menu feature)
All syntax validated with bash -n
2025-12-02 18:16:25 +00:00
Claude 06f0208c0b WIP: Version 0.9.8 - Named Sites & Navigation Menu (partial)
Bug fixes completed:
- Fixed virtual console menu display bug (checks both getty and X11)
- Fixed complete_uninstall to properly remove LMS/Squeezelite
- Fixed full_reinstall to clean all addons and settings

Named websites feature completed:
- Added 'name' field to config.json schema
- Added site name management throughout (add, update, delete, reorder)
- Added update_site_names() menu option
- Names display in site listings

Navigation button configuration completed:
- Added enableNavButton config option
- Added to optional buttons menu
- Integrated with save/load config

TODO: Navigation menu UI implementation in preload.js still needed
2025-12-02 18:09:36 +00:00
outis1one 1988d62f87 Update Readme.md 2025-12-02 12:32:19 -05:00
outis1one 0a4b2bfb70 Merge pull request #53 from outis1one/claude/fix-install-script-01Ct3jRxEga7ar2MMGzqzam3
Release version 0.9.7-5: Maintenance & Polish Update
2025-12-02 11:57:01 -05:00
outis1one 390f286b55 Merge branch 'main' into claude/fix-install-script-01Ct3jRxEga7ar2MMGzqzam3 2025-12-02 11:56:52 -05:00
Claude 2f31a730f6 Release version 0.9.7-5: Maintenance & Polish Update
- Removed PTT (Push-to-Talk) functionality (moved to separate project)
- Fixed power menu to display both local and VPN IP addresses
- Updated Electron to v39.2.4 with enhanced rollback instructions
- Updated README dates to 2025 and version references to 0.9.7-5
- Removed stray text from README printer section
2025-12-02 16:55:09 +00:00
outis1one 6950b6e2bc Merge pull request #52 from outis1one/claude/fix-install-script-01Ct3jRxEga7ar2MMGzqzam3
Fix core menu option 10 (Full reinstall) not working
2025-12-02 10:18:35 -05:00
Claude 95c7af9ac6 Fix core menu option 10 (Full reinstall) not working
Issue: Option 10 was immediately returning to main menu instead of
performing the reinstall.

Root Cause: The menu case statement had 'return' after full_reinstall,
which caused it to exit the core_settings_menu even when the user
cancelled the reinstall (by not typing 'YES').

Fix: Removed the 'return' statement from line 8994.
Now when users cancel, they stay in the core settings menu.
When reinstall completes, the pause() at the end lets them press enter
and naturally return to the menu for additional configuration.

Note: Option 11 (complete_uninstall) correctly keeps 'return' since
uninstalling should exit the entire menu system.
2025-12-02 15:11:21 +00:00
outis1one 737b336d47 Update Readme.md 2025-12-02 08:41:52 -05:00
outis1one c144b3f647 Merge pull request #51 from outis1one/claude/fix-install-script-01Ct3jRxEga7ar2MMGzqzam3
Claude/fix install script 01 ct3j rx ega7ar2 mm gzqzam3
2025-12-02 08:41:13 -05:00
outis1one 5d09a7d27b Merge branch 'main' into claude/fix-install-script-01Ct3jRxEga7ar2MMGzqzam3 2025-12-02 08:41:02 -05:00
Claude 5d63740c05 Release version 0.9.7-4: Gesture & Console Improvements
Major Changes:
1. FIXED: Virtual console Ctrl+Alt+F1-F8 key combinations now work properly
   - Updated configure_virtual_consoles() to modify X11 serverflags
   - Enable/disable now updates both systemd getty AND X11 VT switching
   - Added restart lightdm notification for changes to take effect
   - Fixed initial installation to set X11 VT switching based on user choice
   - Users who previously enabled consoles must re-enable for keys to work

2. IMPROVED: Simplified hidden tab gesture to single toggle
   - 3-finger DOWN now toggles hidden tabs (both show AND hide)
   - Removed separate 3-finger UP gesture (simpler UX)
   - Updated all gesture handlers in both standard and Jitsi modes
   - Updated all console.log messages and documentation

README Updates:
- Updated all version references to 0.9.7-4
- Updated Touch Gesture Quick Reference table
- Simplified gesture: 3-finger DOWN = Toggle hidden tabs
- Updated hidden tabs documentation to reflect toggle behavior
- Added important notes about virtual console fix in 0.9.7-4
- Added instructions to re-enable consoles for existing users
- Updated menu system access examples
- Updated project status to "Gesture & Console Improvements"

This release focuses on fixing the console key combo issue and improving
the hidden tab gesture for easier one-handed use.
2025-12-02 12:48:14 +00:00
Claude 5ceccbb420 Work in progress: Version 0.9.7-4 improvements
Completed Changes:
- Updated version header to 0.9.7-4 with release notes
- FIXED: Virtual console Ctrl+Alt+F1-F8 key combinations now work
  - Updated configure_virtual_consoles() to modify X11 serverflags
  - Enable/disable now updates both systemd getty AND X11 VT switching
  - Added restart lightdm notification for changes to take effect  - Fixed initial installation to set X11 VT switching based on user choice
- FIXED: Swapped hidden tab gestures for easier use
  - 3-finger DOWN now shows hidden tabs (easier than UP)
  - 3-finger UP now returns to normal tabs
  - Updated all gesture handlers and documentation
  - Changed in both standard and Jitsi preload modes

In Progress:
- URL naming feature (requires config schema changes)
- Navigation hot corner menu (substantial new feature)

These changes fix the immediate issues and improve usability.
The navigation menu feature requires extensive additional development.
2025-12-02 12:34:34 +00:00
outis1one dbe881ce44 Update Readme.md 2025-12-01 20:25:55 -05:00
outis1one 1eba5bdf47 Merge pull request #50 from outis1one/claude/fix-install-script-01Ct3jRxEga7ar2MMGzqzam3
Claude/fix install script 01 ct3j rx ega7ar2 mm gzqzam3
2025-12-01 20:24:37 -05:00
outis1one 3387bee3a2 Merge branch 'main' into claude/fix-install-script-01Ct3jRxEga7ar2MMGzqzam3 2025-12-01 20:24:26 -05:00
Claude 5ff748b875 Bump version to 0.9.7-3
Version Changes:
- Created install_kiosk_0.9.7-3.sh with updated version number
- Updated script header with release notes for 0.9.7-3
- Updated SCRIPT_VERSION constant to "0.9.7-3"

README Updates:
- Updated all references from 0.9.7-2 to 0.9.7-3
- Updated Quick Install wget command
- Updated Menu System Access examples
- Updated Complete Uninstall examples
- Updated Project Status section
- Updated footer version

Release Notes for 0.9.7-3:
- Fixed missing complete_uninstall function (line 8704 error)
- Added virtual console configuration (Ctrl+Alt+F1-F8)
- Added emergency hotspot to initial installation
- Enhanced security options and documentation
2025-12-02 01:10:13 +00:00
Claude ed87ff257c Update README with new features and hidden tab use cases
Documentation updates:
- Update Quick Install script name to install_kiosk_0.9.7-2.sh
- Document virtual console configuration feature
- Document emergency hotspot configuration during install
- Document complete uninstall functionality
- Add comprehensive "Why Use Hidden Tabs?" section with:
  - Private communication use cases
  - Administrative access scenarios
  - Content management applications
  - Secure entertainment options
  - Business use cases
  - Real-world example scenarios
- Add new "Installation & Management Features" section with:
  - Virtual Console Configuration details
  - Emergency WiFi Hotspot documentation
  - Complete Uninstall process and safety features
- Update menu system documentation
- Update system behavior security notes
- Update version and date to 0.9.7-2, December 2, 2024
2025-12-02 01:00:23 +00:00
outis1one f5a8c320de Update Readme.md 2025-12-01 19:57:22 -05:00
outis1one a1bfd8aabf Rename install_kiosk_0.9.7-2.sh to install_kiosk_0.9.7-3.sh 2025-12-01 19:57:02 -05:00
outis1one 5e840bafd6 Merge pull request #49 from outis1one/claude/fix-install-script-01Ct3jRxEga7ar2MMGzqzam3
Fix install script issues and add new features
2025-12-01 19:56:09 -05:00
Claude 1cd1565239 Fix install script issues and add new features
- Fix line 8704: Add missing complete_uninstall() function
  - Provides full system cleanup and uninstallation
  - Removes kiosk user, services, and all configurations
  - Offers reboot option after uninstall

- Add virtual console configuration feature
  - New configure_virtual_consoles() function
  - Allows enabling/disabling Ctrl+Alt+F1-F8 console access
  - Added to Advanced menu (option 7)
  - Integrated into initial installation with security prompt

- Add emergency hotspot to initial installation
  - Prompts user at end of installation
  - Can be configured immediately or deferred
  - Provides automatic WiFi hotspot when internet is down
2025-12-02 00:42:39 +00:00
outis1one 7ebf4e6820 Delete setup_intercom_simple.sh 2025-11-26 13:30:03 -05:00
outis1one 6d1c0f9bb5 Delete install_mumble.sh 2025-11-26 13:29:54 -05:00
outis1one a31f1817fc Add files via upload 2025-11-24 00:22:24 -05:00
outis1one 3cfdc1e8a0 Delete talkkonnect_complete_install.sh 2025-11-24 00:21:50 -05:00
outis1one abb8d72771 Update Readme.md 2025-11-24 00:21:10 -05:00
outis1one 3c33c14f24 Update Readme.md 2025-11-24 00:03:57 -05:00
81 changed files with 92459 additions and 1563 deletions
+2
View File
@@ -0,0 +1,2 @@
node_modules/
webui/test/fixtures/.fake-state/
+674
View File
@@ -0,0 +1,674 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.
+876 -58
View File
File diff suppressed because it is too large Load Diff
+314
View File
@@ -0,0 +1,314 @@
#!/bin/bash
################################################################################
# Asterisk-Easy Addon Installation Functions
# Description: Addon module for installing and configuring Asterisk-Easy
# Version: 1.0
################################################################################
# Color definitions for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
################################################################################
# Function: install_asterisk_easy
# Description: Main function to install Asterisk-Easy
# Parameters: None
# Returns: 0 on success, 1 on failure
################################################################################
install_asterisk_easy() {
echo -e "${BLUE}================================${NC}"
echo -e "${BLUE}Asterisk-Easy Installation${NC}"
echo -e "${BLUE}================================${NC}"
# Update system packages
echo -e "${YELLOW}Updating system packages...${NC}"
apt-get update -qq
if [ $? -ne 0 ]; then
echo -e "${RED}Failed to update system packages${NC}"
return 1
fi
# Install Asterisk dependencies
echo -e "${YELLOW}Installing Asterisk dependencies...${NC}"
apt-get install -y \
asterisk \
asterisk-config \
asterisk-dev \
asterisk-doc \
asterisk-modules \
asterisk-voicemail \
dahdi \
libpri \
asterisk-dahdi &>/dev/null
if [ $? -ne 0 ]; then
echo -e "${RED}Failed to install Asterisk packages${NC}"
return 1
fi
# Install additional dependencies
echo -e "${YELLOW}Installing additional dependencies...${NC}"
apt-get install -y \
build-essential \
libssl-dev \
libxml2-dev \
libsqlite3-dev \
uuid-dev &>/dev/null
if [ $? -ne 0 ]; then
echo -e "${RED}Failed to install additional dependencies${NC}"
return 1
fi
# Configure Asterisk
echo -e "${YELLOW}Configuring Asterisk...${NC}"
configure_asterisk_easy
if [ $? -ne 0 ]; then
echo -e "${RED}Failed to configure Asterisk-Easy${NC}"
return 1
fi
# Start Asterisk service
echo -e "${YELLOW}Starting Asterisk service...${NC}"
systemctl enable asterisk
systemctl start asterisk
if [ $? -ne 0 ]; then
echo -e "${RED}Failed to start Asterisk service${NC}"
return 1
fi
echo -e "${GREEN}✓ Asterisk-Easy installation completed successfully${NC}"
return 0
}
################################################################################
# Function: configure_asterisk_easy
# Description: Configure Asterisk-Easy with basic settings
# Parameters: None
# Returns: 0 on success, 1 on failure
################################################################################
configure_asterisk_easy() {
local asterisk_dir="/etc/asterisk"
# Check if Asterisk directory exists
if [ ! -d "$asterisk_dir" ]; then
echo -e "${RED}Asterisk configuration directory not found: $asterisk_dir${NC}"
return 1
fi
# Backup original configuration files
echo -e "${YELLOW}Backing up original Asterisk configuration...${NC}"
if [ -f "$asterisk_dir/extensions.conf" ]; then
cp "$asterisk_dir/extensions.conf" "$asterisk_dir/extensions.conf.bak.$(date +%s)"
fi
if [ -f "$asterisk_dir/sip.conf" ]; then
cp "$asterisk_dir/sip.conf" "$asterisk_dir/sip.conf.bak.$(date +%s)"
fi
# Create basic extensions configuration
echo -e "${YELLOW}Creating basic extensions configuration...${NC}"
create_extensions_conf "$asterisk_dir"
# Create basic SIP configuration
echo -e "${YELLOW}Creating basic SIP configuration...${NC}"
create_sip_conf "$asterisk_dir"
return 0
}
################################################################################
# Function: create_extensions_conf
# Description: Create basic extensions configuration file
# Parameters: $1 - Asterisk configuration directory
# Returns: 0 on success, 1 on failure
################################################################################
create_extensions_conf() {
local asterisk_dir="$1"
local extensions_file="$asterisk_dir/extensions.conf"
cat > "$extensions_file" << 'EOF'
[general]
static=yes
writeprotect=no
[default]
exten => 100,1,Dial(SIP/100)
exten => 100,n,Voicemail(100@default)
exten => 100,n,Hangup()
exten => 101,1,Dial(SIP/101)
exten => 101,n,Voicemail(101@default)
exten => 101,n,Hangup()
exten => 102,1,Dial(SIP/102)
exten => 102,n,Voicemail(102@default)
exten => 102,n,Hangup()
exten => 200,1,VoicemailMain(${CALLERID(num)}@default)
exten => 200,n,Hangup()
EOF
if [ $? -ne 0 ]; then
echo -e "${RED}Failed to create extensions configuration${NC}"
return 1
fi
return 0
}
################################################################################
# Function: create_sip_conf
# Description: Create basic SIP configuration file
# Parameters: $1 - Asterisk configuration directory
# Returns: 0 on success, 1 on failure
################################################################################
create_sip_conf() {
local asterisk_dir="$1"
local sip_file="$asterisk_dir/sip.conf"
cat > "$sip_file" << 'EOF'
[general]
context=default
allowoverlap=no
bindport=5060
bindaddr=0.0.0.0
srvlookup=yes
pedantic=no
maxexpiry=3600
minexpiry=60
defaultexpiry=120
defaultexpirey=120
rtptimeout=30
rtpholdtimeout=300
videosupport=yes
allowtransfer=yes
nat=force_rport,comedia
[100]
type=friend
secret=123456
host=dynamic
context=default
[101]
type=friend
secret=123456
host=dynamic
context=default
[102]
type=friend
secret=123456
host=dynamic
context=default
EOF
if [ $? -ne 0 ]; then
echo -e "${RED}Failed to create SIP configuration${NC}"
return 1
fi
return 0
}
################################################################################
# Function: verify_asterisk_easy
# Description: Verify Asterisk-Easy installation
# Parameters: None
# Returns: 0 if installed and running, 1 otherwise
################################################################################
verify_asterisk_easy() {
# Check if Asterisk is installed
if ! command -v asterisk &> /dev/null; then
echo -e "${RED}Asterisk is not installed${NC}"
return 1
fi
# Check if Asterisk service is running
if ! systemctl is-active --quiet asterisk; then
echo -e "${YELLOW}Asterisk service is not running${NC}"
return 1
fi
echo -e "${GREEN}✓ Asterisk-Easy is properly installed and running${NC}"
return 0
}
################################################################################
# Function: show_asterisk_easy_status
# Description: Display Asterisk-Easy status information
# Parameters: None
# Returns: 0 on success
################################################################################
show_asterisk_easy_status() {
echo -e "${BLUE}================================${NC}"
echo -e "${BLUE}Asterisk-Easy Status${NC}"
echo -e "${BLUE}================================${NC}"
# Check service status
if systemctl is-active --quiet asterisk; then
echo -e "${GREEN}Service Status: Running${NC}"
else
echo -e "${RED}Service Status: Not Running${NC}"
fi
# Check Asterisk version
if command -v asterisk &> /dev/null; then
local version=$(asterisk -v | grep -oP 'Asterisk \K[0-9.]+')
echo -e "${BLUE}Asterisk Version: $version${NC}"
fi
# Check configuration files
if [ -f "/etc/asterisk/extensions.conf" ]; then
echo -e "${GREEN}✓ Extensions Configuration: Present${NC}"
else
echo -e "${RED}✗ Extensions Configuration: Missing${NC}"
fi
if [ -f "/etc/asterisk/sip.conf" ]; then
echo -e "${GREEN}✓ SIP Configuration: Present${NC}"
else
echo -e "${RED}✗ SIP Configuration: Missing${NC}"
fi
return 0
}
################################################################################
# Function: remove_asterisk_easy
# Description: Remove Asterisk-Easy installation
# Parameters: None
# Returns: 0 on success, 1 on failure
################################################################################
remove_asterisk_easy() {
echo -e "${YELLOW}Removing Asterisk-Easy...${NC}"
# Stop Asterisk service
systemctl stop asterisk
systemctl disable asterisk
# Remove Asterisk packages
apt-get remove -y asterisk asterisk-* &>/dev/null
if [ $? -ne 0 ]; then
echo -e "${RED}Failed to remove Asterisk packages${NC}"
return 1
fi
echo -e "${GREEN}✓ Asterisk-Easy has been removed${NC}"
return 0
}
# Export functions for use in main script
export -f install_asterisk_easy
export -f configure_asterisk_easy
export -f verify_asterisk_easy
export -f show_asterisk_easy_status
export -f remove_asterisk_easy
Executable
+228
View File
@@ -0,0 +1,228 @@
#!/bin/bash
################################################################################
# install.sh - Ubuntu Based Kiosk: install and manage, one entry point.
#
# 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).
# 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),
# 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), Clone Settings
# (menus/clone_settings.sh - export/apply portable settings across
# several kiosks; deliberately excludes machine-bound credentials
# like Authelia/WireGuard/Asterisk Intercom - see the file header),
# Upgrade (menus/advanced_upgrade.sh - git pull + re-provision, plus
# an on-demand Electron version check).
#
# Usage (works whether or not a kiosk is already installed):
# git clone <repo>
# cd ubuntu-based-kiosk
# ./install.sh
################################################################################
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib/menu.sh
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
source "$SCRIPT_DIR/menus/display.sh"
# shellcheck source=menus/timezone.sh
source "$SCRIPT_DIR/menus/timezone.sh"
# shellcheck source=menus/hidden_pin.sh
source "$SCRIPT_DIR/menus/hidden_pin.sh"
# shellcheck source=menus/lockout.sh
source "$SCRIPT_DIR/menus/lockout.sh"
# shellcheck source=menus/wifi.sh
source "$SCRIPT_DIR/menus/wifi.sh"
# shellcheck source=menus/power_schedule.sh
source "$SCRIPT_DIR/menus/power_schedule.sh"
# shellcheck source=menus/diagnostics.sh
source "$SCRIPT_DIR/menus/diagnostics.sh"
# shellcheck source=menus/addon_cups.sh
source "$SCRIPT_DIR/menus/addon_cups.sh"
# shellcheck source=menus/addon_authelia.sh
source "$SCRIPT_DIR/menus/addon_authelia.sh"
# shellcheck source=menus/addon_remote_access.sh
source "$SCRIPT_DIR/menus/addon_remote_access.sh"
# shellcheck source=menus/addon_lms_squeezelite.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
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 among menus/*.sh: composes the *_do_uninstall/
# *_do_remove_all/*_do_disable helpers defined in every file above it.
source "$SCRIPT_DIR/menus/complete_uninstall.sh"
# shellcheck source=menus/clone_settings.sh
# Also composes detection helpers (*_is_installed) from every addon above.
source "$SCRIPT_DIR/menus/clone_settings.sh"
# shellcheck source=lib/provision.sh
# Sourced last of all: calls into core_settings_menu and the Advanced
# actions below during first-time setup, so everything they depend on
# must already be defined by the time it actually runs (not merely
# sourced - bash resolves function calls at run time either way).
source "$SCRIPT_DIR/lib/provision.sh"
################################################################################
# Preflight
################################################################################
if [[ "$(whoami)" == "kiosk" ]]; then
log_error "Cannot run as user 'kiosk'"
exit 1
fi
if [[ $EUID -eq 0 ]]; then
log_error "Run as a regular user with sudo privileges, not as root"
exit 1
fi
if ! command -v jq &>/dev/null; then
log_error "jq is required but not installed. Run: sudo apt-get install -y jq"
exit 1
fi
################################################################################
# Top-level menu - grouped the same way the legacy menu groups them
# (Core Settings / Addons / Advanced), so the structure stays familiar
# and the flat list doesn't grow unwieldy as more menus migrate in.
################################################################################
core_settings_menu_builder() {
MENU_LABELS=(
"Sites & Page Timing"
"Display & Interaction"
"Timezone"
"Hidden Site PIN"
"Password Protection & Lockout"
"WiFi"
"Power/Display/Quiet Hours"
"Complete Uninstall"
)
MENU_HANDLERS=(
sites_menu
display_menu
timezone_menu
hidden_pin_menu
lockout_menu
wifi_menu
power_schedule_menu
complete_uninstall_menu
)
}
core_settings_menu() {
run_menu "CORE SETTINGS" core_settings_menu_builder
}
addons_menu_builder() {
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() {
run_menu "ADDONS" addons_menu_builder
}
advanced_menu_builder() {
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() {
run_menu "ADVANCED" advanced_menu_builder
}
main_menu_builder() {
MENU_LABELS=("Core Settings" "Addons" "Advanced")
MENU_HANDLERS=(core_settings_menu addons_menu advanced_menu)
}
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"
@@ -1,13 +1,17 @@
#!/bin/bash
################################################################################
### Ubuntu Based Kiosk (UBK) v0.9.7 ###
### Ubuntu Based Kiosk v0.9.7-3 ###
################################################################################
#
# RELEASE v0.9.7 - phase one complete
# RELEASE v0.9.7-3 - Install improvements & management features
# - Fixed missing complete_uninstall function
# - Added virtual console configuration (Ctrl+Alt+F1-F8)
# - Added emergency hotspot to initial installation
# - Enhanced security options and documentation
#
# Built with Claude Sonnet 4/.5 AI assistance
# License: GPL v3 - Keep derivatives open sour
# Repository: https://github.com/outis1one/ubk/
# License: GPL v3 - Keep derivatives open source
# Repository: https://github.com/outis1one/ubuntu-based-kiosk/
#
# TARGET SYSTEMS:
# - Ubuntu 24.04+ Server (minimal install recommended)
@@ -35,7 +39,7 @@ set -euo pipefail
### SECTION 1: CONSTANTS & GLOBALS
################################################################################
SCRIPT_VERSION="0.9.7"
SCRIPT_VERSION="0.9.7-3"
KIOSK_USER="kiosk"
BUILD_USER="${SUDO_USER:-$(whoami)}"
KIOSK_HOME="/home/${KIOSK_USER}"
@@ -2146,7 +2150,75 @@ disable_emergency_hotspot() {
pause
}
################################################################################
### VIRTUAL CONSOLE CONFIGURATION
################################################################################
configure_virtual_consoles() {
clear
echo "══════════════════════════════════════════════════════════════"
echo " VIRTUAL CONSOLE CONFIGURATION "
echo "══════════════════════════════════════════════════════════════"
echo
echo "Virtual consoles (Ctrl+Alt+F1 through F8) allow manual login"
echo "to a terminal for troubleshooting and system maintenance."
echo
echo "Current status:"
# Check if virtual consoles are enabled
local consoles_disabled=false
if systemctl is-masked getty@tty1.service >/dev/null 2>&1; then
consoles_disabled=true
echo " Virtual consoles: ✗ DISABLED"
else
consoles_disabled=false
echo " Virtual consoles: ✓ ENABLED"
fi
echo
echo "Options:"
echo " 1. Enable virtual consoles (Ctrl+Alt+F1-F8 for manual login)"
echo " 2. Disable virtual consoles (more secure, kiosk only)"
echo " 0. Cancel"
echo
read -r -p "Choose [0-2]: " choice
case "$choice" in
1)
echo
echo "Enabling virtual consoles..."
for i in {1..8}; do
sudo systemctl unmask getty@tty$i.service 2>/dev/null || true
done
sudo systemctl daemon-reload
log_success "Virtual consoles enabled"
echo
echo "You can now access virtual consoles with:"
echo " Ctrl+Alt+F1 through Ctrl+Alt+F8"
echo " (Ctrl+Alt+F7 typically returns to the kiosk)"
pause
;;
2)
echo
read -r -p "Disable all virtual consoles? (y/n): " confirm
if [[ "$confirm" =~ ^[Yy]$ ]]; then
echo "Disabling virtual consoles..."
for i in {1..8}; do
sudo systemctl mask getty@tty$i.service 2>/dev/null || true
done
sudo systemctl daemon-reload
log_success "Virtual consoles disabled"
echo
echo "Virtual console access has been disabled for security."
echo "You can re-enable them from the Tools menu if needed."
pause
fi
;;
0)
return
;;
esac
}
################################################################################
### SECTION 5: POWER/DISPLAY/QUIET SCHEDULES
@@ -3475,6 +3547,124 @@ full_reinstall() {
echo ""
pause
}
complete_uninstall() {
echo ""
echo "══════════════════════════════════════════════════════════════"
echo " COMPLETE UNINSTALL"
echo "══════════════════════════════════════════════════════════════"
echo ""
echo "⚠️ This will COMPLETELY REMOVE:"
echo " • Kiosk user and all data"
echo " • All kiosk configuration and sites"
echo " • All Electron/Node.js installations"
echo " • All browser caches and data"
echo " • CUPS printer system"
echo " • LightDM and Openbox"
echo " • All kiosk schedules and services"
echo " • Emergency hotspot configuration"
echo ""
echo "⚠️ This CANNOT be undone!"
echo ""
read -p "Are you ABSOLUTELY SURE? (type UNINSTALL): " CONFIRM
if [ "$CONFIRM" != "UNINSTALL" ]; then
echo "Cancelled."
return
fi
echo ""
echo "Beginning complete uninstall..."
# Stop all services
echo "[1/12] Stopping all kiosk services..."
sudo systemctl stop lightdm 2>/dev/null || true
sudo systemctl stop kiosk-emergency-hotspot.service 2>/dev/null || true
sudo systemctl stop kiosk-shutdown.timer 2>/dev/null || true
sudo systemctl stop kiosk-display-on.timer 2>/dev/null || true
sudo systemctl stop kiosk-display-off.timer 2>/dev/null || true
sudo systemctl stop kiosk-quiet-start.timer 2>/dev/null || true
sudo systemctl stop kiosk-quiet-end.timer 2>/dev/null || true
sudo systemctl stop kiosk-electron-reload.timer 2>/dev/null || true
sudo systemctl stop x11vnc 2>/dev/null || true
# Remove kiosk user
echo "[2/12] Removing kiosk user..."
if id "$KIOSK_USER" &>/dev/null; then
sudo pkill -u "$KIOSK_USER" 2>/dev/null || true
sudo userdel -r "$KIOSK_USER" 2>/dev/null || true
log_success "Kiosk user removed"
fi
# Remove kiosk files
echo "[3/12] Removing kiosk files..."
sudo rm -rf "$KIOSK_DIR"
sudo rm -rf /home/$KIOSK_USER
# Remove systemd services and timers
echo "[4/12] Removing systemd services..."
sudo rm -f /etc/systemd/system/kiosk-*.service
sudo rm -f /etc/systemd/system/kiosk-*.timer
sudo rm -f /etc/systemd/system/x11vnc.service
sudo systemctl daemon-reload
# Remove scripts
echo "[5/12] Removing scripts..."
sudo rm -f /usr/local/bin/kiosk-*
sudo rm -f /usr/local/bin/rtc-wake.sh
# Remove CUPS
echo "[6/12] Removing CUPS..."
sudo systemctl stop cups 2>/dev/null || true
sudo systemctl disable cups 2>/dev/null || true
sudo apt-get purge -y cups cups-client cups-common 2>/dev/null || true
# Remove Node.js and Electron
echo "[7/12] Removing Node.js..."
sudo apt-get purge -y nodejs npm 2>/dev/null || true
sudo rm -rf /usr/local/lib/node_modules
sudo rm -rf /usr/local/bin/node
sudo rm -rf /usr/local/bin/npm
# Remove LightDM and Openbox
echo "[8/12] Removing LightDM and Openbox..."
sudo systemctl disable lightdm 2>/dev/null || true
sudo apt-get purge -y lightdm openbox 2>/dev/null || true
# Remove VNC
echo "[9/12] Removing VNC..."
sudo systemctl stop x11vnc 2>/dev/null || true
sudo systemctl disable x11vnc 2>/dev/null || true
sudo apt-get purge -y x11vnc 2>/dev/null || true
# Remove polkit rules
echo "[10/12] Removing polkit rules..."
sudo rm -f /etc/polkit-1/localauthority/50-local.d/kiosk-power.pkla
# System cleanup
echo "[11/12] Cleaning up packages..."
sudo apt-get autoremove -y 2>/dev/null || true
sudo apt-get autoclean 2>/dev/null || true
# Re-enable virtual consoles if they were disabled
echo "[12/12] Re-enabling virtual consoles..."
for i in {1..6}; do
sudo systemctl unmask getty@tty$i.service 2>/dev/null || true
done
echo ""
echo "✓✓✓ KIOSK COMPLETELY UNINSTALLED ✓✓✓"
echo ""
echo "The system has been returned to its pre-kiosk state."
echo "You may want to reboot to ensure all changes take effect."
echo ""
read -r -p "Reboot now? (y/n): " do_reboot
if [[ "$do_reboot" =~ ^[Yy]$ ]]; then
echo "Rebooting..."
sleep 3
sudo reboot
fi
}
################################################################################
### SECTION 8: FIRST TIME INSTALLATION
################################################################################
@@ -6575,6 +6765,52 @@ fi
echo "[26/27] Finalizing installation..."
log_success "Core installation complete!"
echo
# Optional: Configure emergency hotspot
echo
echo "══════════════════════════════════════════════════════════════"
echo " OPTIONAL: Emergency Hotspot "
echo "══════════════════════════════════════════════════════════════"
echo
echo "The emergency hotspot automatically activates when there's no"
echo "internet connection, allowing you to connect and troubleshoot."
echo
read -r -p "Configure emergency hotspot now? (y/n): " setup_hotspot
if [[ "$setup_hotspot" =~ ^[Yy]$ ]]; then
install_emergency_hotspot
else
log_info "Emergency hotspot can be configured later from Advanced menu"
fi
# Optional: Configure virtual consoles
echo
echo "══════════════════════════════════════════════════════════════"
echo " OPTIONAL: Virtual Console Access "
echo "══════════════════════════════════════════════════════════════"
echo
echo "Virtual consoles (Ctrl+Alt+F1-F8) allow manual terminal login"
echo "for troubleshooting. This is useful but less secure."
echo
echo "Current status: ENABLED (default)"
echo
read -r -p "Keep virtual consoles enabled? (y/n): " keep_consoles
if [[ ! "$keep_consoles" =~ ^[Yy]$ ]]; then
echo
echo "Disabling virtual consoles for security..."
for i in {1..8}; do
sudo systemctl mask getty@tty$i.service 2>/dev/null || true
done
sudo systemctl daemon-reload
log_success "Virtual consoles disabled"
echo
echo "You can re-enable them later from Advanced menu (option 7)"
else
log_info "Virtual consoles remain enabled (Ctrl+Alt+F1-F8)"
fi
echo
echo "[27/27] Installation complete!"
echo
echo "Next steps:"
echo " • Rerun this script to configure addons"
echo " • Reboot to start the kiosk"
@@ -8957,12 +9193,13 @@ advanced_menu() {
echo " 4. Audio Diagnostics"
echo " 5. Fix Squeezelite Audio"
echo " 6. Factory Reset Config"
echo " 7. Virtual Consoles (Ctrl+Alt+F1-F8)"
echo " 9. Emergency Hotspot"
echo " 10. Network Test"
echo " 0. Return"
echo
read -r -p "Choose [0-10]: " choice
case "$choice" in
1) manual_electron_update ;;
2) system_diagnostics ;;
@@ -8970,6 +9207,7 @@ advanced_menu() {
4) audio_diagnostics ;;
5) fix_squeezelite_audio ;;
6) factory_reset ;;
7) configure_virtual_consoles ;;
9) configure_emergency_hotspot ;;
10) network_test ;;
0) return ;;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+137
View File
@@ -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>
+261
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+8
View File
@@ -0,0 +1,8 @@
{
"name": "kiosk-app",
"version": "1.0.0",
"main": "main.js",
"dependencies": {
"electron": "^42.0.0"
}
}
+119
View File
@@ -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>
+139
View File
@@ -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>
+931
View File
@@ -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});
});
});
+30
View File
@@ -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
+273
View File
@@ -0,0 +1,273 @@
#!/bin/bash
################################################################################
# lib/config.sh - config.json read/write for the kiosk Electron app.
#
# Every menu that touches sites/timing/settings works against the same
# in-memory bash arrays (URLS, DURS, USERS, PASSES, NAMES, ...) and the same
# two functions below. Load once when a menu opens, save after each change.
#
# IMPORTANT: save_config() rewrites config.json from these globals in full.
# Any menu that calls save_config() MUST call load_existing_config() first
# (even if it only touches sites), otherwise settings it doesn't know about
# (swipe mode, navigation security, lockout, etc) get silently reset to
# script defaults. This bit the old single-file Sites menu, which read
# tabs directly via jq but never loaded the rest of the settings - fixed
# here by making load_existing_config() the one canonical loader.
################################################################################
: "${KIOSK_USER:=kiosk}"
: "${KIOSK_HOME:=/home/${KIOSK_USER}}"
: "${KIOSK_DIR:=${KIOSK_HOME}/kiosk-app}"
: "${CONFIG_PATH:=${KIOSK_DIR}/config.json}"
# System paths that menus (e.g. power/display/quiet-hours scheduling)
# write units, scripts, and cron entries into. Overridable so tests can
# point them at a scratch directory instead of the real system - nothing
# under menus/ should ever hardcode /etc/systemd/system, /etc/cron.d, or
# /usr/local/bin directly.
: "${SYSTEMD_DIR:=/etc/systemd/system}"
: "${CRON_D_DIR:=/etc/cron.d}"
: "${BIN_DIR:=/usr/local/bin}"
: "${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
# *this* user a group membership (e.g. lpadmin for CUPS).
: "${BUILD_USER:=${SUDO_USER:-$(whoami)}}"
# Site/tab arrays
declare -a URLS=()
declare -a DURS=()
declare -a USERS=()
declare -a PASSES=()
declare -a NAMES=()
# Other top-level config.json settings we must round-trip even though the
# Sites menu doesn't edit most of them.
AUTOSWITCH="true"
SWIPE_MODE="dual"
ALLOW_NAVIGATION="same-origin"
HOME_TAB_INDEX=-1
INACTIVITY_TIMEOUT=120
ENABLE_PAUSE_BUTTON="true"
ENABLE_KEYBOARD_BUTTON="true"
ENABLE_NAV_BUTTON="true"
ENABLE_PASSWORD_PROTECTION="false"
LOCKOUT_PASSWORD=""
LOCKOUT_TIMEOUT=0
LOCKOUT_AT_TIME=""
LOCKOUT_ACTIVE_START=""
LOCKOUT_ACTIVE_END=""
REQUIRE_PASSWORD_ON_BOOT="false"
AUTHELIA_URL=""
AUTHELIA_USERNAME=""
AUTHELIA_ENCRYPTED_PASSWORD=""
kiosk_user_exists() {
id "$KIOSK_USER" &>/dev/null
}
is_kiosk_installed() {
kiosk_user_exists && sudo -u "$KIOSK_USER" test -f "$KIOSK_DIR/main.js" 2>/dev/null
}
is_service_active() {
local service="$1"
systemctl is-active --quiet "$service" 2>/dev/null
}
# Whether a service is enabled (would start on boot), regardless of
# whether it's currently running. The legacy script's version of this
# pre-checked `systemctl list-unit-files | grep -q "^${service}\s"`
# before calling is-enabled - but every call site passes a bare service
# name (e.g. "squeezelite"), while list-unit-files lines start with
# "squeezelite.service", so that regex never matched and the legacy
# function always fell through to `return 1` no matter the real state.
# `systemctl is-enabled` already reports "not found" as a failure on its
# own, so the pre-check was both broken and unnecessary - dropped here.
is_service_enabled() {
local service="$1"
systemctl is-enabled --quiet "$service" 2>/dev/null
}
# Load every setting config.json has into the bash globals above.
# Safe to call with no existing config file - leaves script defaults in place.
load_existing_config() {
if ! sudo -u "$KIOSK_USER" test -f "$CONFIG_PATH" 2>/dev/null; then
return 0
fi
URLS=()
DURS=()
USERS=()
PASSES=()
NAMES=()
local tab_count
tab_count=$(sudo -u "$KIOSK_USER" jq -r '.tabs | length' "$CONFIG_PATH" 2>/dev/null || echo "0")
if [[ "$tab_count" -gt 0 ]]; then
for ((i = 0; i < tab_count; i++)); do
URLS+=("$(sudo -u "$KIOSK_USER" jq -r ".tabs[$i].url" "$CONFIG_PATH")")
DURS+=("$(sudo -u "$KIOSK_USER" jq -r ".tabs[$i].duration" "$CONFIG_PATH")")
USERS+=("$(sudo -u "$KIOSK_USER" jq -r ".tabs[$i].username // empty" "$CONFIG_PATH")")
PASSES+=("$(sudo -u "$KIOSK_USER" jq -r ".tabs[$i].password // empty" "$CONFIG_PATH")")
NAMES+=("$(sudo -u "$KIOSK_USER" jq -r ".tabs[$i].name // empty" "$CONFIG_PATH")")
done
fi
HOME_TAB_INDEX=$(sudo -u "$KIOSK_USER" jq -r '.homeTabIndex // -1' "$CONFIG_PATH" 2>/dev/null || echo "-1")
INACTIVITY_TIMEOUT=$(sudo -u "$KIOSK_USER" jq -r '.inactivityTimeout // 120' "$CONFIG_PATH" 2>/dev/null || echo "120")
ALLOW_NAVIGATION=$(sudo -u "$KIOSK_USER" jq -r '.allowNavigation // "same-origin"' "$CONFIG_PATH" 2>/dev/null || echo "same-origin")
SWIPE_MODE=$(sudo -u "$KIOSK_USER" jq -r '.swipeMode // "dual"' "$CONFIG_PATH" 2>/dev/null || echo "dual")
local pause_btn keyboard_btn nav_btn password_enabled boot_password
pause_btn=$(sudo -u "$KIOSK_USER" jq -r '.enablePauseButton // true' "$CONFIG_PATH" 2>/dev/null)
[[ "$pause_btn" == "true" ]] && ENABLE_PAUSE_BUTTON="true" || ENABLE_PAUSE_BUTTON="false"
keyboard_btn=$(sudo -u "$KIOSK_USER" jq -r '.enableKeyboardButton // true' "$CONFIG_PATH" 2>/dev/null)
[[ "$keyboard_btn" == "true" ]] && ENABLE_KEYBOARD_BUTTON="true" || ENABLE_KEYBOARD_BUTTON="false"
nav_btn=$(sudo -u "$KIOSK_USER" jq -r '.enableNavButton // true' "$CONFIG_PATH" 2>/dev/null)
[[ "$nav_btn" == "true" ]] && ENABLE_NAV_BUTTON="true" || ENABLE_NAV_BUTTON="false"
password_enabled=$(sudo -u "$KIOSK_USER" jq -r '.enablePasswordProtection // false' "$CONFIG_PATH" 2>/dev/null)
[[ "$password_enabled" == "true" ]] && ENABLE_PASSWORD_PROTECTION="true" || ENABLE_PASSWORD_PROTECTION="false"
LOCKOUT_PASSWORD=$(sudo -u "$KIOSK_USER" jq -r '.lockoutPassword // ""' "$CONFIG_PATH" 2>/dev/null || echo "")
LOCKOUT_TIMEOUT=$(sudo -u "$KIOSK_USER" jq -r '.lockoutTimeout // 0' "$CONFIG_PATH" 2>/dev/null || echo "0")
LOCKOUT_AT_TIME=$(sudo -u "$KIOSK_USER" jq -r '.lockoutAtTime // ""' "$CONFIG_PATH" 2>/dev/null || echo "")
LOCKOUT_ACTIVE_START=$(sudo -u "$KIOSK_USER" jq -r '.lockoutActiveStart // ""' "$CONFIG_PATH" 2>/dev/null || echo "")
LOCKOUT_ACTIVE_END=$(sudo -u "$KIOSK_USER" jq -r '.lockoutActiveEnd // ""' "$CONFIG_PATH" 2>/dev/null || echo "")
boot_password=$(sudo -u "$KIOSK_USER" jq -r '.requirePasswordOnBoot // false' "$CONFIG_PATH" 2>/dev/null)
[[ "$boot_password" == "true" ]] && REQUIRE_PASSWORD_ON_BOOT="true" || REQUIRE_PASSWORD_ON_BOOT="false"
AUTHELIA_URL=$(sudo -u "$KIOSK_USER" jq -r '.autheliaURL // ""' "$CONFIG_PATH" 2>/dev/null || echo "")
AUTHELIA_USERNAME=$(sudo -u "$KIOSK_USER" jq -r '.autheliaUsername // ""' "$CONFIG_PATH" 2>/dev/null || echo "")
AUTHELIA_ENCRYPTED_PASSWORD=$(sudo -u "$KIOSK_USER" jq -r '.autheliaEncryptedPassword // ""' "$CONFIG_PATH" 2>/dev/null || echo "")
}
# Write every bash global back out to config.json, then offer to reload the
# kiosk display so the change takes effect immediately.
save_config() {
if ! kiosk_user_exists; then
log_error "Kiosk user doesn't exist - run the full installer first"
return 1
fi
sudo mkdir -p "$KIOSK_DIR"
sudo chown -R "$KIOSK_USER:$KIOSK_USER" "$KIOSK_DIR"
local tmp
tmp=$(mktemp)
local dual_json="false"
[[ "$SWIPE_MODE" == "dual" ]] && dual_json="true"
local pause_btn_json="true"
[[ "$ENABLE_PAUSE_BUTTON" == "false" ]] && pause_btn_json="false"
local keyboard_btn_json="true"
[[ "$ENABLE_KEYBOARD_BUTTON" == "false" ]] && keyboard_btn_json="false"
local nav_btn_json="true"
[[ "$ENABLE_NAV_BUTTON" == "false" ]] && nav_btn_json="false"
local password_json="false"
[[ "$ENABLE_PASSWORD_PROTECTION" == "true" ]] && password_json="true"
local boot_password_json="false"
[[ "$REQUIRE_PASSWORD_ON_BOOT" == "true" ]] && boot_password_json="true"
# Merge onto whatever's already in config.json rather than rebuilding
# the file from nothing. The legacy save_config did a full `jq -n`
# rebuild listing every known field - any field it doesn't know about
# (e.g. Authelia's autheliaURL/autheliaUsername/
# autheliaEncryptedPassword, written by its own careful `. + {...}`
# merge) gets silently DELETED the next time any other menu that
# calls save_config runs. Real, currently-shipping bug in the legacy
# script, not unique to this migration - ported faithfully into this
# file's first version because no test happened to set an untracked
# field first. `. + {known fields...}` below preserves anything this
# tool doesn't track while still fully replacing every field it does
# (including tabs, via the same array-rebuild loop as before) - jq's
# `+` on objects takes the right-hand value for any key present on
# both sides, so a fully-specified `tabs` here still discards a
# deleted tab rather than merging old and new.
local existing="{}"
if sudo -u "$KIOSK_USER" test -f "$CONFIG_PATH" 2>/dev/null; then
existing=$(sudo -u "$KIOSK_USER" cat "$CONFIG_PATH" 2>/dev/null)
echo "$existing" | jq empty 2>/dev/null || existing="{}"
fi
echo "$existing" | jq \
--argjson autoswitch true \
--argjson enableTouch true \
--argjson dualSwipe "$dual_json" \
--arg swipeMode "$SWIPE_MODE" \
--arg allowNavigation "$ALLOW_NAVIGATION" \
--argjson homeTabIndex "${HOME_TAB_INDEX:--1}" \
--argjson inactivityTimeout "${INACTIVITY_TIMEOUT:-120}" \
--argjson enablePauseButton "$pause_btn_json" \
--argjson enableKeyboardButton "$keyboard_btn_json" \
--argjson enableNavButton "$nav_btn_json" \
--argjson enablePasswordProtection "$password_json" \
--arg lockoutPassword "${LOCKOUT_PASSWORD:-}" \
--argjson lockoutTimeout "${LOCKOUT_TIMEOUT:-0}" \
--arg lockoutAtTime "${LOCKOUT_AT_TIME:-}" \
--arg lockoutActiveStart "${LOCKOUT_ACTIVE_START:-}" \
--arg lockoutActiveEnd "${LOCKOUT_ACTIVE_END:-}" \
--argjson requirePasswordOnBoot "$boot_password_json" \
--arg autheliaURL "${AUTHELIA_URL:-}" \
--arg autheliaUsername "${AUTHELIA_USERNAME:-}" \
--arg autheliaEncryptedPassword "${AUTHELIA_ENCRYPTED_PASSWORD:-}" \
'. + {autoswitch:$autoswitch,enableTouch:$enableTouch,dualSwipe:$dualSwipe,swipeMode:$swipeMode,allowNavigation:$allowNavigation,homeTabIndex:$homeTabIndex,inactivityTimeout:$inactivityTimeout,enablePauseButton:$enablePauseButton,enableKeyboardButton:$enableKeyboardButton,enableNavButton:$enableNavButton,enablePasswordProtection:$enablePasswordProtection,lockoutPassword:$lockoutPassword,lockoutTimeout:$lockoutTimeout,lockoutAtTime:$lockoutAtTime,lockoutActiveStart:$lockoutActiveStart,lockoutActiveEnd:$lockoutActiveEnd,requirePasswordOnBoot:$requirePasswordOnBoot,autheliaURL:$autheliaURL,autheliaUsername:$autheliaUsername,autheliaEncryptedPassword:$autheliaEncryptedPassword,tabs:[]}' > "$tmp"
if [[ ${#URLS[@]} -gt 0 ]]; then
for idx in "${!URLS[@]}"; do
local url="${URLS[$idx]:-}"
local dur="${DURS[$idx]:-0}"
local user="${USERS[$idx]:-}"
local pass="${PASSES[$idx]:-}"
local name="${NAMES[$idx]:-}"
jq --arg u "$url" \
--argjson d "$dur" \
--arg user "$user" \
--arg pass "$pass" \
--arg name "$name" \
'.tabs += [{"url":$u,"duration":$d,"username":$user,"password":$pass,"name":$name}]' \
"$tmp" > "${tmp}.new"
mv -f "${tmp}.new" "$tmp"
done
fi
sudo -u "$KIOSK_USER" bash -c "cat > '$CONFIG_PATH'" < "$tmp"
sudo -u "$KIOSK_USER" chmod 644 "$CONFIG_PATH"
rm -f "$tmp"
log_success "Configuration saved"
if is_service_active lightdm; then
echo
if ask_yes_no "Reload kiosk now to apply changes?" "y"; then
echo "Reloading kiosk..."
sudo systemctl restart lightdm
sleep 2
log_success "Kiosk reloaded"
else
log_warning "Remember to reload: sudo systemctl restart lightdm"
fi
fi
}
+58
View File
@@ -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
}
+340
View File
@@ -0,0 +1,340 @@
#!/bin/bash
################################################################################
# lib/menu.sh - Reusable numbered-menu framework + validated input helpers.
#
# Goal: menu *behavior* (numbering, "0 to exit/return", input validation)
# lives here once. Individual menus/*.sh files only supply their content
# (labels + handler functions) and never re-implement the loop/echo/case
# boilerplate that made the old single-file installer hard to change safely.
#
# Usage:
# my_menu_builder() {
# MENU_LABELS=("Do thing A" "Do thing B")
# MENU_HANDLERS=(action_a action_b)
# }
# run_menu "MY MENU TITLE" my_menu_builder [my_status_func]
#
# The builder runs fresh on every redraw, so labels/handlers can change
# based on current state (e.g. "no sites yet" vs "5 sites configured").
################################################################################
################################################################################
# Logging
################################################################################
log_info() {
echo "[INFO] $*"
}
log_error() {
echo "[ERROR] $*" >&2
}
log_success() {
echo "$*"
}
log_warning() {
echo "$*"
}
# Shared "true"/"false" -> "ON"/"OFF" label for status lines and menu
# entries showing a boolean setting's current value.
onoff() {
[[ "$1" == "true" ]] && echo "ON" || echo "OFF"
}
# Current primary IP, or the literal "No IP" if there isn't one (e.g. no
# network yet). Callers that only care whether there's an address should
# still check for -n on top of this, since "No IP" is itself non-empty.
get_ip_address() {
local ip
ip=$(hostname -I 2>/dev/null | awk '{print $1}')
if [[ -n "$ip" ]]; then
echo "$ip"
else
echo "No IP"
fi
}
# "WireGuard: 10.x.x.x | Tailscale: 100.x.x.x" for whichever VPN clients
# are installed and connected, or "None" if none are.
get_vpn_ips() {
local vpn_info=""
if command -v wg &>/dev/null && sudo wg show 2>/dev/null | grep -q interface; then
local wg_ip
wg_ip=$(sudo wg show all | grep "allowed ips" | head -1 | awk '{print $3}' | cut -d'/' -f1)
[[ -n "$wg_ip" ]] && vpn_info="${vpn_info}WireGuard: $wg_ip | "
fi
if command -v tailscale &>/dev/null; then
local ts_ip
ts_ip=$(tailscale ip -4 2>/dev/null)
[[ -n "$ts_ip" ]] && vpn_info="${vpn_info}Tailscale: $ts_ip | "
fi
if command -v netbird &>/dev/null; then
local nb_ip
nb_ip=$(netbird status 2>/dev/null | grep "NetBird IP:" | awk '{print $3}')
[[ -n "$nb_ip" ]] && vpn_info="${vpn_info}Netbird: $nb_ip | "
fi
vpn_info="${vpn_info% | }"
[[ -n "$vpn_info" ]] && echo "$vpn_info" || echo "None"
}
# enable_and_start_units UNIT [UNIT...]
# Reloads systemd and enables+starts the given unit(s) - services or
# timers - returning non-zero if enable or start fails (e.g. systemd/
# D-Bus unreachable, or a real failure on real hardware). Always call
# this from an `if`/`&&`/`||` context: this whole tool runs under
# set -e, so a bare, unguarded call whose last command fails would take
# down the entire session instead of just this one action.
enable_and_start_units() {
sudo systemctl daemon-reload 2>/dev/null || true
sudo systemctl enable "$@" 2>/dev/null && sudo systemctl start "$@" 2>/dev/null
}
pause() {
read -r -p "Press Enter to continue..."
}
################################################################################
# Validated input helpers
################################################################################
validate_yes_no() {
local answer="$1"
case "${answer,,}" in
y|yes|yeah|yep|yup|sure|ok|okay) return 0 ;;
n|no|nope|nah) return 1 ;;
*) return 2 ;; # invalid
esac
}
ask_yes_no() {
local prompt="$1"
local default="${2:-n}"
local answer
while true; do
read -r -p "$prompt (y/n) [$default]: " answer
answer="${answer:-$default}"
validate_yes_no "$answer"
local result=$?
if [[ $result -eq 0 ]]; then
return 0
elif [[ $result -eq 1 ]]; then
return 1
else
echo "❌ Invalid input. Please enter 'y' for yes or 'n' for no"
echo
fi
done
}
validate_integer() {
local value="$1"
local min="${2:--2147483648}"
local max="${3:-2147483647}"
if [[ $value =~ ^-?[0-9]+$ ]]; then
if [[ $value -ge $min && $value -le $max ]]; then
return 0
fi
fi
return 1
}
ask_integer() {
local prompt="$1"
local default="$2"
local min="${3:--2147483648}"
local max="${4:-2147483647}"
local value
while true; do
read -r -p "$prompt [$default]: " value
value="${value:-$default}"
if validate_integer "$value" "$min" "$max"; then
echo "$value"
return 0
else
echo "❌ Invalid number. Please enter an integer between $min and $max" >&2
echo >&2
fi
done
}
validate_time() {
local time="$1"
[[ $time =~ ^([0-1][0-9]|2[0-3]):([0-5][0-9])$ ]]
}
ask_time() {
local prompt="$1"
local default="$2"
local time
while true; do
read -r -p "$prompt [$default]: " time
time="${time:-$default}"
if validate_time "$time"; then
echo "$time"
return 0
else
echo "❌ Invalid time format. Please use HH:MM (00:00 to 23:59)" >&2
echo >&2
fi
done
}
validate_url() {
local url="$1"
if [[ $url =~ ^(https?|file|data)://.*$ ]] || [[ $url =~ ^about: ]]; then
return 0
else
return 1
fi
}
ask_url() {
local prompt="$1"
local default="$2"
local url
while true; do
read -r -p "$prompt [$default]: " url
url="${url:-$default}"
if validate_url "$url"; then
echo "$url"
return 0
else
echo "❌ Invalid URL. Must start with http://, https://, file://, data:, or about:" >&2
echo >&2
fi
done
}
ask_text() {
local prompt="$1"
local default="${2:-}"
local value
read -r -p "$prompt [$default]: " value
echo "${value:-$default}"
}
validate_menu_choice() {
local choice="$1"
local max="$2"
validate_integer "$choice" 0 "$max"
}
ask_menu_choice() {
local max="$1"
local choice
while true; do
read -r -p "Choose [0-$max]: " choice
if validate_menu_choice "$choice" "$max"; then
echo "$choice"
return 0
else
echo "❌ Invalid choice. Please enter a number between 0 and $max" >&2
echo >&2
fi
done
}
################################################################################
# Menu framework
################################################################################
print_menu_header() {
local title="$1"
echo "══════════════════════════════════════════════════════════"
printf " %s\n" "$title"
echo "══════════════════════════════════════════════════════════"
echo
}
# run_menu TITLE BUILDER_FUNC [STATUS_FUNC] [EXIT_LABEL]
#
# BUILDER_FUNC must set the globals MENU_LABELS and MENU_HANDLERS (parallel
# indexed arrays). It is called once per redraw, so it can reflect current
# state. STATUS_FUNC, if given, is called right after the header to print
# read-only context (current settings, current list, etc).
#
# Entries are auto-numbered 1..N. "0" always returns from run_menu - no
# menu file needs to hand-roll its own exit case.
#
# The handler is called as `handler "$choice"` (the 1-based number picked),
# so a data-driven list (e.g. a set of timezones) can share one handler
# instead of needing a distinct wrapper function per entry. Handlers that
# don't care can just ignore the argument.
run_menu() {
local title="$1"
local builder="$2"
local status_func="${3:-}"
local exit_label="${4:-Return}"
while true; do
clear
print_menu_header "$title"
if [[ -n "$status_func" ]]; then
# `|| true`: same reasoning as the handler call below - a
# status function's job is read-only display, and a
# legitimately failing command inside it (e.g. a pipeline
# whose grep matches nothing, which pipefail turns into a
# pipeline failure even though the actual last command
# succeeded) must not be allowed to kill the whole session
# over what should be, at worst, incomplete status text.
"$status_func" || true
echo
fi
local -a MENU_LABELS=()
local -a MENU_HANDLERS=()
"$builder"
if [[ "${#MENU_LABELS[@]}" -eq 0 ]]; then
log_warning "Nothing to do here yet."
echo " 0. $exit_label"
echo
ask_menu_choice 0 >/dev/null
return 0
fi
local i=1
for label in "${MENU_LABELS[@]}"; do
printf " %2d. %s\n" "$i" "$label"
i=$((i + 1))
done
echo " 0. $exit_label"
echo
local choice
choice=$(ask_menu_choice "${#MENU_LABELS[@]}")
if [[ "$choice" == "0" ]]; then
return 0
fi
# `|| true`: this whole tool runs under `set -e`. A handler that
# legitimately fails (invalid input, a guard clause, etc) and
# returns non-zero as its last statement must not be allowed to
# take the entire session down - it should just redraw the menu.
# Absorbing that here means no menus/*.sh file has to think about
# set -e at all.
"${MENU_HANDLERS[$((choice - 1))]}" "$choice" || true
done
}
+360
View File
@@ -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
}
+295
View File
@@ -0,0 +1,295 @@
#!/bin/bash
################################################################################
# menus/addon_asterisk_intercom.sh - "Asterisk Intercom" addon: connect the
# kiosk as a SIP extension to an *existing* Asterisk server.
#
# The legacy addon offered three options: Client Only (a Baresip SIP
# client - what this file is), Server Only, and Full (server + client).
# Server/Full downloaded and ran a third-party installer from a separate
# "Easy Asterisk" repository to stand up a whole Asterisk PBX. That
# repository has since gone through a major rework upstream, so wiring a
# full PBX install through it here no longer makes sense to maintain -
# and most kiosk deployments don't need this device to *be* the PBX
# anyway. This addon now does only the client/endpoint piece: install
# Baresip and register it as one extension against an Asterisk server
# the user already has running somewhere else. It never installs or
# manages Asterisk itself.
#
# Two other things fixed while narrowing the scope:
# - The legacy client path tracked its own version by calling out to the
# (now-reworked) Easy Asterisk repo's GitHub API and stamping a
# "<repo-version>-client" string in a side file. That coupling is
# exactly what's being dropped, so version tracking now just reads the
# real installed `baresip` package version via dpkg - one less network
# dependency and one less thing to keep in sync with an external repo.
# - The legacy addon had no uninstall option for the client at all -
# added below.
#
# Real system state: apt package, a per-user config directory under
# $KIOSK_HOME, and a systemd --user unit for $KIOSK_USER (not a system
# service - Baresip needs the desktop session's PulseAudio/PipeWire
# socket). Every write goes through `sudo`/`sudo -u "$KIOSK_USER"`, all
# stubbed at the command level in tests - there's no real D-Bus user
# session to target in a test container regardless.
#
# Depends on: lib/menu.sh, lib/config.sh being sourced first.
################################################################################
BARESIP_CONFIG_DIR="${KIOSK_HOME}/.baresip"
BARESIP_USER_SERVICE_DIR="${KIOSK_HOME}/.config/systemd/user"
# Runs `systemctl --user ...` as $KIOSK_USER with the runtime dir/D-Bus
# address it needs to find that user's session. Always call from an
# `if`/`&&`/`||` context - see run_menu's own comment on why a bare call
# that can legitimately fail must never be a standalone statement.
baresip_systemctl_user() {
local kiosk_uid
kiosk_uid=$(id -u "$KIOSK_USER" 2>/dev/null) || return 1
sudo -u "$KIOSK_USER" \
XDG_RUNTIME_DIR="/run/user/${kiosk_uid}" \
DBUS_SESSION_BUS_ADDRESS="unix:path=/run/user/${kiosk_uid}/bus" \
systemctl --user "$@"
}
baresip_installed_version() {
dpkg-query -W -f='${Version}' baresip 2>/dev/null || true
}
# "Installed" means both the package and a written account - a bare
# `apt install baresip` with no configured extension isn't something
# this menu should call done.
baresip_is_installed() {
command -v baresip &>/dev/null && [[ -f "$BARESIP_CONFIG_DIR/accounts" ]]
}
baresip_is_running() {
baresip_systemctl_user is-active --quiet baresip.service 2>/dev/null
}
addon_asterisk_intercom_status() {
if baresip_is_installed; then
local ver
ver=$(baresip_installed_version)
if baresip_is_running; then
echo "Asterisk Intercom: Installed (v${ver:-unknown}) - Running"
else
echo "Asterisk Intercom: Installed (v${ver:-unknown}) - Not running"
fi
if [[ -f "$BARESIP_CONFIG_DIR/accounts" ]]; then
local account
account=$(head -1 "$BARESIP_CONFIG_DIR/accounts" 2>/dev/null)
local extension="${account#<sip:}"
extension="${extension%%@*}"
[[ -n "$extension" ]] && echo " Extension: $extension"
fi
else
echo "Asterisk Intercom: Not installed"
fi
echo " Connects this kiosk as a SIP extension to an Asterisk server"
echo " you already have running elsewhere - it does not install or"
echo " manage Asterisk itself."
}
addon_asterisk_intercom_menu_builder() {
if baresip_is_installed; then
MENU_LABELS=("Reconfigure (new server/extension)" "Uninstall")
MENU_HANDLERS=(action_configure_asterisk_intercom action_uninstall_asterisk_intercom)
else
MENU_LABELS=("Connect to an Asterisk server")
MENU_HANDLERS=(action_configure_asterisk_intercom)
fi
}
addon_asterisk_intercom_menu() {
run_menu "ASTERISK INTERCOM (SIP EXTENSION)" addon_asterisk_intercom_menu_builder addon_asterisk_intercom_status
}
################################################################################
# Actions
################################################################################
action_configure_asterisk_intercom() {
echo
if baresip_is_installed; then
echo "Asterisk Intercom is already configured."
ask_yes_no "Reconfigure with a different server/extension?" "n" || { pause; return; }
fi
echo "Enter the details of the Asterisk server this kiosk should"
echo "register to as an extension (these must match what's already"
echo "configured on that server)."
echo
local server_ip=""
while [[ -z "$server_ip" ]]; do
server_ip=$(ask_text "Server IP or hostname" "")
[[ -z "$server_ip" ]] && log_error "Server address is required"
done
local server_port
server_port=$(ask_integer "Server port" 5060 1 65535)
local extension=""
while [[ -z "$extension" ]]; do
extension=$(ask_text "Extension number (e.g. 201)" "")
[[ -z "$extension" ]] && log_error "Extension is required"
done
local password=""
while [[ -z "$password" ]]; do
read -r -s -p "SIP password: " password
echo
[[ -z "$password" ]] && log_error "Password is required"
done
local answermode="manual"
if ask_yes_no "Auto-answer incoming calls (intercom mode)?" "n"; then
answermode="auto"
fi
local transport="udp"
local media_enc=""
if ask_yes_no "Use TLS encryption?" "n"; then
transport="tls"
media_enc=";mediaenc=srtp"
if [[ "$server_port" == "5060" ]]; then
server_port=5061
echo "Note: port changed to 5061 for TLS"
fi
fi
echo
echo "Configuration summary:"
echo " Server: ${server_ip}:${server_port}"
echo " Extension: ${extension}"
echo " Answer: $([[ "$answermode" == "auto" ]] && echo "Auto" || echo "Manual")"
echo " TLS: $([[ "$transport" == "tls" ]] && echo "Yes" || echo "No")"
echo
ask_yes_no "Proceed with installation?" "y" || { echo "Cancelled"; pause; return; }
echo
echo "Installing Baresip..."
if ! command -v baresip &>/dev/null; then
if ! sudo apt install -y baresip; then
log_error "Failed to install baresip package"
pause
return 1
fi
fi
sudo apt install -y pulseaudio-utils pipewire-pulse 2>/dev/null || true
sudo mkdir -p "$BARESIP_CONFIG_DIR"
sudo tee "$BARESIP_CONFIG_DIR/accounts" > /dev/null <<EOF
<sip:${extension}@${server_ip}:${server_port};transport=${transport}>;auth_pass=${password};answermode=${answermode}${media_enc}
EOF
if [[ ! -f "$BARESIP_CONFIG_DIR/config" ]]; then
sudo tee "$BARESIP_CONFIG_DIR/config" > /dev/null <<'BARESIPCONFIG'
# Baresip configuration for Asterisk Intercom
# Audio settings
audio_player pulse,default
audio_source pulse,default
audio_alert pulse,default
# Call settings
call_local_timeout 120
call_max_calls 4
# Network settings
net_interface
# SIP settings
sip_trans_bsize 128
sip_verify_server no
# Module loading
module pulse.so
module account.so
module contact.so
module menu.so
module stdio.so
module uuid.so
module debug_cmd.so
BARESIPCONFIG
fi
sudo chown -R "${KIOSK_USER}:${KIOSK_USER}" "$BARESIP_CONFIG_DIR"
sudo chmod 600 "$BARESIP_CONFIG_DIR/accounts"
sudo mkdir -p "$BARESIP_USER_SERVICE_DIR"
sudo tee "$BARESIP_USER_SERVICE_DIR/baresip.service" > /dev/null <<'BARESIPUNIT'
[Unit]
Description=Baresip SIP Client
After=pipewire.service pipewire-pulse.service
Wants=pipewire-pulse.service
[Service]
Type=simple
ExecStart=/usr/bin/baresip -f %h/.baresip
Restart=always
RestartSec=5
Environment=PULSE_SERVER=unix:/run/user/%U/pulse/native
[Install]
WantedBy=default.target
BARESIPUNIT
sudo chown -R "${KIOSK_USER}:${KIOSK_USER}" "${KIOSK_HOME}/.config"
if baresip_systemctl_user daemon-reload 2>/dev/null && \
baresip_systemctl_user enable baresip.service 2>/dev/null && \
baresip_systemctl_user start baresip.service 2>/dev/null; then
log_success "Baresip service enabled and started"
else
log_warning "Baresip files written, but enabling/starting the user service failed - it will start automatically on next login. Check: systemctl --user status baresip"
fi
echo
log_success "Asterisk Intercom configured"
echo " Config dir: ${BARESIP_CONFIG_DIR}"
echo " Server: ${server_ip}:${server_port}"
echo " Extension: ${extension}"
echo
echo "Management commands (as $KIOSK_USER):"
echo " Check status: systemctl --user status baresip"
echo " Restart: systemctl --user restart baresip"
echo " View logs: journalctl --user -u baresip -f"
pause
}
action_uninstall_asterisk_intercom() {
echo
ask_yes_no "Remove Asterisk Intercom (Baresip)?" "n" || { echo "Cancelled"; pause; return; }
asterisk_intercom_do_uninstall ask
pause
}
# The actual removal, no confirmation prompt - shared with Complete
# Uninstall so that operation doesn't need to re-implement this teardown
# a second time. $1: "ask" to prompt about config removal interactively
# (the normal case), "purge" to remove config without asking (Complete
# Uninstall).
asterisk_intercom_do_uninstall() {
local data_choice="${1:-ask}"
baresip_systemctl_user stop baresip.service 2>/dev/null || true
baresip_systemctl_user disable baresip.service 2>/dev/null || true
sudo rm -f "$BARESIP_USER_SERVICE_DIR/baresip.service"
sudo apt remove -y baresip 2>/dev/null || true
local purge_config=false
if [[ "$data_choice" == "purge" ]]; then
purge_config=true
elif [[ "$data_choice" == "ask" ]] && ask_yes_no "Remove saved SIP configuration too?" "n"; then
purge_config=true
fi
if $purge_config; then
sudo rm -rf "$BARESIP_CONFIG_DIR"
log_success "Asterisk Intercom removed (configuration deleted)"
else
log_success "Asterisk Intercom removed (configuration preserved)"
fi
}
+232
View File
@@ -0,0 +1,232 @@
#!/bin/bash
################################################################################
# menus/addon_authelia.sh - "Authelia Auto-Login" addon.
#
# Stores encrypted Authelia SSO credentials so the kiosk authenticates
# automatically on every startup. The password is AES-256-CBC encrypted
# with a key derived from this machine's /etc/machine-id via scrypt -
# the encrypted blob is useless on any other machine - and is NEVER
# stored in plain text, matching the legacy addon exactly (same
# algorithm, same salt, same node crypto calls).
#
# autheliaURL/autheliaUsername/autheliaEncryptedPassword are tracked
# fields in lib/config.sh now (load_existing_config/save_config), same
# as every other config.json field this tool manages - this is also
# what motivated fixing save_config to merge onto the existing file
# instead of rebuilding it from scratch (see lib/config.sh): the legacy
# save_config had no idea these three fields existed, so configuring
# Authelia and then visiting Sites/Touch/Navigation/Password Protection
# in the legacy menu would silently wipe the credentials on the next
# save. Real bug in the shipped script, not unique to this migration.
#
# Depends on: lib/menu.sh, lib/config.sh being sourced first.
################################################################################
addon_authelia_status() {
if [[ -n "$AUTHELIA_URL" ]]; then
echo "Authelia: configured"
echo " URL: $AUTHELIA_URL"
echo " Username: $AUTHELIA_USERNAME"
else
echo "Authelia: not configured"
fi
}
addon_authelia_menu_builder() {
if [[ -n "$AUTHELIA_URL" ]]; then
MENU_LABELS=("Reconfigure (overwrite)" "Show server-side setup instructions again" "Clear Authelia configuration")
MENU_HANDLERS=(action_configure_authelia action_show_authelia_server_setup action_clear_authelia)
else
MENU_LABELS=("Configure Authelia auto-login")
MENU_HANDLERS=(action_configure_authelia)
fi
}
addon_authelia_menu() {
load_existing_config
if ! sudo -u "$KIOSK_USER" test -f "$CONFIG_PATH" 2>/dev/null; then
log_error "config.json not found at $CONFIG_PATH - run a full install first"
pause
return
fi
run_menu "AUTHELIA AUTO-LOGIN" addon_authelia_menu_builder addon_authelia_status
}
################################################################################
# Encryption
################################################################################
# Same algorithm as the legacy addon: AES-256-CBC, key derived from
# /etc/machine-id via scrypt with a fixed salt, random IV prepended to
# the ciphertext, everything base64-encoded. main.js decrypts with the
# same derivation - do not change this without updating main.js too.
encrypt_authelia_password() {
local password="$1"
command -v node &>/dev/null || return 1
node -e "
const crypto=require('crypto'),fs=require('fs');
const id=fs.readFileSync('/etc/machine-id','utf8').trim();
const key=crypto.scryptSync(id,'kiosk-authelia-v1',32);
const iv=crypto.randomBytes(16);
const c=crypto.createCipheriv('aes-256-cbc',key,iv);
const enc=Buffer.concat([c.update(process.argv[1],'utf8'),c.final()]);
process.stdout.write(Buffer.concat([iv,enc]).toString('base64'));
" "$password" 2>/dev/null
}
################################################################################
# Actions
################################################################################
action_configure_authelia() {
echo
echo "Stores encrypted Authelia credentials so the kiosk"
echo "authenticates automatically on every startup."
echo "Password is encrypted with this machine's unique ID -"
echo "the encrypted blob is useless on any other machine."
echo
if [[ -n "$AUTHELIA_URL" ]]; then
echo "Current config:"
echo " URL: $AUTHELIA_URL"
echo " Username: $AUTHELIA_USERNAME"
echo
ask_yes_no "Overwrite existing Authelia config?" "n" || { echo "Cancelled"; return; }
echo
fi
local url user pass
read -r -p "Authelia URL (e.g. https://auth.yourdomain.com): " url
[[ -z "$url" ]] && { echo "Cancelled"; return; }
read -r -p "Authelia username: " user
[[ -z "$user" ]] && { echo "Cancelled"; return; }
read -r -s -p "Authelia password: " pass
echo
[[ -z "$pass" ]] && { echo "Cancelled"; return; }
echo "Encrypting with machine ID..."
local encrypted
encrypted=$(encrypt_authelia_password "$pass")
if [[ -z "$encrypted" ]]; then
log_error "Encryption failed - is Node.js installed?"
return 1
fi
AUTHELIA_URL="$url"
AUTHELIA_USERNAME="$user"
AUTHELIA_ENCRYPTED_PASSWORD="$encrypted"
save_config
log_success "Authelia config saved (password encrypted, NOT stored in plain text)"
action_show_authelia_server_setup
echo
if is_service_active lightdm && ask_yes_no "Restart kiosk display now?" "n"; then
sudo systemctl restart lightdm
fi
}
action_clear_authelia() {
echo
ask_yes_no "Clear Authelia configuration?" "n" || { echo "Cancelled"; return; }
AUTHELIA_URL=""
AUTHELIA_USERNAME=""
AUTHELIA_ENCRYPTED_PASSWORD=""
save_config
log_success "Authelia configuration cleared"
}
action_show_authelia_server_setup() {
echo
echo "════════════════════════════════════════════════════════════"
echo " AUTHELIA SERVER-SIDE SETUP (Dockerized)"
echo "════════════════════════════════════════════════════════════"
echo
echo "1. Generate the argon2 password hash on your Docker host:"
echo
echo " docker run --rm authelia/authelia:latest \\"
echo " authelia crypto hash generate argon2 \\"
echo " --password 'yourpassword'"
echo
echo " Copy the \$argon2id\$... output — that is your hash."
echo
echo "2. ADD a kiosk user to ~/docker/authelia/config/users.yml"
echo " (append — do not replace existing users):"
echo
echo " kiosk:"
echo " displayname: \"Kiosk Display\""
echo " password: '\$argon2id\$v=19\$m=65536,t=3,p=4\$<paste hash here>'"
echo " email: kiosk@local.com"
echo " groups:"
echo " - kiosk"
echo
echo "3. MERGE into ~/docker/authelia/config/configuration.yml:"
echo
echo " ── access_control ─────────────────────────────────────"
echo " Find your EXISTING access_control block and add the"
echo " kiosk rule as the FIRST rule inside it."
echo
echo " !! DO NOT create a second access_control: block !!"
echo " YAML silently ignores duplicate keys — the kiosk rule"
echo " will be invisible to Authelia and you will get a white"
echo " screen on the kiosk."
echo
echo " Authelia reads rules top-down, first match wins."
echo " The kiosk rule MUST be above any two_factor rule or"
echo " the two_factor wildcard will match first."
echo
echo " ── EXAMPLE — before (your existing config): ──────────"
echo " access_control:"
echo " default_policy: deny"
echo " rules:"
echo " - domain: '*.yourdomain.com'"
echo " policy: two_factor"
echo
echo " ── EXAMPLE — after (add kiosk rule above two_factor): ─"
echo " access_control:"
echo " default_policy: deny"
echo " rules:"
echo " - domain: '*.yourdomain.com' # <-- kiosk first"
echo " subject: 'group:kiosk'"
echo " policy: one_factor"
echo " - domain: '*.yourdomain.com' # <-- existing"
echo " policy: two_factor"
echo
echo " Why one_factor? The kiosk authenticates via the API"
echo " (/api/firstfactor — password only). TOTP and WebAuthn"
echo " require a second interactive step that is impossible"
echo " from a script, so the kiosk group must use one_factor."
echo
echo " ── session ─────────────────────────────────────────────"
echo " Keep your existing session block — no changes needed."
echo " The kiosk re-authenticates via API on every startup so"
echo " session expiry barely matters for it."
echo
echo " If you do NOT yet have a session block, add:"
echo
echo " session:"
echo " expiration: 8h"
echo " inactivity: 1h"
echo " remember_me: 7d"
echo " cookies:"
echo " - domain: yourdomain.com"
echo " authelia_url: https://auth.yourdomain.com"
echo
echo "4. Restart Authelia on your Docker host:"
echo " docker compose restart authelia"
echo
echo "────────────────────────────────────────────────────────────"
echo " NOTE: HTTP Basic Auth (per-site username/password) still"
echo " works alongside Authelia for sites that use browser-popup"
echo " authentication rather than Authelia SSO."
echo "────────────────────────────────────────────────────────────"
echo
echo " To clear Authelia config later, use this menu's"
echo " 'Clear Authelia configuration' option."
echo
pause
}
+161
View File
@@ -0,0 +1,161 @@
#!/bin/bash
################################################################################
# menus/addon_cups.sh - "CUPS Printing" addon (from the legacy Addons menu).
#
# First Addon migrated. Genuinely mutates real system state - installs/
# purges apt packages, writes /etc/cups/cupsd.conf and a polkit rule,
# touches ufw - at fixed paths CUPS itself doesn't let us relocate the
# way $SYSTEMD_DIR/$CRON_D_DIR/etc let us relocate our own files. Only
# the polkit rule's directory is parameterized ($POLKIT_DIR, since that's
# ours to place); everything else (cupsd.conf, apt, systemctl, ufw) gets
# full command-level `sudo` stubbing in every test - there is no scratch
# equivalent for a real apt-managed subsystem's own file layout.
#
# Depends on: lib/menu.sh, lib/config.sh being sourced first.
################################################################################
cups_is_installed() {
dpkg -l 2>/dev/null | grep -q "^ii\s\+cups\s"
}
cups_is_active() {
systemctl is-active --quiet cups
}
addon_cups_status() {
if cups_is_installed && cups_is_active; then
echo "CUPS: installed and running (http://$(get_ip_address):631)"
elif cups_is_installed; then
echo "CUPS: installed but not running"
else
echo "CUPS: not installed"
fi
}
addon_cups_menu_builder() {
if cups_is_installed && cups_is_active; then
MENU_LABELS=("Reconfigure for network access" "Complete uninstall (purge)")
MENU_HANDLERS=(action_reconfigure_cups action_cups_uninstall)
elif cups_is_installed; then
MENU_LABELS=("Start CUPS" "Complete uninstall (purge)")
MENU_HANDLERS=(action_start_cups action_cups_uninstall)
else
MENU_LABELS=("Install CUPS printing")
MENU_HANDLERS=(action_install_cups)
fi
}
addon_cups_menu() {
run_menu "CUPS PRINTING SUPPORT" addon_cups_menu_builder addon_cups_status
}
################################################################################
# Actions
################################################################################
action_install_cups() {
echo
ask_yes_no "Install CUPS printing?" "n" || { echo "Cancelled"; return; }
echo "Installing CUPS from scratch..."
if ! sudo apt update; then
log_error "apt update failed - check network/package sources and try again"
return 1
fi
if ! sudo apt install -y cups cups-client cups-filters printer-driver-all \
printer-driver-cups-pdf hplip printer-driver-gutenprint \
foomatic-db-compressed-ppds openprinting-ppds; then
log_error "CUPS package installation failed"
return 1
fi
sudo systemctl enable cups 2>/dev/null || true
sudo systemctl start cups 2>/dev/null || true
echo "Waiting for CUPS to start..."
for _ in {1..30}; do
# Must stay in an `if` - a bare `cmd1 && cmd2` statement is
# subject to set -e itself when cmd1 fails, which is virtually
# guaranteed on early iterations right after install.
if cups_is_active && lpstat -r &>/dev/null 2>&1; then
break
fi
sleep 1
done
# $BUILD_USER already resolves to $SUDO_USER when the tool was run via
# sudo, so a single usermod covers it - the legacy code ran this twice
# (once for a hardcoded computed user, once again for $SUDO_USER
# directly), which was harmless but genuinely redundant.
sudo usermod -aG lpadmin "$BUILD_USER"
action_reconfigure_cups
log_success "CUPS installed"
echo " Web interface: http://$(get_ip_address):631"
}
action_start_cups() {
sudo systemctl enable cups
sudo systemctl start cups
log_success "CUPS started"
}
action_reconfigure_cups() {
if [[ -f /etc/cups/cupsd.conf ]]; then
sudo cp /etc/cups/cupsd.conf "/etc/cups/cupsd.conf.backup-$(date +%Y%m%d-%H%M%S)"
fi
if command -v cupsctl &>/dev/null; then
sudo cupsctl --remote-admin --remote-any --share-printers 2>/dev/null || true
fi
sudo sed -i 's/^Listen localhost:631/Port 631/' /etc/cups/cupsd.conf 2>/dev/null || true
sudo sed -i 's/^Listen 127.0.0.1:631/Port 631/' /etc/cups/cupsd.conf 2>/dev/null || true
sudo mkdir -p "$POLKIT_DIR"
sudo tee "$POLKIT_DIR/kiosk-printing.pkla" > /dev/null <<EOF
[Allow kiosk printing]
Identity=unix-user:${KIOSK_USER}
Action=org.opensuse.cupspkhelper.mechanism.*
ResultAny=yes
ResultInactive=yes
ResultActive=yes
EOF
sudo ufw allow 631/tcp comment 'CUPS' 2>/dev/null || true
sudo systemctl restart cups 2>/dev/null || true
log_success "CUPS configured for network access"
}
action_cups_uninstall() {
echo
ask_yes_no "Completely remove CUPS, including all queues and settings (purge)?" "n" || { echo "Cancelled"; return; }
cups_do_uninstall
}
# The actual removal, no prompt - shared with Complete Uninstall so that
# operation doesn't need to re-implement CUPS teardown a second time.
cups_do_uninstall() {
echo "Performing complete CUPS uninstall..."
sudo systemctl stop cups cups-browsed 2>/dev/null || true
sudo systemctl disable cups cups-browsed 2>/dev/null || true
sudo apt remove --purge -y cups cups-daemon cups-client cups-filters \
cups-common cups-core-drivers cups-server-common cups-browsed \
cups-ppdc cups-bsd libcups2 libcupsimage2 2>/dev/null || true
sudo apt remove --purge -y printer-driver-all printer-driver-cups-pdf \
hplip printer-driver-gutenprint foomatic-db-compressed-ppds \
openprinting-ppds 2>/dev/null || true
sudo rm -rf /etc/cups /var/cache/cups /var/spool/cups /var/log/cups /usr/share/cups
sudo rm -f "$POLKIT_DIR/kiosk-printing.pkla"
sudo apt autoremove -y 2>/dev/null || true
sudo apt clean 2>/dev/null || true
log_success "CUPS completely removed"
}
+362
View File
@@ -0,0 +1,362 @@
#!/bin/bash
################################################################################
# menus/addon_lms_squeezelite.sh - "LMS Server / Squeezelite Player" addon.
#
# Two independent pieces sharing one menu, same as the legacy code: an LMS
# (Lyrion/Logitech Media Server) server the kiosk can host, and a
# Squeezelite player the kiosk can run to play music from any LMS server
# (this one or another one on the LAN). LMS itself is a real apt-managed
# subsystem with its own fixed paths (repo file, GPG keyring, ufw rules,
# /etc/squeezeboxserver) - like CUPS, those get full command-level `sudo`/
# `wget`/`apt` stubbing in tests rather than relocation. Squeezelite's own
# start script and systemd unit are ours to place, so - like power_schedule
# and the other addons - they go through $BIN_DIR/$SYSTEMD_DIR (lib/
# config.sh) instead of hardcoded /usr/local/bin and /etc/systemd/system,
# so tests can point them at a scratch directory.
#
# LMS ships under two package/service names depending on version -
# "logitechmediaserver" (older) and "lyrionmusicserver" (the project's
# current name after its rename) - so detection and every service call
# has to check both.
#
# Depends on: lib/menu.sh, lib/config.sh being sourced first.
################################################################################
lms_service_name() {
if systemctl list-unit-files 2>/dev/null | grep -q "lyrionmusicserver.service"; then
echo "lyrionmusicserver"
elif systemctl list-unit-files 2>/dev/null | grep -q "logitechmediaserver.service"; then
echo "logitechmediaserver"
fi
}
lms_is_installed() {
is_service_active logitechmediaserver || is_service_enabled logitechmediaserver || \
is_service_active lyrionmusicserver || is_service_enabled lyrionmusicserver
}
lms_is_running() {
is_service_active logitechmediaserver || is_service_active lyrionmusicserver
}
squeezelite_is_installed() {
is_service_active squeezelite || is_service_enabled squeezelite
}
addon_lms_squeezelite_status() {
if lms_is_installed; then
echo "LMS Server: Installed"
if lms_is_running; then
echo " Status: Running"
else
echo " Status: Stopped"
fi
echo " Web: http://$(get_ip_address):9000"
echo
fi
if squeezelite_is_installed; then
local player_name="Unknown"
if [[ -f "$BIN_DIR/squeezelite-start.sh" ]]; then
player_name=$(grep '^PLAYER_NAME=' "$BIN_DIR/squeezelite-start.sh" 2>/dev/null | cut -d'=' -f2 | tr -d '"' || echo "Unknown")
fi
echo "Squeezelite Player: Installed"
if is_service_active squeezelite; then
echo " Status: Running"
else
echo " Status: Stopped"
fi
echo " Name: $player_name"
echo
fi
echo " A server is needed to stream music. The kiosk can run the"
echo " server (if sufficient resources) or connect to another server."
}
addon_lms_squeezelite_menu_builder() {
MENU_LABELS=("Install/Configure LMS Server" "Install/Configure Squeezelite Player")
MENU_HANDLERS=(action_install_lms action_install_squeezelite)
if lms_is_installed; then
MENU_LABELS+=("Uninstall LMS Server")
MENU_HANDLERS+=(action_uninstall_lms)
fi
if squeezelite_is_installed; then
MENU_LABELS+=("Uninstall Squeezelite Player")
MENU_HANDLERS+=(action_uninstall_squeezelite)
fi
}
addon_lms_squeezelite_menu() {
run_menu "LMS SERVER / SQUEEZELITE PLAYER" addon_lms_squeezelite_menu_builder addon_lms_squeezelite_status
}
################################################################################
# Actions - LMS Server
################################################################################
action_install_lms() {
echo
if lms_is_installed; then
echo "LMS is already installed."
if ask_yes_no "Reconfigure port?" "n"; then
local new_port
new_port=$(ask_integer "New HTTP port" 9000 1 65535)
sudo sed -i "s/httpport:.*/httpport: $new_port/" /etc/squeezeboxserver/prefs/server.prefs 2>/dev/null || true
sudo systemctl restart lyrionmusicserver 2>/dev/null || sudo systemctl restart logitechmediaserver 2>/dev/null || true
log_success "LMS reconfigured on port $new_port"
fi
pause
return
fi
echo "Installing Lyrion Music Server..."
# Try the repository method first.
if wget -qO - https://debian.slimdevices.com/debian/squeezebox-keyring.gpg | sudo gpg --dearmor -o /usr/share/keyrings/lms-keyring.gpg 2>/dev/null; then
echo "deb [signed-by=/usr/share/keyrings/lms-keyring.gpg] http://debian.slimdevices.com/debian stable main" | sudo tee /etc/apt/sources.list.d/lms.list
# `|| true`: a bare, unguarded `apt update` failing here (bad
# mirror, no network) would otherwise crash the whole session
# under set -e instead of falling through to the direct-download
# fallback below, which is exactly the degrade path this is
# supposed to hit when the repository route doesn't work.
sudo apt update 2>/dev/null || true
if sudo apt install -y logitechmediaserver 2>/dev/null; then
log_success "LMS installed via repository"
else
log_warning "Repository install failed, trying direct download..."
fi
fi
# Fall back to a direct .deb download if the repository didn't produce
# either possible package.
if ! command -v logitechmediaserver &>/dev/null && ! command -v lyrionmusicserver &>/dev/null; then
local lms_deb="/tmp/lms.deb"
echo "Downloading LMS v9.0.3..."
if wget -q https://downloads.lms-community.org/LyrionMusicServer_v9.0.3/lyrionmusicserver_9.0.3_amd64.deb -O "$lms_deb"; then
echo "Installing LMS package..."
if sudo apt install -y "$lms_deb"; then
log_success "LMS installed via direct download"
else
log_error "Failed to install LMS package"
rm -f "$lms_deb"
pause
return 1
fi
rm -f "$lms_deb"
else
log_error "Failed to download LMS from lms-community.org"
pause
return 1
fi
fi
local service_name
service_name=$(lms_service_name)
if [[ -z "$service_name" ]]; then
log_warning "Service file not found, checking installed files..."
service_name=$(dpkg -L lyrionmusicserver logitechmediaserver 2>/dev/null | grep -m1 '\.service$' | xargs -r basename | sed 's/\.service$//' || echo "")
fi
if [[ -z "$service_name" ]]; then
log_error "Could not detect LMS service name"
echo "Manual steps:"
echo " 1. Find service: systemctl list-unit-files | grep -i lms"
echo " 2. Enable: sudo systemctl enable SERVICE_NAME"
echo " 3. Start: sudo systemctl start SERVICE_NAME"
pause
return 1
fi
log_info "Using service: $service_name"
# enable_and_start_units, not a bare `sudo systemctl enable ... | tee`
# pipe: the legacy version's `2>&1 | tee /tmp/lms-enable.log` made the
# whole statement's exit status depend on `tee` (always 0) rather than
# `systemctl enable`, so a real enable/start failure was silently
# swallowed instead of falling through to the warning below.
if enable_and_start_units "$service_name"; then
sudo ufw allow 9000/tcp comment 'LMS-HTTP' 2>/dev/null || true
sudo ufw allow 3483/tcp comment 'LMS-SlimProto' 2>/dev/null || true
sudo ufw allow 3483/udp comment 'LMS-Discovery' 2>/dev/null || true
log_success "LMS installed"
echo " Web interface: http://$(get_ip_address):9000"
else
log_warning "LMS installed, but systemctl enable/start failed - check 'systemctl status $service_name'"
fi
pause
}
action_uninstall_lms() {
echo
ask_yes_no "Remove LMS Server?" "n" || { echo "Cancelled"; pause; return; }
lms_do_uninstall ask
pause
}
# The actual removal, no confirmation prompt - shared with Complete
# Uninstall so that operation doesn't need to re-implement LMS teardown a
# second time. $1: "ask" to prompt about data removal interactively (the
# normal case), "purge" to remove data without asking (Complete Uninstall).
lms_do_uninstall() {
local data_choice="${1:-ask}"
local service_name
service_name=$(lms_service_name)
if [[ -n "$service_name" ]]; then
echo "Stopping $service_name..."
sudo systemctl stop "$service_name" 2>/dev/null || true
sudo systemctl disable "$service_name" 2>/dev/null || true
fi
# Try to remove both possible package names - only one will actually
# be installed, the other is a harmless no-op.
sudo apt remove -y lyrionmusicserver 2>/dev/null || true
sudo apt remove -y logitechmediaserver 2>/dev/null || true
sudo rm -f /etc/apt/sources.list.d/lms.list
sudo rm -f /usr/share/keyrings/lms-keyring.gpg
local purge_data=false
if [[ "$data_choice" == "purge" ]]; then
purge_data=true
elif [[ "$data_choice" == "ask" ]] && ask_yes_no "Remove LMS data and configuration?" "n"; then
purge_data=true
fi
if $purge_data; then
sudo rm -rf /var/lib/squeezeboxserver
sudo rm -rf /etc/squeezeboxserver
log_success "LMS and data removed"
else
log_success "LMS removed (data preserved)"
fi
}
################################################################################
# Actions - Squeezelite Player
################################################################################
action_install_squeezelite() {
echo
if squeezelite_is_installed; then
echo "Squeezelite is already installed."
ask_yes_no "Reconfigure?" "n" || { pause; return; }
fi
if ! command -v squeezelite &>/dev/null; then
if ! sudo apt install -y squeezelite; then
log_error "squeezelite package installation failed"
pause
return 1
fi
fi
local player_name
player_name=$(ask_text "Player name" "Kiosk")
echo
echo "LMS Server Configuration:"
echo " Enter IP:PORT of your LMS server"
echo " Leave blank for auto-discovery on LAN"
echo
local lms_server
lms_server=$(ask_text "LMS Server (e.g., 192.168.1.100:3483)" "")
sudo tee "$BIN_DIR/squeezelite-start.sh" > /dev/null <<SQSTART
#!/bin/bash
PLAYER_NAME="$player_name"
LMS_SERVER="$lms_server"
for i in {1..20}; do
pactl info >/dev/null 2>&1 && break
sleep 1
done
if ! pactl info >/dev/null 2>&1; then
logger "ERROR: Squeezelite - PipeWire not available"
exit 1
fi
if [[ -n "\$LMS_SERVER" ]]; then
exec /usr/bin/squeezelite -n "\$PLAYER_NAME" -s "\$LMS_SERVER" -o pulse -a 80:4:: -b 512:1024 -C 5
else
exec /usr/bin/squeezelite -n "\$PLAYER_NAME" -o pulse -a 80:4:: -b 512:1024 -C 5
fi
SQSTART
sudo chmod +x "$BIN_DIR/squeezelite-start.sh"
local kiosk_uid
kiosk_uid=$(id -u "$KIOSK_USER")
sudo tee "$SYSTEMD_DIR/squeezelite.service" > /dev/null <<EOF
[Unit]
Description=Squeezelite
After=sound.target network-online.target
Wants=network-online.target
[Service]
Type=simple
User=$KIOSK_USER
Environment="XDG_RUNTIME_DIR=/run/user/$kiosk_uid"
ExecStartPre=/bin/sleep 10
ExecStart=${BIN_DIR}/squeezelite-start.sh
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload 2>/dev/null || true
# Enable only, not start: squeezelite needs the kiosk user's real
# session (PipeWire, XDG_RUNTIME_DIR) up first, which is why a reboot
# is required below rather than starting it immediately.
if ! sudo systemctl enable squeezelite 2>/dev/null; then
log_warning "Squeezelite files written, but 'systemctl enable' failed - check 'systemctl status squeezelite'"
fi
log_success "Squeezelite installed: $player_name"
if [[ -n "$lms_server" ]]; then
echo " Server: $lms_server"
else
echo " Server: Auto-discovery"
fi
echo
echo "⚠️ IMPORTANT: Squeezelite requires a reboot to work properly"
echo
if ask_yes_no "Reboot now?" "n"; then
echo "Rebooting in 5 seconds..."
sleep 5
sudo reboot
else
echo "⚠️ Remember to reboot before using Squeezelite"
echo " Command: sudo reboot"
fi
pause
}
action_uninstall_squeezelite() {
echo
ask_yes_no "Remove Squeezelite Player?" "n" || { echo "Cancelled"; pause; return; }
squeezelite_do_uninstall
pause
}
# Shared with Complete Uninstall - same reasoning as lms_do_uninstall.
squeezelite_do_uninstall() {
sudo systemctl stop squeezelite 2>/dev/null || true
sudo systemctl disable squeezelite 2>/dev/null || true
sudo rm -f "$SYSTEMD_DIR/squeezelite.service"
sudo rm -f "$BIN_DIR/squeezelite-start.sh"
sudo apt remove -y squeezelite 2>/dev/null || true
log_success "Squeezelite removed"
}
+434
View File
@@ -0,0 +1,434 @@
#!/bin/bash
################################################################################
# menus/addon_remote_access.sh - "Remote Access" addon (VNC, WireGuard,
# Tailscale, Netbird).
#
# Third Addon migrated, and the biggest so far in scope (4 sub-areas).
# All four genuinely mutate real system state at fixed paths this project
# doesn't own the layout of (apt packages, /etc/wireguard, real VPN
# client CLIs) - same risk class as CUPS. Only $WIREGUARD_DIR and
# $SYSTEMD_DIR (lib/config.sh) are parameterized, since those are the
# only paths this file itself writes to; every command (apt, systemctl,
# wg, tailscale, netbird, x11vnc) gets full stubbing in every test.
#
# Tailscale and Netbird install themselves via `curl -fsSL <vendor
# url> | sh` - the vendors' own documented install method, preserved as-
# is rather than redesigned. This is NEVER allowed to run for real in
# any test: curl itself is stubbed, not just sudo, so there is no path
# by which a test could reach the network.
#
# None of x11vnc/wg/tailscale/netbird are installed in a fresh
# environment, so their "not installed" detection is real/unstubbed and
# safe to exercise end-to-end - only the "install" actions need stubs.
#
# Depends on: lib/menu.sh, lib/config.sh being sourced first.
################################################################################
remote_access_status() {
echo "VNC: $(is_service_active x11vnc && echo "running" || echo "not installed")"
echo "WireGuard: $(wireguard_connected && echo "connected" || (command -v wg &>/dev/null && echo "installed, not connected" || echo "not installed"))"
echo "Tailscale: $(command -v tailscale &>/dev/null && echo "installed" || echo "not installed")"
echo "Netbird: $(command -v netbird &>/dev/null && echo "installed" || echo "not installed")"
}
remote_access_menu_builder() {
MENU_LABELS=("VNC Remote Desktop" "WireGuard VPN" "Tailscale VPN" "Netbird VPN")
MENU_HANDLERS=(vnc_menu wireguard_menu tailscale_menu netbird_menu)
}
remote_access_menu() {
run_menu "REMOTE ACCESS" remote_access_menu_builder remote_access_status
}
################################################################################
# VNC (x11vnc)
################################################################################
vnc_status() {
if is_service_active x11vnc; then
echo "VNC: running - connect to $(get_ip_address):5900"
else
echo "VNC: not installed"
fi
}
vnc_menu_builder() {
if is_service_active x11vnc; then
MENU_LABELS=("Reconfigure password" "Uninstall")
MENU_HANDLERS=(action_vnc_change_password action_vnc_uninstall)
else
MENU_LABELS=("Install x11vnc")
MENU_HANDLERS=(action_vnc_install)
fi
}
vnc_menu() {
run_menu "VNC REMOTE DESKTOP" vnc_menu_builder vnc_status
}
action_vnc_install() {
echo
ask_yes_no "Install x11vnc?" "n" || { echo "Cancelled"; return; }
if ! sudo apt install -y x11vnc; then
log_error "x11vnc installation failed"
return 1
fi
local vnc_pass
read -r -s -p "VNC password: " vnc_pass
echo
if [[ -z "$vnc_pass" ]]; then
log_error "No password provided - cancelled"
return 1
fi
sudo -u "$KIOSK_USER" mkdir -p "$KIOSK_HOME/.vnc"
sudo -u "$KIOSK_USER" x11vnc -storepasswd "$vnc_pass" "$KIOSK_HOME/.vnc/passwd"
sudo tee "$SYSTEMD_DIR/x11vnc.service" > /dev/null <<EOF
[Unit]
Description=x11vnc Remote Desktop
After=lightdm.service
[Service]
Type=simple
User=${KIOSK_USER}
ExecStart=/usr/bin/x11vnc -display :0 -auth guess -rfbauth ${KIOSK_HOME}/.vnc/passwd -forever -loop -noxdamage -repeat -shared
Restart=always
[Install]
WantedBy=multi-user.target
EOF
if enable_and_start_units x11vnc; then
sudo ufw allow 5900/tcp comment 'VNC' 2>/dev/null || true
log_success "VNC installed - connect to $(get_ip_address):5900"
else
log_warning "x11vnc installed but systemctl enable/start failed - check 'systemctl status x11vnc'"
fi
}
action_vnc_change_password() {
echo
local vnc_pass
read -r -s -p "New VNC password: " vnc_pass
echo
if [[ -z "$vnc_pass" ]]; then
log_error "No password provided - cancelled"
return 1
fi
sudo -u "$KIOSK_USER" x11vnc -storepasswd "$vnc_pass" "$KIOSK_HOME/.vnc/passwd"
if sudo systemctl restart x11vnc 2>/dev/null; then
log_success "VNC password updated"
else
log_warning "Password file updated, but restarting x11vnc failed - check 'systemctl status x11vnc'"
fi
}
action_vnc_uninstall() {
echo
ask_yes_no "Remove VNC?" "n" || { echo "Cancelled"; return; }
vnc_do_uninstall
}
# Shared with Complete Uninstall - same reasoning as cups_do_uninstall.
vnc_do_uninstall() {
sudo systemctl stop x11vnc 2>/dev/null || true
sudo systemctl disable x11vnc 2>/dev/null || true
sudo rm -f "$SYSTEMD_DIR/x11vnc.service"
sudo apt remove -y x11vnc 2>/dev/null || true
log_success "VNC removed"
}
################################################################################
# WireGuard
################################################################################
wireguard_connected() {
command -v wg &>/dev/null && sudo wg show 2>/dev/null | grep -q interface
}
wireguard_status() {
if wireguard_connected; then
echo "WireGuard: connected"
sudo wg show 2>/dev/null | grep -E "interface:|endpoint:|allowed ips:" | sed 's/^/ /' || true
elif command -v wg &>/dev/null; then
echo "WireGuard: installed, not connected"
else
echo "WireGuard: not installed"
fi
}
wireguard_menu_builder() {
if wireguard_connected; then
MENU_LABELS=("Show full config" "Paste new config" "Uninstall")
MENU_HANDLERS=(action_wireguard_show_config action_wireguard_paste_config action_wireguard_uninstall)
elif command -v wg &>/dev/null; then
MENU_LABELS=("Paste config" "Uninstall")
MENU_HANDLERS=(action_wireguard_paste_config action_wireguard_uninstall)
else
MENU_LABELS=("Install WireGuard")
MENU_HANDLERS=(action_wireguard_install)
fi
}
wireguard_menu() {
run_menu "WIREGUARD VPN" wireguard_menu_builder wireguard_status
}
action_wireguard_install() {
echo
ask_yes_no "Install WireGuard?" "n" || { echo "Cancelled"; return; }
if ! sudo apt install -y wireguard wireguard-tools; then
log_error "WireGuard installation failed"
return 1
fi
log_success "WireGuard installed"
echo
if ask_yes_no "Paste a config now?" "n"; then
action_wireguard_paste_config
fi
}
action_wireguard_show_config() {
echo
sudo wg show all
}
# Reads a WireGuard config from stdin until EOF (Ctrl+D on a real
# terminal) - same as the legacy addon. Writes to $WIREGUARD_DIR rather
# than a hardcoded /etc/wireguard, so tests can point it at scratch space
# and verify the written content without touching the real directory.
action_wireguard_paste_config() {
echo
echo "Paste your WireGuard config (Ctrl+D when done):"
local config
config=$(cat)
if [[ -z "$config" ]]; then
log_error "No config provided"
return 1
fi
local wg_name
wg_name=$(ask_text "Config name" "wg0")
sudo mkdir -p "$WIREGUARD_DIR"
echo "$config" | sudo tee "$WIREGUARD_DIR/${wg_name}.conf" > /dev/null
sudo chmod 600 "$WIREGUARD_DIR/${wg_name}.conf"
if enable_and_start_units "wg-quick@${wg_name}"; then
log_success "WireGuard configured: $wg_name"
else
log_warning "Config written, but systemctl enable/start failed - check 'systemctl status wg-quick@${wg_name}'"
fi
}
action_wireguard_uninstall() {
echo
ask_yes_no "Remove WireGuard?" "n" || { echo "Cancelled"; return; }
wireguard_do_uninstall
}
# Shared with Complete Uninstall - same reasoning as cups_do_uninstall.
wireguard_do_uninstall() {
sudo systemctl stop 'wg-quick@*' 2>/dev/null || true
sudo systemctl disable 'wg-quick@*' 2>/dev/null || true
sudo apt remove -y wireguard wireguard-tools 2>/dev/null || true
log_success "WireGuard removed"
}
################################################################################
# Tailscale
################################################################################
tailscale_backend_state() {
tailscale status --json 2>/dev/null | jq -r '.BackendState // "unknown"' 2>/dev/null || echo "unknown"
}
tailscale_status() {
if ! command -v tailscale &>/dev/null; then
echo "Tailscale: not installed"
return
fi
if [[ "$(tailscale_backend_state)" == "Running" ]]; then
echo "Tailscale: connected"
echo " Hostname: $(tailscale status --json 2>/dev/null | jq -r '.Self.HostName // "unknown"')"
echo " IP: $(tailscale ip -4 2>/dev/null)"
else
echo "Tailscale: installed, not connected"
fi
}
tailscale_menu_builder() {
if command -v tailscale &>/dev/null; then
MENU_LABELS=("Connect (interactive)" "Connect with auth key" "Show status" "Uninstall")
MENU_HANDLERS=(action_tailscale_connect_interactive action_tailscale_connect_authkey action_tailscale_show_status action_tailscale_uninstall)
else
MENU_LABELS=("Install Tailscale")
MENU_HANDLERS=(action_tailscale_install)
fi
}
tailscale_menu() {
run_menu "TAILSCALE VPN" tailscale_menu_builder tailscale_status
}
action_tailscale_install() {
echo
ask_yes_no "Install Tailscale?" "n" || { echo "Cancelled"; return; }
if ! curl -fsSL https://tailscale.com/install.sh | sh; then
log_error "Tailscale installation failed"
return 1
fi
log_success "Tailscale installed"
echo
echo "Options:"
echo " 1. Connect now (interactive)"
echo " 2. Connect with auth key"
echo " 3. Connect later"
local choice
choice=$(ask_integer "Choose" "3" 1 3)
case "$choice" in
1) action_tailscale_connect_interactive ;;
2) action_tailscale_connect_authkey ;;
esac
}
action_tailscale_connect_interactive() {
echo
if sudo tailscale up; then
log_success "Tailscale connected"
else
log_error "Tailscale connection failed"
fi
}
action_tailscale_connect_authkey() {
echo
echo "Get an auth key from: https://login.tailscale.com/admin/settings/keys"
local authkey
read -r -p "Enter auth key: " authkey
if [[ -z "$authkey" ]]; then
echo "Cancelled"
return
fi
if sudo tailscale up --authkey="$authkey"; then
log_success "Tailscale connected"
else
log_error "Tailscale connection failed"
fi
}
action_tailscale_show_status() {
echo
tailscale status
}
action_tailscale_uninstall() {
echo
ask_yes_no "Remove Tailscale?" "n" || { echo "Cancelled"; return; }
tailscale_do_uninstall
}
# Shared with Complete Uninstall - same reasoning as cups_do_uninstall.
tailscale_do_uninstall() {
sudo tailscale down 2>/dev/null || true
sudo apt remove -y tailscale 2>/dev/null || true
log_success "Tailscale removed"
}
################################################################################
# Netbird
################################################################################
netbird_connected() {
[[ "$(netbird status 2>/dev/null | grep "Status:" | awk '{print $2}')" == "Connected" ]]
}
netbird_status() {
if ! command -v netbird &>/dev/null; then
echo "Netbird: not installed"
return
fi
if netbird_connected; then
echo "Netbird: connected"
netbird status 2>/dev/null | grep -E "NetBird IP:|Public key:" | sed 's/^/ /' || true
else
echo "Netbird: installed, not connected"
fi
}
netbird_menu_builder() {
if command -v netbird &>/dev/null; then
MENU_LABELS=("Connect with setup key" "Show status" "Uninstall")
MENU_HANDLERS=(action_netbird_connect action_netbird_show_status action_netbird_uninstall)
else
MENU_LABELS=("Install Netbird")
MENU_HANDLERS=(action_netbird_install)
fi
}
netbird_menu() {
run_menu "NETBIRD VPN" netbird_menu_builder netbird_status
}
action_netbird_install() {
echo
ask_yes_no "Install Netbird?" "n" || { echo "Cancelled"; return; }
if ! curl -fsSL https://pkgs.netbird.io/install.sh | sh; then
log_error "Netbird installation failed"
return 1
fi
log_success "Netbird installed"
echo
if ask_yes_no "Connect with a setup key now?" "n"; then
action_netbird_connect
fi
}
action_netbird_connect() {
echo
echo "Get a setup key from the Netbird dashboard"
local setup_key
read -r -p "Enter setup key: " setup_key
if [[ -z "$setup_key" ]]; then
echo "Cancelled"
return
fi
if sudo netbird up --setup-key "$setup_key"; then
log_success "Netbird connected"
else
log_error "Netbird connection failed"
fi
}
action_netbird_show_status() {
echo
netbird status
}
action_netbird_uninstall() {
echo
ask_yes_no "Remove Netbird?" "n" || { echo "Cancelled"; return; }
netbird_do_uninstall
}
# Shared with Complete Uninstall - same reasoning as cups_do_uninstall.
netbird_do_uninstall() {
sudo netbird down 2>/dev/null || true
sudo apt remove -y netbird 2>/dev/null || true
log_success "Netbird removed"
}
+353
View File
@@ -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"
}
+241
View File
@@ -0,0 +1,241 @@
#!/bin/bash
################################################################################
# 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.
# 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, lib/electron.sh being sourced first.
################################################################################
electron_installed_version() {
local package_json="$KIOSK_DIR/package.json"
if ! sudo test -f "$package_json" 2>/dev/null; then
echo "not installed"
return
fi
local version
version=$(sudo grep -oP '"electron"\s*:\s*"\^?\K[0-9.]+' "$package_json" 2>/dev/null || true)
if [[ -z "$version" ]]; then
local electron_pkg="$KIOSK_DIR/node_modules/electron/package.json"
if sudo test -f "$electron_pkg" 2>/dev/null; then
version=$(sudo grep -oP '"version"\s*:\s*"\K[0-9.]+' "$electron_pkg" 2>/dev/null || true)
fi
fi
echo "${version:-unknown}"
}
electron_is_running() {
pgrep -f "electron.*main.js" &>/dev/null || pgrep -f "node.*electron" &>/dev/null
}
advanced_electron_status() {
local ver
ver=$(electron_installed_version)
echo "Electron: v${ver}"
if electron_is_running; then
echo " Running"
else
echo " Not running"
fi
}
advanced_electron_menu_builder() {
MENU_LABELS=(
"Check for updates / update Electron"
"Fix blank screen (repair Electron binary + sandbox)"
)
MENU_HANDLERS=(action_update_electron action_repair_electron)
}
advanced_electron_menu() {
run_menu "ELECTRON MAINTENANCE" advanced_electron_menu_builder advanced_electron_status
}
################################################################################
# Actions
################################################################################
action_update_electron() {
echo
if ! sudo test -d "$KIOSK_DIR" 2>/dev/null; then
log_error "Kiosk directory not found: $KIOSK_DIR"
pause
return 1
fi
local current_version
current_version=$(electron_installed_version)
log_info "Current Electron version: $current_version"
if electron_is_running; then
log_success "Electron app is running"
else
log_warning "Electron app does not appear to be running"
fi
echo
ask_yes_no "Check for latest Electron version?" "y" || { echo "Cancelled"; pause; return; }
local latest_version
latest_version=$(npm view electron version 2>/dev/null || true)
if [[ -z "$latest_version" ]]; then
latest_version=$(curl -s https://registry.npmjs.org/electron/latest 2>/dev/null | grep -oP '"version"\s*:\s*"\K[0-9.]+' || true)
fi
if [[ -z "$latest_version" ]]; then
latest_version=$(curl -s https://api.github.com/repos/electron/electron/releases/latest 2>/dev/null | grep -oP '"tag_name"\s*:\s*"v\K[0-9.]+' || true)
fi
if [[ -z "$latest_version" ]]; then
log_error "Could not fetch latest Electron version - check your internet connection"
pause
return 1
fi
log_success "Latest stable Electron version: $latest_version"
echo
if [[ "$current_version" == "$latest_version" ]]; then
log_success "Already running the latest version"
ask_yes_no "Reinstall Electron $latest_version anyway?" "n" || { echo "Cancelled"; pause; return; }
fi
echo "──────────────────────────────────────────────────────────"
echo "UPDATE SUMMARY"
echo "──────────────────────────────────────────────────────────"
echo "Current version: $current_version"
echo "Target version: $latest_version"
echo "Installation: $KIOSK_DIR"
echo
local current_major="${current_version%%.*}"
local latest_major="${latest_version%%.*}"
log_warning "Review breaking changes before updating:"
echo " https://www.electronjs.org/docs/latest/breaking-changes"
if [[ "$latest_major" != "$current_major" ]]; then
log_warning "MAJOR VERSION CHANGE (v${current_major} -> v${latest_major})"
fi
echo
ask_yes_no "Reviewed breaking changes and want to proceed?" "n" || { echo "Cancelled"; pause; return; }
echo
log_info "Creating backup..."
local kiosk_owner
kiosk_owner=$(sudo stat -c '%U' "$KIOSK_DIR" 2>/dev/null || echo "$KIOSK_USER")
local backup_dir="${KIOSK_DIR}/backups/electron_backup_$(date +%Y%m%d_%H%M%S)"
sudo -u "$kiosk_owner" mkdir -p "$backup_dir"
if sudo test -f "$KIOSK_DIR/package.json" 2>/dev/null; then
sudo -u "$kiosk_owner" cp "$KIOSK_DIR/package.json" "$backup_dir/"
fi
if sudo test -f "$KIOSK_DIR/package-lock.json" 2>/dev/null; then
sudo -u "$kiosk_owner" cp "$KIOSK_DIR/package-lock.json" "$backup_dir/"
fi
echo "$current_version" | sudo -u "$kiosk_owner" tee "$backup_dir/electron_version.txt" > /dev/null
log_success "Backup created at: $backup_dir"
echo
ask_yes_no "Proceed with Electron update to $latest_version?" "n" || {
log_info "Update cancelled - backup preserved at: $backup_dir"
pause
return
}
echo
log_info "Stopping kiosk display..."
sudo systemctl stop lightdm 2>/dev/null || true
sleep 2
sudo -u "$KIOSK_USER" sed -i "s/\"electron\": \".*\"/\"electron\": \"^${latest_version}\"/" "$KIOSK_DIR/package.json"
if sudo test -d "$KIOSK_DIR/node_modules/electron" 2>/dev/null; then
sudo -u "$KIOSK_USER" rm -rf "$KIOSK_DIR/node_modules/electron"
fi
log_info "Installing Electron $latest_version (this may take a few minutes)..."
if sudo -u "$KIOSK_USER" bash -c "cd '$KIOSK_DIR' && npm install electron@'$latest_version'"; then
log_success "Electron updated to $latest_version"
local sandbox="$KIOSK_DIR/node_modules/electron/dist/chrome-sandbox"
if sudo test -f "$sandbox" 2>/dev/null; then
sudo chown root:root "$sandbox"
sudo chmod 4755 "$sandbox"
fi
if ask_yes_no "Restart kiosk display now?" "y"; then
sudo systemctl start lightdm
sleep 3
if systemctl is-active --quiet lightdm; then
log_success "Kiosk display started"
else
log_error "Kiosk display failed to start - check: sudo journalctl -u lightdm -n 50"
fi
else
log_info "Start manually with: sudo systemctl start lightdm"
fi
log_success "Backup preserved at: $backup_dir (delete once confirmed working)"
else
log_error "Electron install failed - restoring from backup..."
if sudo test -f "$backup_dir/package.json" 2>/dev/null; then
sudo -u "$KIOSK_USER" cp "$backup_dir/package.json" "$KIOSK_DIR/"
fi
if sudo -u "$KIOSK_USER" bash -c "cd '$KIOSK_DIR' && npm install"; then
log_success "Restored original Electron installation"
sudo systemctl start lightdm
else
log_error "Failed to restore - manual intervention required"
fi
fi
pause
}
action_repair_electron() {
echo
echo "This will:"
echo " 1. Check if the Electron binary is present"
echo " 2. Download it if missing (~120MB)"
echo " 3. Fix chrome-sandbox permissions (setuid root)"
echo " 4. Restart the kiosk display"
echo
ask_yes_no "Continue?" "y" || { echo "Cancelled"; pause; return; }
sudo systemctl stop lightdm 2>/dev/null || true
sleep 1
# 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
fi
log_info "Restarting kiosk display..."
sudo systemctl restart lightdm
sleep 3
if systemctl is-active --quiet lightdm && pgrep -f "electron.*main.js" &>/dev/null; then
log_success "Kiosk display is running"
else
log_warning "LightDM started but Electron may still be loading."
echo " Check: sudo tail -20 $KIOSK_DIR/../electron.log"
fi
pause
}
+306
View File
@@ -0,0 +1,306 @@
#!/bin/bash
################################################################################
# menus/advanced_emergency_hotspot.sh - "Emergency Hotspot" (Advanced):
# auto-starts a WiFi hotspot if no internet is detected 60 seconds after
# boot, so the kiosk can be reached and reconfigured remotely.
#
# Writes a standalone runtime script ($BIN_DIR/kiosk-emergency-hotspot)
# plus a oneshot systemd unit ($SYSTEMD_DIR) that runs it at boot - both
# of those paths are ours to place, so (like power_schedule and every
# other addon) they're parameterized instead of hardcoded. hostapd/
# dnsmasq/iptables themselves are real apt packages with their own fixed
# config locations, stubbed at the command level in tests like CUPS.
#
# The runtime script itself is a template: everything written with `\$`
# below stays literal and only resolves when the script actually runs at
# boot (on the real machine, not in this tool); only the un-escaped
# $wifi_iface/$hotspot_ssid/$hotspot_pass/$hotspot_ip/$KIOSK_USER/
# $KIOSK_DIR are substituted once, at configuration time.
#
# Depends on: lib/menu.sh, lib/config.sh being sourced first.
################################################################################
EMERGENCY_HOTSPOT_SCRIPT="$BIN_DIR/kiosk-emergency-hotspot"
emergency_hotspot_is_configured() {
[[ -f "$EMERGENCY_HOTSPOT_SCRIPT" ]]
}
emergency_hotspot_ssid() {
grep '^HOTSPOT_SSID=' "$EMERGENCY_HOTSPOT_SCRIPT" 2>/dev/null | cut -d'=' -f2 | tr -d '"' || true
}
advanced_emergency_hotspot_status() {
if emergency_hotspot_is_configured; then
local ssid
ssid=$(emergency_hotspot_ssid)
echo "Emergency Hotspot: Configured (SSID: ${ssid:-unknown})"
else
echo "Emergency Hotspot: Not configured"
fi
echo " Auto-starts a WiFi hotspot if no internet is detected 60"
echo " seconds after boot, so you can connect and reconfigure remotely."
}
advanced_emergency_hotspot_menu_builder() {
if emergency_hotspot_is_configured; then
MENU_LABELS=("Reconfigure" "Disable")
MENU_HANDLERS=(action_configure_emergency_hotspot action_disable_emergency_hotspot)
else
MENU_LABELS=("Enable emergency hotspot")
MENU_HANDLERS=(action_configure_emergency_hotspot)
fi
}
advanced_emergency_hotspot_menu() {
run_menu "EMERGENCY HOTSPOT" advanced_emergency_hotspot_menu_builder advanced_emergency_hotspot_status
}
################################################################################
# Actions
################################################################################
action_configure_emergency_hotspot() {
echo
if ! sudo apt install -y hostapd dnsmasq iptables; then
log_error "Failed to install hostapd/dnsmasq/iptables"
pause
return 1
fi
sudo systemctl stop hostapd dnsmasq 2>/dev/null || true
sudo systemctl disable hostapd dnsmasq 2>/dev/null || true
local wifi_iface
wifi_iface=$(ls /sys/class/net 2>/dev/null | grep -E "^wl" | head -1 || true)
if [[ -z "$wifi_iface" ]]; then
log_error "No WiFi interface found"
pause
return 1
fi
echo "WiFi interface: $wifi_iface"
echo
local hotspot_ssid
hotspot_ssid=$(ask_text "Hotspot SSID" "Kiosk-Emergency")
local hotspot_pass=""
while [[ ${#hotspot_pass} -lt 8 ]]; do
read -r -s -p "Hotspot password (8+ chars): " hotspot_pass
echo
[[ ${#hotspot_pass} -lt 8 ]] && log_error "Password must be at least 8 characters"
done
local hotspot_ip="192.168.50.1"
sudo mkdir -p "$BIN_DIR"
sudo tee "$EMERGENCY_HOTSPOT_SCRIPT" > /dev/null <<EOF
#!/bin/bash
################################################################################
### KIOSK EMERGENCY HOTSPOT
### Auto-starts if no internet connection 60 seconds after boot
################################################################################
WIFI_IFACE="$wifi_iface"
HOTSPOT_SSID="$hotspot_ssid"
HOTSPOT_PASS="$hotspot_pass"
HOTSPOT_IP="$hotspot_ip"
KIOSK_USER="$KIOSK_USER"
# Wait 60 seconds after boot
sleep 60
# Check for internet connectivity
if ping -c 3 -W 5 8.8.8.8 >/dev/null 2>&1; then
logger "KIOSK: Internet connected - emergency hotspot not needed"
exit 0
fi
logger "KIOSK: No internet detected - starting emergency hotspot"
# Stop any conflicting services
systemctl stop wpa_supplicant 2>/dev/null || true
ip link set \$WIFI_IFACE down 2>/dev/null || true
sleep 2
# Configure static IP for hotspot
ip addr flush dev \$WIFI_IFACE
ip addr add \${HOTSPOT_IP}/24 dev \$WIFI_IFACE
ip link set \$WIFI_IFACE up
# Configure dnsmasq
cat > /tmp/dnsmasq-hotspot.conf <<DNSMASQ
interface=\$WIFI_IFACE
dhcp-range=192.168.50.10,192.168.50.50,12h
dhcp-option=3,\$HOTSPOT_IP
dhcp-option=6,\$HOTSPOT_IP
server=8.8.8.8
log-queries
log-dhcp
DNSMASQ
# Start dnsmasq
dnsmasq -C /tmp/dnsmasq-hotspot.conf
# Configure hostapd
cat > /tmp/hostapd-hotspot.conf <<HOSTAPD
interface=\$WIFI_IFACE
driver=nl80211
ssid=\$HOTSPOT_SSID
hw_mode=g
channel=6
macaddr_acl=0
auth_algs=1
ignore_broadcast_ssid=0
wpa=2
wpa_passphrase=\$HOTSPOT_PASS
wpa_key_mgmt=WPA-PSK
wpa_pairwise=TKIP
rsn_pairwise=CCMP
HOSTAPD
# Start hostapd
hostapd -B /tmp/hostapd-hotspot.conf
# Enable IP forwarding (optional - for internet sharing if wired connection exists)
echo 1 > /proc/sys/net/ipv4/ip_forward 2>/dev/null || true
# Show notification on kiosk display
sudo -u \$KIOSK_USER DISPLAY=:0 DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/\$(id -u \$KIOSK_USER)/bus \\
notify-send -u critical -t 0 "Emergency Hotspot Active" \\
"SSID: \$HOTSPOT_SSID\\nPassword: \$HOTSPOT_PASS\\nConnect to: http://\$HOTSPOT_IP" 2>/dev/null || true
logger "KIOSK: Emergency hotspot started - SSID: \$HOTSPOT_SSID, IP: \$HOTSPOT_IP"
# Create on-screen notification HTML
sudo -u \$KIOSK_USER tee /tmp/hotspot-notification.html > /dev/null <<'NOTIFY'
<!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.95);
color: white;
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
}
.container {
text-align: center;
padding: 40px;
background: linear-gradient(135deg, #e74c3c 0%, #c0392b 100%);
border-radius: 20px;
box-shadow: 0 10px 40px rgba(0,0,0,0.5);
max-width: 600px;
}
h1 { font-size: 48px; margin-bottom: 20px; }
.icon { font-size: 72px; margin-bottom: 20px; }
.info { font-size: 24px; margin: 20px 0; line-height: 1.6; }
.credential {
background: rgba(0,0,0,0.3);
padding: 15px;
border-radius: 10px;
margin: 10px 0;
font-family: monospace;
font-size: 20px;
}
.dismiss {
margin-top: 30px;
padding: 15px 40px;
font-size: 18px;
background: white;
color: #e74c3c;
border: none;
border-radius: 10px;
cursor: pointer;
font-weight: bold;
}
.dismiss:hover { background: #ecf0f1; }
</style>
</head>
<body>
<div class="container">
<div class="icon">📡</div>
<h1>Emergency Hotspot Active</h1>
<div class="info">No internet connection detected<br>Hotspot created for remote access</div>
<div class="credential">SSID: <strong>\$HOTSPOT_SSID</strong></div>
<div class="credential">Password: <strong>\$HOTSPOT_PASS</strong></div>
<div class="credential">Connect to: <strong>http://\$HOTSPOT_IP</strong></div>
<button class="dismiss" onclick="window.close()">Dismiss</button>
</div>
<script>
// Auto-dismiss after 5 minutes
setTimeout(() => window.close(), 300000);
</script>
</body>
</html>
NOTIFY
# Show notification window if Electron is running
if pgrep -f "electron.*main.js" >/dev/null 2>&1; then
sudo -u \$KIOSK_USER DISPLAY=:0 \\
"$KIOSK_DIR/node_modules/electron/dist/electron" \\
/tmp/hotspot-notification.html &
fi
exit 0
EOF
sudo chmod +x "$EMERGENCY_HOTSPOT_SCRIPT"
sudo mkdir -p "$SYSTEMD_DIR"
sudo tee "$SYSTEMD_DIR/kiosk-emergency-hotspot.service" > /dev/null <<UNITEOF
[Unit]
Description=Kiosk Emergency Hotspot
After=network.target lightdm.service
Wants=network.target
[Service]
Type=oneshot
ExecStart=${EMERGENCY_HOTSPOT_SCRIPT}
RemainAfterExit=yes
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
UNITEOF
sudo systemctl daemon-reload 2>/dev/null || true
# Enable only, not start: this is a boot-time oneshot that waits 60s
# and checks connectivity - starting it right now would just run that
# wait/check immediately, which isn't what "configure" means here.
if ! sudo systemctl enable kiosk-emergency-hotspot.service 2>/dev/null; then
log_warning "Hotspot files written, but 'systemctl enable' failed - check 'systemctl status kiosk-emergency-hotspot.service'"
fi
echo
log_success "Emergency hotspot configured"
echo " SSID: $hotspot_ssid"
echo " Password: $hotspot_pass"
echo " IP: $hotspot_ip"
echo
echo "Hotspot auto-starts if no internet is detected 60 seconds after boot."
pause
}
action_disable_emergency_hotspot() {
echo
ask_yes_no "Disable emergency hotspot?" "n" || { echo "Cancelled"; pause; return; }
emergency_hotspot_do_disable
pause
}
# Shared with Complete Uninstall - same reasoning as cups_do_uninstall.
emergency_hotspot_do_disable() {
sudo systemctl stop kiosk-emergency-hotspot.service 2>/dev/null || true
sudo systemctl disable kiosk-emergency-hotspot.service 2>/dev/null || true
sudo rm -f "$SYSTEMD_DIR/kiosk-emergency-hotspot.service"
sudo rm -f "$EMERGENCY_HOTSPOT_SCRIPT"
sudo systemctl daemon-reload 2>/dev/null || true
log_success "Emergency hotspot disabled"
}
+46
View File
@@ -0,0 +1,46 @@
#!/bin/bash
################################################################################
# menus/advanced_factory_reset.sh - "Factory Reset" (Advanced): wipe
# config.json back to script defaults without touching anything else.
#
# Deliberately narrow - this only removes $CONFIG_PATH. Installed addons
# (CUPS, LMS, VPNs, etc.), the kiosk user, and the system itself are left
# alone; that's what Complete Uninstall is for.
#
# Depends on: lib/menu.sh, lib/config.sh being sourced first.
################################################################################
advanced_factory_reset_status() {
if sudo -u "$KIOSK_USER" test -f "$CONFIG_PATH" 2>/dev/null; then
echo "Config: $CONFIG_PATH exists"
else
echo "Config: not found (already at defaults)"
fi
}
advanced_factory_reset_menu_builder() {
MENU_LABELS=("Reset configuration to defaults")
MENU_HANDLERS=(action_factory_reset)
}
advanced_factory_reset_menu() {
run_menu "FACTORY RESET" advanced_factory_reset_menu_builder advanced_factory_reset_status
}
################################################################################
# Actions
################################################################################
action_factory_reset() {
echo
echo "This resets $CONFIG_PATH to defaults - sites, schedules,"
echo "password protection, and every other setting stored there are"
echo "cleared. Installed addons (CUPS, LMS, VPNs, etc.) are not touched."
echo
ask_yes_no "Continue?" "n" || { echo "Cancelled"; pause; return; }
sudo -u "$KIOSK_USER" rm -f "$CONFIG_PATH"
log_success "Configuration reset - reconfigure via Core Settings"
pause
}
+144
View File
@@ -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
}
+127
View File
@@ -0,0 +1,127 @@
#!/bin/bash
################################################################################
# menus/advanced_virtual_consoles.sh - "Virtual Consoles" (Advanced): toggle
# Ctrl+Alt+F1-F8 terminal login access for troubleshooting.
#
# Real system state: masks/unmasks the getty@ttyN systemd units and writes
# a fixed-path X11 server-flags file. Neither is relocatable (X11 only
# reads /etc/X11/xorg.conf.d/, and getty units are always system units),
# so tests use full command-level `sudo` stubbing, same approach as CUPS.
#
# Depends on: lib/menu.sh, lib/config.sh being sourced first.
################################################################################
vconsoles_are_disabled() {
local getty_masked=false
local vt_switch_disabled=false
if systemctl is-masked --quiet getty@tty1.service 2>/dev/null; then
getty_masked=true
fi
if [[ -f /etc/X11/xorg.conf.d/10-serverflags.conf ]] && \
grep -q 'Option.*"DontVTSwitch".*"true"' /etc/X11/xorg.conf.d/10-serverflags.conf 2>/dev/null; then
vt_switch_disabled=true
fi
[[ "$getty_masked" == "true" || "$vt_switch_disabled" == "true" ]]
}
advanced_virtual_consoles_status() {
if vconsoles_are_disabled; then
echo "Virtual consoles: Disabled"
else
echo "Virtual consoles: Enabled"
fi
}
advanced_virtual_consoles_menu_builder() {
if vconsoles_are_disabled; then
MENU_LABELS=("Enable virtual consoles (Ctrl+Alt+F1-F8 for manual login)")
MENU_HANDLERS=(action_enable_virtual_consoles)
else
MENU_LABELS=("Disable virtual consoles (more secure, kiosk only)")
MENU_HANDLERS=(action_disable_virtual_consoles)
fi
}
advanced_virtual_consoles_menu() {
run_menu "VIRTUAL CONSOLES" advanced_virtual_consoles_menu_builder advanced_virtual_consoles_status
}
################################################################################
# Actions
################################################################################
action_enable_virtual_consoles() {
echo
echo "Enabling virtual consoles..."
for i in {1..8}; do
sudo systemctl unmask "getty@tty${i}.service" 2>/dev/null || true
done
sudo systemctl daemon-reload 2>/dev/null || true
sudo mkdir -p /etc/X11/xorg.conf.d
sudo tee /etc/X11/xorg.conf.d/10-serverflags.conf > /dev/null <<'EOF'
Section "ServerFlags"
# Disable Ctrl+Alt+Backspace (X server kill)
Option "DontZap" "true"
# ALLOW VT switching (Ctrl+Alt+F1-F12)
Option "DontVTSwitch" "false"
# Don't allow clients to disconnect on exit
Option "AllowClosedownGrabs" "false"
EndSection
EOF
log_success "Virtual consoles enabled"
echo " Access with Ctrl+Alt+F1 through Ctrl+Alt+F8"
echo " (Ctrl+Alt+F7 typically returns to the kiosk)"
echo
if ask_yes_no "Restart kiosk display now to apply?" "n"; then
sudo systemctl restart lightdm
else
log_warning "Remember to restart: sudo systemctl restart lightdm"
fi
pause
}
action_disable_virtual_consoles() {
echo
ask_yes_no "Disable all virtual consoles?" "n" || { echo "Cancelled"; pause; return; }
echo "Disabling virtual consoles..."
for i in {1..8}; do
sudo systemctl mask "getty@tty${i}.service" 2>/dev/null || true
done
sudo systemctl daemon-reload 2>/dev/null || true
sudo mkdir -p /etc/X11/xorg.conf.d
sudo tee /etc/X11/xorg.conf.d/10-serverflags.conf > /dev/null <<'EOF'
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
EOF
log_success "Virtual consoles disabled"
echo " You can re-enable them from this menu at any time."
echo
if ask_yes_no "Restart kiosk display now to apply?" "n"; then
sudo systemctl restart lightdm
else
log_warning "Remember to restart: sudo systemctl restart lightdm"
fi
pause
}
+259
View File
@@ -0,0 +1,259 @@
#!/bin/bash
################################################################################
# menus/clone_settings.sh - "Clone Settings" (Advanced): export the portable
# parts of this kiosk's configuration to a JSON file, and apply that file
# to other already-installed kiosks - for standing up several kiosks that
# should share the same sites/settings.
#
# This is deliberately an MVP, not the legacy Export/Import Settings
# redesigned 1:1. It moves only what's actually safe to copy between
# machines automatically:
# - config.json's portable fields (sites, display/touch, navigation,
# lockout, password protection) - plain data, no machine binding.
# - Which addons were present at export time, as an informational
# checklist on apply - NOT automated installation. That's a
# deliberately separate, bigger follow-up (each addon would need a
# non-interactive install variant, mirroring the *_do_uninstall
# helpers Complete Uninstall already composes).
#
# Explicitly NOT exported, because copying them would be actively wrong,
# not just incomplete:
# - Authelia credentials: encrypted with a key derived from this
# machine's /etc/machine-id (addon_authelia.sh) - decrypts to
# garbage on any other machine.
# - WireGuard/VPN identity: a private key is that device's identity;
# reusing one across machines is a peer conflict, not a saving.
# - Asterisk Intercom's SIP extension: most PBXes reject two
# simultaneous registrations to the same extension.
# Apply prints all three as an explicit "needs a human" checklist rather
# than silently skipping them.
#
# Depends on: lib/menu.sh, lib/config.sh, and every menus/addon_*.sh
# being sourced first (for the *_is_installed detection helpers).
################################################################################
clone_settings_status() {
echo "Exports/applies sites, display, navigation, and lockout settings"
echo "between already-installed kiosks. Addon credentials that are"
echo "bound to one machine (Authelia, WireGuard, Asterisk Intercom)"
echo "are never copied - see the checklist after applying a profile."
}
clone_settings_menu_builder() {
MENU_LABELS=("Export settings" "Apply settings (clone)")
MENU_HANDLERS=(action_export_clone_settings action_apply_clone_settings)
}
clone_settings_menu() {
run_menu "CLONE SETTINGS" clone_settings_menu_builder clone_settings_status
}
################################################################################
# Helpers
################################################################################
# JSON array of addon identifiers currently present on this machine.
clone_detect_addons() {
local addons=()
cups_is_installed 2>/dev/null && addons+=("cups")
lms_is_installed 2>/dev/null && addons+=("lms")
squeezelite_is_installed 2>/dev/null && addons+=("squeezelite")
is_service_active x11vnc 2>/dev/null && addons+=("vnc")
command -v wg &>/dev/null && addons+=("wireguard")
command -v tailscale &>/dev/null && addons+=("tailscale")
command -v netbird &>/dev/null && addons+=("netbird")
baresip_is_installed 2>/dev/null && addons+=("asterisk_intercom")
webui_is_installed 2>/dev/null && addons+=("webui")
if sudo -u "$KIOSK_USER" test -f "$CONFIG_PATH" 2>/dev/null; then
local authelia_url
authelia_url=$(sudo -u "$KIOSK_USER" jq -r '.autheliaURL // ""' "$CONFIG_PATH" 2>/dev/null || true)
[[ -n "$authelia_url" ]] && addons+=("authelia")
fi
if [[ "${#addons[@]}" -eq 0 ]]; then
echo "[]"
else
printf '%s\n' "${addons[@]}" | jq -R . | jq -s . || echo "[]"
fi
}
################################################################################
# Actions
################################################################################
action_export_clone_settings() {
echo
if ! sudo -u "$KIOSK_USER" test -f "$CONFIG_PATH" 2>/dev/null; then
log_error "config.json not found at $CONFIG_PATH - configure sites/settings first"
pause
return 1
fi
local out_path
out_path=$(ask_text "Export profile to" "$HOME/kiosk-clone-settings.json")
local raw_config
raw_config=$(sudo -u "$KIOSK_USER" cat "$CONFIG_PATH" 2>/dev/null)
if ! echo "$raw_config" | jq empty 2>/dev/null; then
log_error "config.json is not valid JSON - cannot export"
pause
return 1
fi
local settings
settings=$(echo "$raw_config" | jq 'del(.autheliaURL, .autheliaUsername, .autheliaEncryptedPassword)')
local addons_present
addons_present=$(clone_detect_addons)
jq -n \
--argjson settings "$settings" \
--argjson addons "$addons_present" \
--arg script_version "${SCRIPT_VERSION:-unknown}" \
'{profile_version: 1, script_version: $script_version, settings: $settings, addons_present: $addons}' \
> "$out_path"
log_success "Settings exported to $out_path"
echo
echo "Included: sites, display/touch/navigation settings, lockout,"
echo "password protection (SHA-256 hash only)."
echo
echo "NOT included (needs fresh setup on each new device):"
echo " - Authelia credentials (encrypted per-machine, won't decrypt elsewhere)"
echo " - WireGuard/VPN keys (each device needs its own identity)"
echo " - Asterisk Intercom extension (most PBXes reject duplicate registrations)"
pause
}
action_apply_clone_settings() {
echo
local in_path
in_path=$(ask_text "Profile file to apply" "")
if [[ -z "$in_path" ]]; then
echo "Cancelled"
pause
return
fi
if [[ ! -f "$in_path" ]]; then
log_error "File not found: $in_path"
pause
return 1
fi
if ! jq empty "$in_path" 2>/dev/null; then
log_error "Not valid JSON: $in_path"
pause
return 1
fi
local settings addons_present
settings=$(jq -c '.settings // {}' "$in_path" || echo '{}')
addons_present=$(jq -r '.addons_present[]? // empty' "$in_path" || true)
echo "Profile summary:"
echo " Sites: $(echo "$settings" | jq '.tabs | length // 0')"
echo " Addons expected: $(echo "$addons_present" | tr '\n' ' ')"
echo
ask_yes_no "Apply this profile? This overwrites current sites/display/lockout settings." "n" || { echo "Cancelled"; pause; return; }
sudo mkdir -p "$KIOSK_DIR"
sudo chown -R "$KIOSK_USER:$KIOSK_USER" "$KIOSK_DIR"
local existing="{}"
if sudo -u "$KIOSK_USER" test -f "$CONFIG_PATH" 2>/dev/null; then
existing=$(sudo -u "$KIOSK_USER" cat "$CONFIG_PATH" 2>/dev/null)
echo "$existing" | jq empty 2>/dev/null || existing="{}"
fi
# Merge, not replace - same reasoning as save_config: this machine's
# own Authelia fields (never in the exported settings blob) must
# survive an apply untouched. Guarded: $settings came from an
# external file - a corrupted/hand-edited profile whose "settings"
# key isn't a JSON object must not be allowed to crash the session.
local merged
if ! merged=$(echo "$existing" | jq --argjson s "$settings" '. + $s' 2>/dev/null); then
log_error "Profile's settings are not a valid JSON object - nothing was changed"
pause
return 1
fi
local tmp
tmp=$(mktemp)
echo "$merged" > "$tmp"
sudo -u "$KIOSK_USER" bash -c "cat > '$CONFIG_PATH'" < "$tmp"
sudo -u "$KIOSK_USER" chmod 644 "$CONFIG_PATH"
rm -f "$tmp"
log_success "Settings applied"
echo
echo "Addon checklist (from the exported profile):"
local missing_any=false
if [[ -z "$addons_present" ]]; then
echo " (profile recorded no addons)"
fi
while IFS= read -r addon; do
[[ -z "$addon" ]] && continue
case "$addon" in
cups)
if cups_is_installed; then
echo " [x] CUPS Printing - already installed"
else
echo " [ ] CUPS Printing - not installed, install via Addons"
missing_any=true
fi ;;
lms)
if lms_is_installed; then
echo " [x] LMS Server - already installed"
else
echo " [ ] LMS Server - not installed, install via Addons"
missing_any=true
fi ;;
squeezelite)
if squeezelite_is_installed; then
echo " [x] Squeezelite Player - already installed"
else
echo " [ ] Squeezelite Player - not installed, install via Addons"
missing_any=true
fi ;;
vnc)
if is_service_active x11vnc; then
echo " [x] VNC - already installed"
else
echo " [ ] VNC - not installed, install via Addons"
missing_any=true
fi ;;
wireguard)
echo " [!] WireGuard - needs a NEW keypair/peer on this device, never clone the key" ;;
tailscale)
if command -v tailscale &>/dev/null; then
echo " [x] Tailscale - installed (connect with a reusable auth key if not yet connected)"
else
echo " [ ] Tailscale - not installed, install via Addons"
missing_any=true
fi ;;
netbird)
if command -v netbird &>/dev/null; then
echo " [x] Netbird - installed (connect with a reusable setup key if not yet connected)"
else
echo " [ ] Netbird - not installed, install via Addons"
missing_any=true
fi ;;
asterisk_intercom)
echo " [!] Asterisk Intercom - needs its own SIP extension on this device" ;;
authelia)
echo " [!] Authelia - needs a fresh login on this device" ;;
*)
echo " [?] $addon - unrecognized entry in profile" ;;
esac
done <<< "$addons_present"
if $missing_any; then
echo
echo "Install anything marked \"not installed\" above via the Addons menu."
fi
pause
}
+154
View File
@@ -0,0 +1,154 @@
#!/bin/bash
################################################################################
# menus/complete_uninstall.sh - "Complete Uninstall" (Core Settings): full
# teardown, returning the machine to its pre-kiosk state.
#
# Composed from every other addon's own silent uninstall helper
# (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,
# 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/
# LightDM/Openbox, polkit rules, leftover systemd units - are handled
# directly below, same as the legacy script.
#
# Ordering matters: every addon teardown runs before the kiosk user is
# removed, because asterisk_intercom_do_uninstall still needs
# `id -u "$KIOSK_USER"` to resolve that user's systemd --user session.
#
# After this runs, the kiosk user (and therefore is_kiosk_installed) is
# gone - install.sh itself will refuse to start against this machine
# again until a fresh install re-provisions it. That's intentional:
# there is nothing left here for this tool to manage.
#
# Depends on: lib/menu.sh, lib/config.sh, and every menus/addon_*.sh /
# menus/power_schedule.sh / menus/advanced_emergency_hotspot.sh being
# sourced first (for the *_do_uninstall helpers above).
################################################################################
complete_uninstall_status() {
echo "⚠ Removes the kiosk user, every addon, and returns this machine"
echo " to its pre-kiosk state. Cannot be undone."
}
complete_uninstall_menu_builder() {
MENU_LABELS=("Completely uninstall the kiosk")
MENU_HANDLERS=(action_complete_uninstall)
}
complete_uninstall_menu() {
run_menu "COMPLETE UNINSTALL" complete_uninstall_menu_builder complete_uninstall_status
}
################################################################################
# Actions
################################################################################
action_complete_uninstall() {
echo
echo "⚠️ This will COMPLETELY REMOVE:"
echo " • Kiosk user and all data"
echo " • All kiosk configuration and sites"
echo " • All Electron/Node.js installations"
echo " • All browser caches and data"
echo " • CUPS printer system"
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"
echo
echo "⚠️ This CANNOT be undone!"
echo
local confirm
confirm=$(ask_text "Type UNINSTALL to confirm" "")
if [[ "$confirm" != "UNINSTALL" ]]; then
echo "Cancelled"
pause
return
fi
echo
echo "Beginning complete uninstall..."
echo "[1/12] Stopping kiosk display..."
sudo systemctl stop lightdm 2>/dev/null || true
echo "[2/12] Removing addons..."
cups_do_uninstall
vnc_do_uninstall
wireguard_do_uninstall
tailscale_do_uninstall
netbird_do_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
emergency_hotspot_do_disable
# Must come after every addon teardown above - Asterisk Intercom's
# helper still needs this user to resolve its systemd --user session.
echo "[4/12] Removing kiosk user..."
if id "$KIOSK_USER" &>/dev/null; then
sudo pkill -u "$KIOSK_USER" 2>/dev/null || true
sudo userdel -r "$KIOSK_USER" 2>/dev/null || true
log_success "Kiosk user removed"
fi
echo "[5/12] Removing kiosk files..."
sudo rm -rf "$KIOSK_DIR"
sudo rm -rf "$KIOSK_HOME"
echo "[6/12] Removing remaining systemd units..."
sudo rm -f "$SYSTEMD_DIR"/kiosk-*.service
sudo rm -f "$SYSTEMD_DIR"/kiosk-*.timer
sudo systemctl daemon-reload 2>/dev/null || true
echo "[7/12] Removing remaining scripts..."
sudo rm -f "$BIN_DIR"/kiosk-*
sudo rm -f /etc/udev/rules.d/99-kiosk-hotplug.rules
sudo udevadm control --reload-rules 2>/dev/null || true
echo "[8/12] Removing Node.js..."
sudo apt-get purge -y nodejs npm 2>/dev/null || true
sudo rm -rf /usr/local/lib/node_modules
sudo rm -rf /usr/local/bin/node
sudo rm -rf /usr/local/bin/npm
echo "[9/12] Removing LightDM and Openbox..."
sudo systemctl disable lightdm 2>/dev/null || true
sudo apt-get purge -y lightdm openbox 2>/dev/null || true
echo "[10/12] Removing polkit rules..."
sudo rm -f "$POLKIT_DIR/kiosk-power.pkla"
sudo rm -f "$POLKIT_DIR/kiosk-printing.pkla"
echo "[11/12] Re-enabling virtual consoles..."
for i in {1..8}; do
sudo systemctl unmask "getty@tty${i}.service" 2>/dev/null || true
done
sudo systemctl daemon-reload 2>/dev/null || true
echo "[12/12] Cleaning up packages..."
sudo apt-get autoremove -y 2>/dev/null || true
sudo apt-get autoclean 2>/dev/null || true
echo
log_success "Kiosk completely uninstalled"
echo "The system has been returned to its pre-kiosk state."
echo "You may want to reboot to ensure all changes take effect."
echo
if ask_yes_no "Reboot now?" "n"; then
echo "Rebooting in 3 seconds..."
sleep 3
sudo reboot
fi
}
+244
View File
@@ -0,0 +1,244 @@
#!/bin/bash
################################################################################
# menus/diagnostics.sh - "Diagnostics" menu (from the legacy Advanced menu).
#
# A deliberate change of pace after Sites/WiFi/Power: everything here is
# read-only (system/audio status, log tailing, ping+DNS) except one
# optional "play a test sound?" prompt, so there's no destructive-action
# risk profile to design around. Straight port, using $KIOSK_USER/
# $KIOSK_HOME instead of the legacy code's mix of the variable and a
# hardcoded "kiosk" literal.
#
# Only 4 of the legacy Advanced menu's 12 items are here (System
# Diagnostics, View Logs, Audio Diagnostics, Network Test) - Manual
# Electron Update, Factory Reset, Export/Import Settings, Emergency
# Hotspot, and Fix Blank Screen are mutating/destructive and belong with
# a later, more careful pass (some, like Manual Electron Update, share
# Upgrade's issue of being coupled to the legacy script's own
# self-extraction mechanism - see ubuntu-based-kiosk.sh's changelog for
# why Upgrade/Reinstall/Uninstall aren't migrated yet either).
#
# Depends on: lib/menu.sh, lib/config.sh being sourced first.
################################################################################
diagnostics_menu_builder() {
MENU_LABELS=("System status" "View logs" "Audio diagnostics" "Network test")
MENU_HANDLERS=(action_system_diagnostics view_logs_menu action_audio_diagnostics action_network_test)
}
diagnostics_menu() {
run_menu "DIAGNOSTICS" diagnostics_menu_builder
}
################################################################################
# System status
################################################################################
action_system_diagnostics() {
clear
echo " ═══ SYSTEM DIAGNOSTICS ═══"
echo
echo "=== Kiosk Status ==="
systemctl status lightdm --no-pager -l 2>&1 | head -20 || true
echo
echo "=== Audio Status ==="
sudo -u "$KIOSK_USER" pactl info 2>/dev/null | grep -E "Server|User" || echo "Not running"
echo
echo "=== Network ==="
echo "IP: $(get_ip_address)"
echo "VPN: $(get_vpn_ips)"
echo
pause
}
################################################################################
# Logs
################################################################################
view_logs_menu_builder() {
MENU_LABELS=("Electron log (last 50 lines)" "LightDM log (last 50 lines)" "System journal (last 100 lines)")
MENU_HANDLERS=(action_view_electron_log action_view_lightdm_log action_view_journal)
}
view_logs_menu() {
run_menu "VIEW LOGS" view_logs_menu_builder
}
action_view_electron_log() {
echo
if sudo test -f "$KIOSK_HOME/electron.log"; then
sudo tail -50 "$KIOSK_HOME/electron.log" || true
else
echo "No electron log found yet"
fi
pause
}
action_view_lightdm_log() {
echo
sudo tail -50 /var/log/lightdm/lightdm.log 2>&1 || echo "No lightdm log found"
pause
}
action_view_journal() {
echo
sudo journalctl -n 100 || log_error "Could not read the system journal"
pause
}
################################################################################
# Audio diagnostics
################################################################################
audio_diagnostics_pactl() {
sudo -u "$KIOSK_USER" XDG_RUNTIME_DIR="/run/user/$(id -u "$KIOSK_USER")" pactl "$@"
}
action_audio_diagnostics() {
clear
echo "═══ AUDIO DIAGNOSTICS ═══"
echo
local issue_found=false
echo "[1/8] Checking audio hardware..."
if lspci 2>/dev/null | grep -i audio || lsusb 2>/dev/null | grep -i audio; then
log_success "Audio hardware detected"
lspci 2>/dev/null | grep -i audio || true
lsusb 2>/dev/null | grep -i audio | head -3 || true
else
log_error "No audio hardware detected"
issue_found=true
fi
echo
echo "[2/8] Checking ALSA devices..."
if aplay -l &>/dev/null; then
log_success "ALSA devices found"
aplay -l 2>/dev/null | grep -E "^card|device" || true
else
log_error "No ALSA devices"
issue_found=true
fi
echo
echo "[3/8] Checking PipeWire status..."
local pipewire_running=false
if audio_diagnostics_pactl info &>/dev/null; then
log_success "PipeWire accessible"
pipewire_running=true
audio_diagnostics_pactl info 2>/dev/null | grep -E "Server|User|Host" || true
else
log_error "PipeWire not accessible to kiosk user"
issue_found=true
echo " Try: sudo -u ${KIOSK_USER} systemctl --user start pipewire pipewire-pulse"
fi
echo
if $pipewire_running; then
echo "[4/8] Checking audio sinks..."
local sinks
sinks=$(audio_diagnostics_pactl list sinks short 2>/dev/null) || true
if [[ -n "$sinks" ]]; then
echo "$sinks"
local default_sink
default_sink=$(audio_diagnostics_pactl get-default-sink 2>/dev/null || echo "none")
echo "Default: $default_sink"
else
log_error "No audio sinks found"
issue_found=true
fi
echo
echo "[5/8] Checking active streams..."
local sink_inputs
sink_inputs=$(audio_diagnostics_pactl list sink-inputs short 2>/dev/null) || true
if [[ -n "$sink_inputs" ]]; then
echo "Active streams:"
echo "$sink_inputs"
else
echo "No active streams"
fi
echo
else
echo "[4/8] Skipped - PipeWire not running"
echo "[5/8] Skipped - PipeWire not running"
echo
fi
echo "[6/8] Checking Squeezelite..."
if systemctl is-active --quiet squeezelite; then
log_success "Squeezelite running"
if $pipewire_running; then
local sq_pid
sq_pid=$(pgrep -f squeezelite | head -1) || true
if [[ -n "$sq_pid" ]]; then
if audio_diagnostics_pactl list sink-inputs 2>/dev/null | grep -q "application.process.id = \"$sq_pid\""; then
log_success "Squeezelite connected to audio"
else
log_warning "Squeezelite NOT connected to audio sink"
issue_found=true
fi
fi
fi
else
echo "Squeezelite not running"
fi
echo
if $pipewire_running; then
echo "[7/8] Checking volume..."
local volume muted
volume=$(audio_diagnostics_pactl get-sink-volume @DEFAULT_SINK@ 2>/dev/null | grep -oE '[0-9]+%' | head -1 || echo "unknown")
muted=$(audio_diagnostics_pactl get-sink-mute @DEFAULT_SINK@ 2>/dev/null || echo "unknown")
echo "Volume: $volume"
echo "Muted: $muted"
else
echo "[7/8] Skipped - PipeWire not running"
fi
echo
echo "[8/8] Audio test..."
if ask_yes_no "Play test sound?" "n" && $pipewire_running; then
echo "Playing beep..."
audio_diagnostics_pactl_play_test
fi
echo
echo "═══════════════════════════════"
if $issue_found; then
echo "⚠️ ISSUES DETECTED - See above"
else
echo "✓ All checks passed"
fi
echo "═══════════════════════════════"
pause
}
audio_diagnostics_pactl_play_test() {
sudo -u "$KIOSK_USER" XDG_RUNTIME_DIR="/run/user/$(id -u "$KIOSK_USER")" paplay /usr/share/sounds/alsa/Front_Center.wav 2>/dev/null || \
sudo -u "$KIOSK_USER" XDG_RUNTIME_DIR="/run/user/$(id -u "$KIOSK_USER")" speaker-test -t sine -f 1000 -l 1 2>/dev/null || \
echo "No test available"
}
################################################################################
# Network test
################################################################################
action_network_test() {
echo
echo " ═══ NETWORK TEST ═══"
echo
echo "Ping test..."
ping -c 4 8.8.8.8 || log_error "Ping failed"
echo
echo "DNS test..."
nslookup google.com || log_error "DNS lookup failed"
pause
}
+133
View File
@@ -0,0 +1,133 @@
#!/bin/bash
################################################################################
# menus/display.sh - "Display & Interaction" menu.
#
# Second menu migrated off the old single-file installer, folding together
# three small settings screens that used to be separate Core Settings
# entries (Touch controls, Navigation security, Optional Features). All
# three are simple scalar/boolean fields on config.json, so this is a
# deliberately different shape from menus/sites.sh's list CRUD - a toggle
# list where each entry shows its current value and flips/edits itself,
# saving immediately (same immediate-save pattern as Sites, so behavior
# stays consistent no matter which menu you're in).
#
# Depends on: lib/menu.sh, lib/config.sh being sourced first.
################################################################################
display_status() {
echo "Touch gesture mode: $SWIPE_MODE"
echo "Link navigation: $ALLOW_NAVIGATION"
echo "Pause button: $(onoff "$ENABLE_PAUSE_BUTTON")"
echo "Keyboard button: $(onoff "$ENABLE_KEYBOARD_BUTTON")"
echo "Navigation button: $(onoff "$ENABLE_NAV_BUTTON")"
}
display_menu_builder() {
MENU_LABELS=(
"Touch gesture mode (currently: $SWIPE_MODE)"
"Link navigation security (currently: $ALLOW_NAVIGATION)"
"Toggle pause button (currently: $(onoff "$ENABLE_PAUSE_BUTTON"))"
"Toggle on-screen keyboard button (currently: $(onoff "$ENABLE_KEYBOARD_BUTTON"))"
"Toggle navigation/help button (currently: $(onoff "$ENABLE_NAV_BUTTON"))"
)
MENU_HANDLERS=(
action_set_touch_mode
action_set_navigation_security
action_toggle_pause_button
action_toggle_keyboard_button
action_toggle_nav_button
)
}
display_menu() {
load_existing_config
run_menu "DISPLAY & INTERACTION" display_menu_builder display_status
}
################################################################################
# Touch gesture mode
################################################################################
action_set_touch_mode() {
echo
echo "DUAL-DIRECTION (recommended for touchscreens):"
echo " 2-finger swipe = switch pages, 1-finger swipe = navigate within page"
echo "STANDARD (simpler):"
echo " 2-finger swipe = switch pages only, 1-finger swipes do nothing"
echo
local default
[[ "$SWIPE_MODE" == "dual" ]] && default="y" || default="n"
if ask_yes_no "Use dual-direction mode?" "$default"; then
SWIPE_MODE="dual"
else
SWIPE_MODE="standard"
fi
log_success "Touch mode: $SWIPE_MODE"
save_config
}
################################################################################
# Navigation security
################################################################################
action_set_navigation_security() {
echo
echo " r) restricted - only the loaded URL, no link clicking"
echo " s) same-origin - can click links within the same domain (recommended)"
echo " o) open - can click any link, browse anywhere"
echo
local choice
read -r -p "(r)estricted / (s)ame-origin / (o)pen [${ALLOW_NAVIGATION}]: " choice
case "${choice,,}" in
r) ALLOW_NAVIGATION="restricted" ;;
o) ALLOW_NAVIGATION="open" ;;
s) ALLOW_NAVIGATION="same-origin" ;;
"") ;; # keep current value
*) log_warning "Unrecognized choice, keeping '$ALLOW_NAVIGATION'" ;;
esac
log_success "Link navigation: $ALLOW_NAVIGATION"
save_config
}
################################################################################
# On-screen button toggles
################################################################################
action_toggle_pause_button() {
if [[ "$ENABLE_PAUSE_BUTTON" == "true" ]]; then
ENABLE_PAUSE_BUTTON="false"
log_warning "Pause button disabled"
else
ENABLE_PAUSE_BUTTON="true"
log_success "Pause button enabled"
fi
save_config
}
action_toggle_keyboard_button() {
if [[ "$ENABLE_KEYBOARD_BUTTON" == "true" ]]; then
ENABLE_KEYBOARD_BUTTON="false"
log_warning "On-screen keyboard button disabled"
else
ENABLE_KEYBOARD_BUTTON="true"
log_success "On-screen keyboard button enabled"
fi
save_config
}
action_toggle_nav_button() {
if [[ "$ENABLE_NAV_BUTTON" == "true" ]]; then
ENABLE_NAV_BUTTON="false"
log_warning "Navigation/help button disabled"
else
ENABLE_NAV_BUTTON="true"
log_success "Navigation/help button enabled"
fi
save_config
}
+88
View File
@@ -0,0 +1,88 @@
#!/bin/bash
################################################################################
# menus/hidden_pin.sh - "Hidden Site PIN" menu.
#
# Guards access to hidden pages (duration = -1, see menus/sites.sh) via a
# flat PIN file rather than config.json - a fourth shape for the framework
# to prove out (plain file, not JSON at all).
#
# Depends on: lib/menu.sh, lib/config.sh being sourced first.
################################################################################
hidden_pin_file() {
echo "$KIOSK_DIR/.jitsi-pin"
}
hidden_pin_status() {
local pin_file
pin_file=$(hidden_pin_file)
if sudo -u "$KIOSK_USER" test -f "$pin_file" 2>/dev/null; then
local current_pin
current_pin=$(sudo -u "$KIOSK_USER" cat "$pin_file" 2>/dev/null)
if [[ "$current_pin" == "NOPIN" ]]; then
echo "Current: no PIN (hidden pages open to anyone)"
else
echo "Current: PIN set (${#current_pin} digits)"
fi
else
echo "Current: not configured (default: 1234)"
fi
}
hidden_pin_menu_builder() {
MENU_LABELS=("Set new PIN (4-8 digits)" "Disable PIN (open access)" "Reset to default (1234)")
MENU_HANDLERS=(action_set_pin action_disable_pin action_reset_pin)
}
hidden_pin_menu() {
run_menu "HIDDEN SITE PIN" hidden_pin_menu_builder hidden_pin_status
}
################################################################################
# Actions
################################################################################
write_pin() {
local value="$1"
local pin_file
pin_file=$(hidden_pin_file)
sudo mkdir -p "$KIOSK_DIR"
echo "$value" | sudo -u "$KIOSK_USER" tee "$pin_file" > /dev/null
sudo -u "$KIOSK_USER" chmod 600 "$pin_file"
log_warning "Restart the kiosk display for this to take effect"
}
action_set_pin() {
echo
local new_pin confirm_pin
while true; do
read -r -p "Enter new PIN (4-8 digits): " new_pin
if [[ ! "$new_pin" =~ ^[0-9]{4,8}$ ]]; then
echo "❌ PIN must be 4-8 digits"
continue
fi
read -r -p "Confirm PIN: " confirm_pin
if [[ "$new_pin" == "$confirm_pin" ]]; then
write_pin "$new_pin"
log_success "PIN updated"
break
else
echo "❌ PINs don't match, try again"
fi
done
}
action_disable_pin() {
write_pin "NOPIN"
log_success "PIN disabled - hidden pages accessible without a PIN"
}
action_reset_pin() {
write_pin "1234"
log_success "PIN reset to default (1234)"
}
+187
View File
@@ -0,0 +1,187 @@
#!/bin/bash
################################################################################
# menus/lockout.sh - "Password Protection & Lockout" menu.
#
# Fifth menu migrated. Back to config.json (like Display), but with a
# sensitive field: the lockout password is SHA-256 hashed before it's
# ever written to disk (matching the Electron app's comparison logic in
# main.js) - LOCKOUT_PASSWORD must never hold plaintext.
#
# Unlike the legacy configure_password_protection wizard (walk through
# every question once, then one final "save these changes? y/n"), this
# follows the same immediate-save pattern as every other migrated menu:
# each action is a complete, standalone change. Re-running "Enable" to
# change your mind is just as easy as the old "discard changes" path,
# and there's no separate confirm-at-the-end step to forget.
#
# LOCKOUT_ACTIVE_START/LOCKOUT_ACTIVE_END are intentionally never touched
# here - the app doesn't act on them (see Readme "Configuration Files"),
# so lib/config.sh just carries whatever is already in config.json
# through unchanged.
#
# Depends on: lib/menu.sh, lib/config.sh being sourced first.
################################################################################
lockout_status() {
if [[ "$ENABLE_PASSWORD_PROTECTION" == "true" ]]; then
echo "Password protection: ENABLED"
echo "Inactivity lockout: ${LOCKOUT_TIMEOUT} minutes$( [[ "$LOCKOUT_TIMEOUT" == "0" ]] && echo " (disabled - boot/wake only)")"
if [[ -n "$LOCKOUT_AT_TIME" ]]; then
echo "Daily lock time: $LOCKOUT_AT_TIME"
else
echo "Daily lock time: not set"
fi
echo "Password on boot: $(onoff "$REQUIRE_PASSWORD_ON_BOOT")"
else
echo "Password protection: disabled"
fi
}
lockout_menu_builder() {
if [[ "$ENABLE_PASSWORD_PROTECTION" == "true" ]]; then
MENU_LABELS=(
"Change lockout password"
"Change inactivity lockout timeout (currently: ${LOCKOUT_TIMEOUT}m)"
"Set/clear daily lock time (currently: ${LOCKOUT_AT_TIME:-not set})"
"Toggle require password on boot (currently: $(onoff "$REQUIRE_PASSWORD_ON_BOOT"))"
"Disable password protection"
)
MENU_HANDLERS=(
action_change_password
action_change_timeout
action_change_daily_lock
action_toggle_boot_password
action_disable_protection
)
else
MENU_LABELS=("Enable password protection")
MENU_HANDLERS=(action_enable_protection)
fi
}
lockout_menu() {
load_existing_config
run_menu "PASSWORD PROTECTION & LOCKOUT" lockout_menu_builder lockout_status
}
################################################################################
# Shared helpers
################################################################################
# Prompts for a new password twice, hashes it, and assigns to
# LOCKOUT_PASSWORD. Returns 1 (without saving) if the user gives up.
prompt_and_hash_password() {
local pass1 pass2
while true; do
read -r -s -p "Enter password: " pass1
echo
read -r -s -p "Confirm password: " pass2
echo
if [[ -z "$pass1" ]]; then
echo "❌ Password cannot be empty"
continue
fi
if [[ "$pass1" != "$pass2" ]]; then
echo "❌ Passwords don't match, try again"
continue
fi
LOCKOUT_PASSWORD=$(echo -n "$pass1" | sha256sum | cut -d' ' -f1)
return 0
done
}
################################################################################
# Actions
################################################################################
action_enable_protection() {
echo
echo "Add password protection with automatic lockout:"
echo " • Blank screen after an inactivity period"
echo " • Password required to unlock"
echo " • Password required after display schedule wake-up"
echo " • Optional: lock at a specific time daily"
echo " • Optional: require password on system boot"
echo
echo "Set lockout password:"
prompt_and_hash_password
echo
echo "Session lockout time (minutes of inactivity)."
echo "Enter 0 to only require a password after display wake or boot."
LOCKOUT_TIMEOUT=$(ask_integer "Lockout timeout in minutes" "30" 0 1440)
echo
if ask_yes_no "Lock automatically at a specific time each day?" "n"; then
LOCKOUT_AT_TIME=$(ask_time "Time to lock (24-hour HH:MM)" "17:00")
else
LOCKOUT_AT_TIME=""
fi
echo
if ask_yes_no "Require password on system boot/power on?" "y"; then
REQUIRE_PASSWORD_ON_BOOT="true"
else
REQUIRE_PASSWORD_ON_BOOT="false"
fi
ENABLE_PASSWORD_PROTECTION="true"
log_success "Password protection enabled (lockout: ${LOCKOUT_TIMEOUT}m)"
save_config
}
action_disable_protection() {
ENABLE_PASSWORD_PROTECTION="false"
LOCKOUT_PASSWORD=""
LOCKOUT_TIMEOUT=0
LOCKOUT_AT_TIME=""
REQUIRE_PASSWORD_ON_BOOT="false"
log_success "Password protection disabled"
save_config
}
action_change_password() {
echo
prompt_and_hash_password
log_success "Password updated"
save_config
}
action_change_timeout() {
echo
echo "Session lockout time (minutes of inactivity)."
echo "Enter 0 to only require a password after display wake or boot."
LOCKOUT_TIMEOUT=$(ask_integer "Lockout timeout in minutes" "$LOCKOUT_TIMEOUT" 0 1440)
log_success "Lockout timeout: ${LOCKOUT_TIMEOUT}m"
save_config
}
action_change_daily_lock() {
echo
local default_prompt
[[ -n "$LOCKOUT_AT_TIME" ]] && default_prompt="y" || default_prompt="n"
if ask_yes_no "Lock automatically at a specific time each day?" "$default_prompt"; then
LOCKOUT_AT_TIME=$(ask_time "Time to lock (24-hour HH:MM)" "${LOCKOUT_AT_TIME:-17:00}")
log_success "Will lock at ${LOCKOUT_AT_TIME} daily"
else
LOCKOUT_AT_TIME=""
log_success "Daily lock time cleared"
fi
save_config
}
action_toggle_boot_password() {
if [[ "$REQUIRE_PASSWORD_ON_BOOT" == "true" ]]; then
REQUIRE_PASSWORD_ON_BOOT="false"
log_warning "Password on boot disabled"
else
REQUIRE_PASSWORD_ON_BOOT="true"
log_success "Password on boot enabled"
fi
save_config
}
+654
View File
@@ -0,0 +1,654 @@
#!/bin/bash
################################################################################
# menus/power_schedule.sh - "Power / Display / Quiet Hours" menu.
#
# Sixth menu migrated, and the biggest and riskiest so far: it writes
# systemd timers/services, a cron entry, and shell scripts that can power
# off the physical machine, blank the display, mute audio, and (via RTC)
# wake the machine back up on a schedule. Every write goes through
# $SYSTEMD_DIR / $CRON_D_DIR / $BIN_DIR (lib/config.sh) rather than
# hardcoded /etc/systemd/system, /etc/cron.d, /usr/local/bin, so tests can
# point them at a scratch directory - this file must never assume it's
# safe to actually mutate the real system just because it's running.
#
# Deliberately out of scope: the legacy dispatcher's "6. Test schedules &
# system" led into a shared diagnostics submenu (audio test, network
# test, keyboard test, ...) that isn't specific to scheduling and belongs
# with a future Advanced/Diagnostics migration instead. What *is* in
# scope - testing the schedule you just configured - stays here as the
# same inline "test now?" prompts the legacy menu already had.
#
# Bug fixed vs. the legacy configure_power_display_quiet: it refused to
# even open "Configure power schedule" when no RTC wake was detected,
# even though shutdown-only scheduling (configure_power_schedule's own
# fallback) never needed RTC in the first place. Also: none of shutdown
# time/wake time/display off/on/quiet start/end/custom Electron reload
# time were validated as HH:MM in the legacy menu (plain `read`, no
# format check) - a typo would silently produce a broken OnCalendar=
# value. All of those now go through ask_time.
#
# Depends on: lib/menu.sh, lib/config.sh being sourced first.
################################################################################
################################################################################
# Shared helpers
################################################################################
rtc_wake_available() {
[[ -w /sys/class/rtc/rtc0/wakealarm ]] || sudo test -w /sys/class/rtc/rtc0/wakealarm 2>/dev/null
}
timer_exists() {
[[ -f "$SYSTEMD_DIR/$1" ]]
}
timer_oncalendar() {
grep "^OnCalendar=" "$SYSTEMD_DIR/$1" 2>/dev/null | cut -d'=' -f2 | sed 's/\*-\*-\* //' | sed 's/:00$//'
}
################################################################################
# Top-level menu
################################################################################
power_schedule_status() {
local any=false
if timer_exists kiosk-shutdown.timer; then
any=true
local t; t=$(timer_oncalendar kiosk-shutdown.timer) || true
echo "Power: shutdown daily at ${t:-an unknown time}"
fi
if timer_exists kiosk-display-off.timer; then
any=true
echo "Display: off at $(timer_oncalendar kiosk-display-off.timer), on at $(timer_oncalendar kiosk-display-on.timer)"
fi
if timer_exists kiosk-quiet-start.timer; then
any=true
echo "Quiet: $(timer_oncalendar kiosk-quiet-start.timer) to $(timer_oncalendar kiosk-quiet-end.timer)"
fi
if timer_exists kiosk-electron-reload.timer; then
any=true
echo "Reload: enabled ($(timer_oncalendar kiosk-electron-reload.timer))"
fi
$any || echo "No schedules configured"
echo
if rtc_wake_available; then
echo "RTC wake: available (can schedule power on/off)"
else
echo "RTC wake: not available (display/quiet/reload scheduling still works)"
fi
}
power_schedule_menu_builder() {
MENU_LABELS=(
"Configure power schedule$(rtc_wake_available || echo ' (shutdown only - no RTC wake)')"
"Configure display schedule"
"Configure quiet hours"
"Configure Electron reload schedule"
"Remove all schedules"
)
MENU_HANDLERS=(
action_configure_power_schedule
action_configure_display_schedule
action_configure_quiet_hours
electron_reload_menu
action_remove_all_schedules
)
}
power_schedule_menu() {
run_menu "POWER / DISPLAY / QUIET HOURS" power_schedule_menu_builder power_schedule_status
}
################################################################################
# Power schedule
################################################################################
action_configure_power_schedule() {
echo
local rtc_ok=false
rtc_wake_available && rtc_ok=true
if $rtc_ok; then
echo "RTC wake capability detected - can schedule shutdown and wake."
else
echo "RTC wake not available - shutdown only, no auto-wake."
fi
echo
local shutdown_time wake_time=""
shutdown_time=$(ask_time "Shutdown time (24-hour HH:MM)" "22:00")
$rtc_ok && wake_time=$(ask_time "Wake time (24-hour HH:MM)" "06:00")
sudo systemctl stop kiosk-shutdown.timer 2>/dev/null || true
sudo systemctl disable kiosk-shutdown.timer 2>/dev/null || true
sudo rm -f "$SYSTEMD_DIR/kiosk-shutdown.service" "$SYSTEMD_DIR/kiosk-shutdown.timer"
sudo rm -f "$BIN_DIR/kiosk-power-off.sh" "$BIN_DIR/rtc-wake.sh"
sudo rm -f "$CRON_D_DIR/kiosk-rtc-wake"
sudo tee "$BIN_DIR/kiosk-power-off.sh" > /dev/null <<'EOF'
#!/bin/bash
logger "KIOSK: Scheduled shutdown initiated"
systemctl poweroff
EOF
sudo chmod +x "$BIN_DIR/kiosk-power-off.sh"
sudo tee "$SYSTEMD_DIR/kiosk-shutdown.service" > /dev/null <<EOF
[Unit]
Description=Kiosk Scheduled Shutdown
[Service]
Type=oneshot
ExecStart=${BIN_DIR}/kiosk-power-off.sh
EOF
sudo tee "$SYSTEMD_DIR/kiosk-shutdown.timer" > /dev/null <<EOF
[Unit]
Description=Kiosk Shutdown Timer
[Timer]
OnCalendar=*-*-* ${shutdown_time}:00
Persistent=true
[Install]
WantedBy=timers.target
EOF
if $rtc_ok && [[ -n "$wake_time" ]]; then
sudo tee "$BIN_DIR/rtc-wake.sh" > /dev/null <<'RTCSCRIPT'
#!/bin/bash
WAKE_TIME="$1"
CURRENT=$(date +%s)
WAKE=$(date -d "$WAKE_TIME" +%s)
# If wake time is earlier than current time, schedule for tomorrow
[[ $WAKE -le $CURRENT ]] && WAKE=$(date -d "tomorrow $WAKE_TIME" +%s)
# Clear existing alarm
echo 0 > /sys/class/rtc/rtc0/wakealarm 2>/dev/null || true
# Set new alarm
if echo $WAKE > /sys/class/rtc/rtc0/wakealarm 2>/dev/null; then
logger "KIOSK: RTC wake set for $(date -d @$WAKE '+%Y-%m-%d %H:%M:%S')"
echo "RTC wake set for $(date -d @$WAKE '+%Y-%m-%d %H:%M:%S')"
else
logger "KIOSK: ERROR - Failed to set RTC wake"
echo "ERROR: Failed to set RTC wake"
exit 1
fi
RTCSCRIPT
sudo chmod +x "$BIN_DIR/rtc-wake.sh"
local shutdown_hour="${shutdown_time%%:*}"
local shutdown_min="${shutdown_time##*:}"
local wake_min=$((10#$shutdown_min - 5))
local wake_hour=$((10#$shutdown_hour))
[[ $wake_min -lt 0 ]] && { wake_min=$((wake_min + 60)); wake_hour=$((wake_hour - 1)); }
[[ $wake_hour -lt 0 ]] && wake_hour=$((wake_hour + 24))
sudo tee "$CRON_D_DIR/kiosk-rtc-wake" > /dev/null <<EOF
# Set RTC wake alarm 5 minutes before shutdown
$wake_min $wake_hour * * * root ${BIN_DIR}/rtc-wake.sh "$wake_time" >> /var/log/kiosk-rtc.log 2>&1
EOF
log_info "RTC wake cron job created"
fi
if enable_and_start_units kiosk-shutdown.timer; then
log_success "Power schedule configured: shutdown at ${shutdown_time}$( [[ -n "$wake_time" ]] && echo ", wake at ${wake_time}")"
else
log_warning "Schedule files written, but systemctl enable/start failed - check 'systemctl status kiosk-shutdown.timer'"
fi
}
################################################################################
# Display schedule
################################################################################
action_configure_display_schedule() {
echo
if timer_exists kiosk-shutdown.timer; then
log_warning "Power shutdown configured at $(timer_oncalendar kiosk-shutdown.timer) - display will already be off by then"
echo
fi
local doff don
doff=$(ask_time "Display OFF time (24-hour HH:MM)" "22:00")
don=$(ask_time "Display ON time (24-hour HH:MM)" "06:00")
sudo systemctl stop kiosk-display-off.timer kiosk-display-on.timer 2>/dev/null || true
sudo systemctl disable kiosk-display-off.timer kiosk-display-on.timer 2>/dev/null || true
sudo rm -f "$SYSTEMD_DIR"/kiosk-display-off.{service,timer} "$SYSTEMD_DIR"/kiosk-display-on.{service,timer}
sudo rm -f "$BIN_DIR/kiosk-display-off.sh" "$BIN_DIR/kiosk-display-on.sh"
sudo tee "$BIN_DIR/kiosk-display-off.sh" > /dev/null <<EOF
#!/bin/bash
# Turn off display using multiple methods for reliability
export DISPLAY=:0
export XAUTHORITY=${KIOSK_HOME}/.Xauthority
logger "KIOSK: Display OFF script starting"
# Method 1: xset via kiosk user
sudo -u ${KIOSK_USER} DISPLAY=:0 XAUTHORITY=${KIOSK_HOME}/.Xauthority xset dpms force off 2>/dev/null && logger "KIOSK: xset dpms off success" || logger "KIOSK: xset dpms off failed"
# Method 2: vbetool (if available)
if command -v vbetool &>/dev/null; then
vbetool dpms off 2>/dev/null && echo "✓ vbetool off" || echo "✗ vbetool failed"
fi
# Method 3: Backlight control (laptops)
if [[ -d /sys/class/backlight ]]; then
for bl in /sys/class/backlight/*/brightness; do
if [[ -w "\$bl" ]]; then
echo 0 > "\$bl" 2>/dev/null && echo "✓ backlight off: \$bl" || echo "✗ backlight failed"
fi
done
fi
logger "KIOSK: Display turned OFF (scheduled)"
EOF
sudo chmod +x "$BIN_DIR/kiosk-display-off.sh"
sudo tee "$BIN_DIR/kiosk-display-on.sh" > /dev/null <<EOF
#!/bin/bash
# Turn on display using multiple methods for reliability
export DISPLAY=:0
export XAUTHORITY=${KIOSK_HOME}/.Xauthority
logger "KIOSK: Display ON script starting"
# Method 1: xset via kiosk user
sudo -u ${KIOSK_USER} DISPLAY=:0 XAUTHORITY=${KIOSK_HOME}/.Xauthority xset dpms force on 2>/dev/null && logger "KIOSK: xset dpms on success" || logger "KIOSK: xset dpms on failed"
# Method 2: vbetool (if available)
if command -v vbetool &>/dev/null; then
vbetool dpms on 2>/dev/null && echo "✓ vbetool on" || echo "✗ vbetool failed"
fi
# Method 3: Backlight control (laptops)
if [[ -d /sys/class/backlight ]]; then
for bl in /sys/class/backlight/*/brightness; do
if [[ -w "\$bl" ]]; then
cat "\${bl%/*}/max_brightness" > "\$bl" 2>/dev/null && echo "✓ backlight on: \$bl" || echo "✗ backlight failed"
fi
done
fi
# Method 4: Wake up input (move mouse)
sudo -u ${KIOSK_USER} DISPLAY=:0 XAUTHORITY=${KIOSK_HOME}/.Xauthority xdotool mousemove 1 1 2>/dev/null && logger "KIOSK: mouse wiggle success" || logger "KIOSK: mouse wiggle failed"
# Method 5: Signal Electron app to require password if enabled
sudo -u ${KIOSK_USER} touch ${KIOSK_DIR}/.display-wake 2>/dev/null && logger "KIOSK: password flag set" || logger "KIOSK: password flag failed"
logger "KIOSK: Display turned ON (scheduled)"
EOF
sudo chmod +x "$BIN_DIR/kiosk-display-on.sh"
sudo tee "$SYSTEMD_DIR/kiosk-display-off.service" > /dev/null <<EOF
[Unit]
Description=Kiosk Display Off
[Service]
Type=oneshot
ExecStart=${BIN_DIR}/kiosk-display-off.sh
StandardOutput=journal
StandardError=journal
EOF
sudo tee "$SYSTEMD_DIR/kiosk-display-on.service" > /dev/null <<EOF
[Unit]
Description=Kiosk Display On
[Service]
Type=oneshot
ExecStart=${BIN_DIR}/kiosk-display-on.sh
StandardOutput=journal
StandardError=journal
EOF
sudo tee "$SYSTEMD_DIR/kiosk-display-off.timer" > /dev/null <<EOF
[Unit]
Description=Kiosk Display Off Timer
[Timer]
OnCalendar=*-*-* ${doff}:00
Persistent=true
[Install]
WantedBy=timers.target
EOF
sudo tee "$SYSTEMD_DIR/kiosk-display-on.timer" > /dev/null <<EOF
[Unit]
Description=Kiosk Display On Timer
[Timer]
OnCalendar=*-*-* ${don}:00
Persistent=true
[Install]
WantedBy=timers.target
EOF
if enable_and_start_units kiosk-display-off.timer kiosk-display-on.timer; then
log_success "Display schedule configured: off at ${doff}, on at ${don}"
else
log_warning "Schedule files written, but systemctl enable/start failed - check 'systemctl status kiosk-display-off.timer'"
fi
echo
if ask_yes_no "Test display control now?" "n"; then
echo "Testing display OFF in 3 seconds..."
sleep 3
sudo "$BIN_DIR/kiosk-display-off.sh"
echo "Waiting 5 seconds..."
sleep 5
echo "Testing display ON..."
sudo "$BIN_DIR/kiosk-display-on.sh"
log_success "Display test complete"
fi
}
################################################################################
# Quiet hours
################################################################################
action_configure_quiet_hours() {
echo
timer_exists kiosk-shutdown.timer && echo "Power shutdown: $(timer_oncalendar kiosk-shutdown.timer)"
if timer_exists kiosk-display-off.timer; then
echo "Display: off at $(timer_oncalendar kiosk-display-off.timer), on at $(timer_oncalendar kiosk-display-on.timer)"
fi
echo
local qstart qend qmode
qstart=$(ask_time "Quiet hours start (24-hour HH:MM)" "22:00")
qend=$(ask_time "Quiet hours end (24-hour HH:MM)" "07:00")
echo
echo "What should be muted during quiet hours?"
echo " 1. All audio (mute system)"
echo " 2. Squeezelite only (stop music player)"
read -r -p "Choice [1]: " qmode
qmode="${qmode:-1}"
sudo systemctl stop kiosk-quiet-start.timer kiosk-quiet-end.timer 2>/dev/null || true
sudo systemctl disable kiosk-quiet-start.timer kiosk-quiet-end.timer 2>/dev/null || true
sudo rm -f "$SYSTEMD_DIR"/kiosk-quiet-start.{service,timer} "$SYSTEMD_DIR"/kiosk-quiet-end.{service,timer}
sudo rm -f "$BIN_DIR/kiosk-quiet-start.sh" "$BIN_DIR/kiosk-quiet-end.sh"
case "$qmode" in
2)
sudo tee "$BIN_DIR/kiosk-quiet-start.sh" > /dev/null <<'EOF'
#!/bin/bash
systemctl stop squeezelite 2>/dev/null
logger "KIOSK: Quiet hours started - Squeezelite stopped"
echo "✓ Quiet hours: Squeezelite stopped"
EOF
sudo tee "$BIN_DIR/kiosk-quiet-end.sh" > /dev/null <<'EOF'
#!/bin/bash
systemctl start squeezelite 2>/dev/null
logger "KIOSK: Quiet hours ended - Squeezelite started"
echo "✓ Quiet hours ended: Squeezelite started"
EOF
;;
*)
qmode=1
sudo tee "$BIN_DIR/kiosk-quiet-start.sh" > /dev/null <<'EOF'
#!/bin/bash
# Save current volume before muting
pactl get-sink-volume @DEFAULT_SINK@ | grep -oE '[0-9]+%' | head -1 | tr -d '%' > /tmp/kiosk-vol-backup 2>/dev/null || echo "100" > /tmp/kiosk-vol-backup
pactl set-sink-mute @DEFAULT_SINK@ 1 2>/dev/null
logger "KIOSK: Quiet hours started - all audio muted"
echo "✓ Quiet hours: All audio muted"
EOF
sudo tee "$BIN_DIR/kiosk-quiet-end.sh" > /dev/null <<'EOF'
#!/bin/bash
# Restore previous volume
VOL=$(cat /tmp/kiosk-vol-backup 2>/dev/null || echo "100")
pactl set-sink-mute @DEFAULT_SINK@ 0 2>/dev/null
pactl set-sink-volume @DEFAULT_SINK@ ${VOL}% 2>/dev/null
logger "KIOSK: Quiet hours ended - audio restored to ${VOL}%"
echo "✓ Quiet hours ended: Audio restored to ${VOL}%"
EOF
;;
esac
sudo chmod +x "$BIN_DIR/kiosk-quiet-start.sh" "$BIN_DIR/kiosk-quiet-end.sh"
sudo tee "$SYSTEMD_DIR/kiosk-quiet-start.service" > /dev/null <<EOF
[Unit]
Description=Kiosk Quiet Hours Start
[Service]
Type=oneshot
ExecStart=${BIN_DIR}/kiosk-quiet-start.sh
StandardOutput=journal
StandardError=journal
EOF
sudo tee "$SYSTEMD_DIR/kiosk-quiet-end.service" > /dev/null <<EOF
[Unit]
Description=Kiosk Quiet Hours End
[Service]
Type=oneshot
ExecStart=${BIN_DIR}/kiosk-quiet-end.sh
StandardOutput=journal
StandardError=journal
EOF
sudo tee "$SYSTEMD_DIR/kiosk-quiet-start.timer" > /dev/null <<EOF
[Unit]
Description=Kiosk Quiet Hours Start Timer
[Timer]
OnCalendar=*-*-* ${qstart}:00
Persistent=true
[Install]
WantedBy=timers.target
EOF
sudo tee "$SYSTEMD_DIR/kiosk-quiet-end.timer" > /dev/null <<EOF
[Unit]
Description=Kiosk Quiet Hours End Timer
[Timer]
OnCalendar=*-*-* ${qend}:00
Persistent=true
[Install]
WantedBy=timers.target
EOF
local mode_label="All audio muted"
[[ "$qmode" == "2" ]] && mode_label="Squeezelite stopped"
if enable_and_start_units kiosk-quiet-start.timer kiosk-quiet-end.timer; then
log_success "Quiet hours configured: ${qstart} to ${qend} (${mode_label})"
else
log_warning "Schedule files written, but systemctl enable/start failed - check 'systemctl status kiosk-quiet-start.timer'"
fi
echo
if ask_yes_no "Test quiet hours now?" "n"; then
echo "Testing quiet START..."
sudo "$BIN_DIR/kiosk-quiet-start.sh"
echo "Waiting 5 seconds..."
sleep 5
echo "Testing quiet END..."
sudo "$BIN_DIR/kiosk-quiet-end.sh"
log_success "Quiet hours test complete"
fi
}
################################################################################
# Electron reload schedule (its own small nested menu, mirroring the
# legacy configure_electron_reload's "configured vs not" dispatch)
################################################################################
electron_reload_menu_builder() {
if timer_exists kiosk-electron-reload.timer; then
MENU_LABELS=("Change schedule" "Disable automatic reload")
MENU_HANDLERS=(electron_reload_custom action_disable_electron_reload)
else
MENU_LABELS=("Daily at 3am" "Every 3 days at 3am" "Custom schedule")
MENU_HANDLERS=(action_electron_reload_daily action_electron_reload_every_3_days electron_reload_custom)
fi
}
electron_reload_status() {
if timer_exists kiosk-electron-reload.timer; then
echo "Automatic reload: enabled ($(timer_oncalendar kiosk-electron-reload.timer))"
else
echo "Automatic reload: not configured"
fi
}
electron_reload_menu() {
run_menu "ELECTRON RELOAD SCHEDULE" electron_reload_menu_builder electron_reload_status
}
# setup_electron_reload_timer SCHEDULE DESCRIPTION
# SCHEDULE is a systemd OnCalendar= expression, not just a time - unlike
# the shutdown/display/quiet timers above, so it isn't run through ask_time.
setup_electron_reload_timer() {
local schedule="$1"
local description="$2"
sudo systemctl stop kiosk-electron-reload.timer 2>/dev/null || true
sudo systemctl disable kiosk-electron-reload.timer 2>/dev/null || true
sudo rm -f "$SYSTEMD_DIR"/kiosk-electron-reload.{service,timer}
sudo rm -f "$BIN_DIR/kiosk-reload-electron"
sudo tee "$BIN_DIR/kiosk-reload-electron" > /dev/null <<'RELOADSCRIPT'
#!/bin/bash
logger "KIOSK: Scheduled Electron reload"
systemctl restart lightdm
RELOADSCRIPT
sudo chmod +x "$BIN_DIR/kiosk-reload-electron"
sudo tee "$SYSTEMD_DIR/kiosk-electron-reload.service" > /dev/null <<EOF
[Unit]
Description=Reload Electron App
[Service]
Type=oneshot
ExecStart=${BIN_DIR}/kiosk-reload-electron
EOF
sudo tee "$SYSTEMD_DIR/kiosk-electron-reload.timer" > /dev/null <<EOF
[Unit]
Description=Electron Reload Timer
[Timer]
OnCalendar=$schedule
Persistent=true
[Install]
WantedBy=timers.target
EOF
if enable_and_start_units kiosk-electron-reload.timer; then
log_success "Electron reload configured: $description"
else
log_warning "Schedule files written, but systemctl enable/start failed - check 'systemctl status kiosk-electron-reload.timer'"
fi
}
action_electron_reload_daily() {
setup_electron_reload_timer "*-*-* 03:00:00" "daily at 3am"
}
action_electron_reload_every_3_days() {
setup_electron_reload_timer "*-*-1,4,7,10,13,16,19,22,25,28,31 03:00:00" "every 3 days at 3am"
}
electron_reload_custom() {
echo
echo "Custom schedule options:"
echo " 1. Every X days at a specific time"
echo " 2. Daily at a custom time"
echo " 3. Specific weekday"
echo " 0. Cancel"
echo
local choice
choice=$(ask_integer "Choose" "0" 0 3)
[[ "$choice" == "0" ]] && { echo "Cancelled"; return; }
local schedule="" description=""
case "$choice" in
1)
local days time day_list=""
days=$(ask_integer "Reload every X days" "3" 1 31)
time=$(ask_time "Time (24-hour HH:MM)" "03:00")
for ((d = 1; d <= 31; d += days)); do
day_list="${day_list}${d},"
done
day_list="${day_list%,}"
schedule="*-*-${day_list} ${time}:00"
description="every ${days} days at ${time}"
;;
2)
local time
time=$(ask_time "Time (24-hour HH:MM)" "03:00")
schedule="*-*-* ${time}:00"
description="daily at ${time}"
;;
3)
local day time
echo "Days: Mon Tue Wed Thu Fri Sat Sun"
read -r -p "Enter day: " day
time=$(ask_time "Time (24-hour HH:MM)" "03:00")
schedule="${day} *-*-* ${time}:00"
description="every ${day} at ${time}"
;;
esac
setup_electron_reload_timer "$schedule" "$description"
}
action_disable_electron_reload() {
if ask_yes_no "Disable automatic Electron reload?" "n"; then
sudo systemctl stop kiosk-electron-reload.timer 2>/dev/null || true
sudo systemctl disable kiosk-electron-reload.timer 2>/dev/null || true
sudo rm -f "$SYSTEMD_DIR"/kiosk-electron-reload.{service,timer}
sudo rm -f "$BIN_DIR/kiosk-reload-electron"
sudo systemctl daemon-reload 2>/dev/null || true
log_success "Automatic Electron reload disabled"
fi
}
################################################################################
# Remove all
################################################################################
action_remove_all_schedules() {
echo
ask_yes_no "Remove ALL power/display/quiet/reload schedules?" "n" || { echo "Cancelled"; return; }
power_schedule_do_remove_all
}
# The actual removal, no prompt - shared with Complete Uninstall so that
# operation doesn't need to re-implement schedule teardown a second time.
power_schedule_do_remove_all() {
for timer in kiosk-shutdown kiosk-display-off kiosk-display-on kiosk-quiet-start kiosk-quiet-end kiosk-electron-reload; do
sudo systemctl stop "${timer}.timer" 2>/dev/null || true
sudo systemctl disable "${timer}.timer" 2>/dev/null || true
done
sudo rm -f "$SYSTEMD_DIR"/kiosk-shutdown.{service,timer}
sudo rm -f "$SYSTEMD_DIR"/kiosk-display-off.{service,timer} "$SYSTEMD_DIR"/kiosk-display-on.{service,timer}
sudo rm -f "$SYSTEMD_DIR"/kiosk-quiet-start.{service,timer} "$SYSTEMD_DIR"/kiosk-quiet-end.{service,timer}
sudo rm -f "$SYSTEMD_DIR"/kiosk-electron-reload.{service,timer}
sudo rm -f "$BIN_DIR/kiosk-power-off.sh" "$BIN_DIR/kiosk-display-off.sh" "$BIN_DIR/kiosk-display-on.sh"
sudo rm -f "$BIN_DIR/kiosk-quiet-start.sh" "$BIN_DIR/kiosk-quiet-end.sh"
sudo rm -f "$BIN_DIR/rtc-wake.sh" "$BIN_DIR/kiosk-reload-electron"
sudo rm -f "$CRON_D_DIR/kiosk-rtc-wake"
sudo systemctl daemon-reload 2>/dev/null || true
log_success "All schedules removed"
}
+363
View File
@@ -0,0 +1,363 @@
#!/bin/bash
################################################################################
# menus/sites.sh - "Sites & Page Timing" menu.
#
# First real menu built on lib/menu.sh + lib/config.sh, as a proof of
# concept for pulling menus out of the old 12k-line installer one at a
# time. Covers exactly what was asked for first: adding, deleting, and
# changing pages, and the timing (duration) of each.
#
# Depends on: lib/menu.sh, lib/config.sh being sourced first.
################################################################################
# Index of the page currently being edited by the nested "edit page" menu.
SITE_EDIT_IDX=""
################################################################################
# Small helpers
################################################################################
# Normalize whatever the user typed into a URL, same rules the old
# installer used: bare host -> https://, bare IP -> http://.
sites_parse_url() {
local raw="$1"
if [[ "$raw" =~ ^https?:// ]]; then
echo "$raw"
elif [[ "$raw" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+ ]]; then
echo "http://${raw}"
else
echo "https://${raw}"
fi
}
# Human label for a duration value: >0 seconds = auto-rotate, 0 = manual,
# -1 = hidden (PIN-gated).
sites_duration_label() {
local dur="$1"
if [[ "$dur" == "-1" ]]; then
echo "hidden"
elif [[ "$dur" == "0" ]]; then
echo "manual"
else
echo "auto-rotate ${dur}s"
fi
}
sites_display_label() {
local idx="$1"
local label="${URLS[$idx]}"
[[ -n "${NAMES[$idx]:-}" ]] && label="\"${NAMES[$idx]}\" - ${URLS[$idx]}"
echo "$label"
}
################################################################################
# Status line shown above the main Sites menu
################################################################################
sites_status() {
if [[ "${#URLS[@]}" -eq 0 ]]; then
echo "No pages configured yet."
return
fi
echo "Current pages:"
local has_rotation=false
for idx in "${!URLS[@]}"; do
local dur="${DURS[$idx]}"
local flags=""
[[ "$dur" != "0" && "$dur" != "-1" ]] && has_rotation=true
[[ -n "${USERS[$idx]:-}" ]] && flags+=" [auth]"
[[ "$HOME_TAB_INDEX" == "$idx" ]] && flags+=" [HOME]"
printf " %2d. %s (%s)%s\n" "$((idx + 1))" "$(sites_display_label "$idx")" "$(sites_duration_label "$dur")" "$flags"
done
echo
if $has_rotation; then
echo "Auto-rotation: active (pages with duration > 0)"
else
echo "Auto-rotation: off (all pages manual/hidden)"
fi
if [[ "$HOME_TAB_INDEX" != "-1" ]]; then
echo "Home page: #$((HOME_TAB_INDEX + 1)), ${INACTIVITY_TIMEOUT}s inactivity timeout"
else
echo "Home page: disabled"
fi
}
################################################################################
# Top-level Sites menu
################################################################################
sites_menu_builder() {
MENU_LABELS=("Add a page")
MENU_HANDLERS=(action_add_page)
if [[ "${#URLS[@]}" -gt 0 ]]; then
MENU_LABELS+=("Edit a page" "Delete a page")
MENU_HANDLERS+=(action_pick_and_edit_page action_delete_page)
fi
if [[ "${#URLS[@]}" -gt 1 ]]; then
MENU_LABELS+=("Reorder pages")
MENU_HANDLERS+=(action_reorder_page)
fi
if [[ "${#URLS[@]}" -gt 0 ]]; then
MENU_LABELS+=("Set/clear home page")
MENU_HANDLERS+=(action_set_home_page)
fi
}
sites_menu() {
load_existing_config
run_menu "SITES & PAGE TIMING" sites_menu_builder sites_status
}
################################################################################
# Add
################################################################################
action_add_page() {
echo
echo "Duration controls rotation:"
echo " > 0 = auto-rotates every X seconds"
echo " 0 = manual only (swipe/nav menu to reach it)"
echo " -1 = hidden (PIN-gated, F10 or 3-finger swipe)"
echo
local raw_url url dur name needs_auth user pass
read -r -p "URL: " raw_url
if [[ -z "$raw_url" ]]; then
echo "Cancelled"
return
fi
url=$(sites_parse_url "$raw_url")
dur=$(ask_integer "Duration in seconds (-1=hidden, 0=manual)" "180" -1 86400)
name=$(ask_text "Page name (optional, blank = show URL)" "")
user=""
pass=""
if ask_yes_no "Does this page need HTTP Basic Auth?" "n"; then
read -r -p " Username: " user
read -r -s -p " Password: " pass
echo
fi
URLS+=("$url")
DURS+=("$dur")
USERS+=("$user")
PASSES+=("$pass")
NAMES+=("$name")
log_success "Added: $url ($(sites_duration_label "$dur"))"
save_config
}
################################################################################
# Edit (nested menu on the selected page)
################################################################################
action_pick_and_edit_page() {
echo
for idx in "${!URLS[@]}"; do
echo " $((idx + 1)). $(sites_display_label "$idx")"
done
echo
local num
num=$(ask_integer "Edit which page? (0=cancel)" "0" 0 "${#URLS[@]}")
[[ "$num" == "0" ]] && return
SITE_EDIT_IDX=$((num - 1))
run_menu "EDIT PAGE #${num}" edit_page_menu_builder edit_page_status
}
edit_page_status() {
local idx="$SITE_EDIT_IDX"
echo "URL: ${URLS[$idx]}"
echo "Name: ${NAMES[$idx]:-(none)}"
echo "Timing: $(sites_duration_label "${DURS[$idx]}")"
if [[ -n "${USERS[$idx]:-}" ]]; then
echo "Basic auth: enabled (user: ${USERS[$idx]})"
else
echo "Basic auth: disabled"
fi
}
edit_page_menu_builder() {
MENU_LABELS=("Change URL" "Change name" "Change timing (duration)" "Change Basic Auth")
MENU_HANDLERS=(action_edit_url action_edit_name action_edit_duration action_edit_auth)
}
action_edit_url() {
local idx="$SITE_EDIT_IDX"
local raw_url
raw_url=$(ask_text "New URL" "${URLS[$idx]}")
URLS[$idx]=$(sites_parse_url "$raw_url")
log_success "URL updated"
save_config
}
action_edit_name() {
local idx="$SITE_EDIT_IDX"
NAMES[$idx]=$(ask_text "New name (blank = show URL)" "${NAMES[$idx]:-}")
log_success "Name updated"
save_config
}
action_edit_duration() {
local idx="$SITE_EDIT_IDX"
echo
echo " > 0 = auto-rotates every X seconds"
echo " 0 = manual only"
echo " -1 = hidden (PIN-gated)"
DURS[$idx]=$(ask_integer "Duration in seconds" "${DURS[$idx]}" -1 86400)
log_success "Timing updated: $(sites_duration_label "${DURS[$idx]}")"
save_config
}
action_edit_auth() {
local idx="$SITE_EDIT_IDX"
if ask_yes_no "Enable HTTP Basic Auth for this page?" "$([[ -n "${USERS[$idx]:-}" ]] && echo y || echo n)"; then
read -r -p " Username: " USERS_new
read -r -s -p " Password: " PASSES_new
echo
USERS[$idx]="$USERS_new"
PASSES[$idx]="$PASSES_new"
log_success "Basic Auth updated"
else
USERS[$idx]=""
PASSES[$idx]=""
log_success "Basic Auth disabled"
fi
save_config
}
################################################################################
# Delete
################################################################################
action_delete_page() {
echo
for idx in "${!URLS[@]}"; do
echo " $((idx + 1)). $(sites_display_label "$idx")"
done
echo
local num
num=$(ask_integer "Delete which page? (0=cancel)" "0" 0 "${#URLS[@]}")
[[ "$num" == "0" ]] && { echo "Cancelled"; return; }
local del_idx=$((num - 1))
echo "Deleting: $(sites_display_label "$del_idx")"
unset 'URLS[del_idx]' 'DURS[del_idx]' 'USERS[del_idx]' 'PASSES[del_idx]' 'NAMES[del_idx]'
URLS=("${URLS[@]}")
DURS=("${DURS[@]}")
USERS=("${USERS[@]}")
PASSES=("${PASSES[@]}")
NAMES=("${NAMES[@]}")
if [[ "$HOME_TAB_INDEX" == "$del_idx" ]]; then
HOME_TAB_INDEX=-1
log_warning "Home page was deleted - home feature disabled"
elif [[ "$HOME_TAB_INDEX" -gt "$del_idx" ]]; then
HOME_TAB_INDEX=$((HOME_TAB_INDEX - 1))
fi
log_success "Page deleted"
save_config
}
################################################################################
# Reorder
################################################################################
action_reorder_page() {
echo
echo "Current order:"
for idx in "${!URLS[@]}"; do
echo " $((idx + 1)). $(sites_display_label "$idx")"
done
echo
local max="${#URLS[@]}"
local from_num to_num
from_num=$(ask_integer "Move which page? (0=cancel)" "0" 0 "$max")
[[ "$from_num" == "0" ]] && { echo "Cancelled"; return; }
to_num=$(ask_integer "Move to position?" "1" 1 "$max")
local from_idx=$((from_num - 1))
local to_idx=$((to_num - 1))
if [[ "$from_idx" == "$to_idx" ]]; then
echo "Same position - no change"
return
fi
# Work out where the home page (if any, and not the one being moved)
# will land, before we touch the arrays. Removing the moved item and
# then re-inserting it at $to_idx always places it at index $to_idx of
# the *final* array - no further adjustment needed there. Everything
# else only shifts by the remove and the insert individually.
local new_home_idx="$HOME_TAB_INDEX"
if [[ "$HOME_TAB_INDEX" == "$from_idx" ]]; then
new_home_idx="$to_idx"
elif [[ "$HOME_TAB_INDEX" != "-1" ]]; then
[[ "$from_idx" -lt "$HOME_TAB_INDEX" ]] && new_home_idx=$((new_home_idx - 1))
[[ "$to_idx" -le "$new_home_idx" ]] && new_home_idx=$((new_home_idx + 1))
fi
HOME_TAB_INDEX="$new_home_idx"
local move_url="${URLS[$from_idx]}" move_dur="${DURS[$from_idx]}" \
move_user="${USERS[$from_idx]}" move_pass="${PASSES[$from_idx]}" move_name="${NAMES[$from_idx]}"
unset 'URLS[from_idx]' 'DURS[from_idx]' 'USERS[from_idx]' 'PASSES[from_idx]' 'NAMES[from_idx]'
URLS=("${URLS[@]}")
DURS=("${DURS[@]}")
USERS=("${USERS[@]}")
PASSES=("${PASSES[@]}")
NAMES=("${NAMES[@]}")
URLS=("${URLS[@]:0:$to_idx}" "$move_url" "${URLS[@]:$to_idx}")
DURS=("${DURS[@]:0:$to_idx}" "$move_dur" "${DURS[@]:$to_idx}")
USERS=("${USERS[@]:0:$to_idx}" "$move_user" "${USERS[@]:$to_idx}")
PASSES=("${PASSES[@]:0:$to_idx}" "$move_pass" "${PASSES[@]:$to_idx}")
NAMES=("${NAMES[@]:0:$to_idx}" "$move_name" "${NAMES[@]:$to_idx}")
log_success "Pages reordered"
save_config
}
################################################################################
# Home page
################################################################################
action_set_home_page() {
echo
if [[ "$HOME_TAB_INDEX" != "-1" ]]; then
echo "Current home page: #$((HOME_TAB_INDEX + 1)), ${INACTIVITY_TIMEOUT}s timeout"
else
echo "Home page currently disabled"
fi
echo
for idx in "${!URLS[@]}"; do
echo " $((idx + 1)). $(sites_display_label "$idx")"
done
echo
local num
num=$(ask_integer "Set which page as home? (0=disable)" "0" 0 "${#URLS[@]}")
if [[ "$num" == "0" ]]; then
HOME_TAB_INDEX=-1
log_success "Home page disabled"
save_config
return
fi
HOME_TAB_INDEX=$((num - 1))
local timeout_min
timeout_min=$(ask_integer "Inactivity timeout in minutes" "2" 1 240)
INACTIVITY_TIMEOUT=$((timeout_min * 60))
log_success "Home page: #${num} (${timeout_min}m inactivity timeout)"
save_config
}
+129
View File
@@ -0,0 +1,129 @@
#!/bin/bash
################################################################################
# menus/timezone.sh - "Timezone" menu.
#
# Third menu migrated off the old single-file installer, and a different
# shape again: not config.json at all - talks to timedatectl/system state
# directly. Also the clearest demonstration of the framework's value: the
# original hand-numbered an 18-entry list ("1) America/New_York ... 18)
# Enter manually") in a single case statement. Here the common-zone list
# is just data, one handler (action_pick_common_timezone) handles all of
# them using the number run_menu hands it, and adding/removing a zone
# from the list never touches numbering anywhere else.
#
# Depends on: lib/menu.sh, lib/config.sh being sourced first.
################################################################################
TIMEZONE_COMMON_ZONES=(
"America/New_York" "America/Chicago" "America/Denver" "America/Los_Angeles"
"America/Phoenix" "America/Anchorage" "Pacific/Honolulu" "Europe/London"
"Europe/Paris" "Europe/Berlin" "Europe/Rome" "Asia/Tokyo" "Asia/Shanghai"
"Asia/Dubai" "Australia/Sydney" "Pacific/Auckland"
)
TIMEZONE_COMMON_LABELS=(
"US Eastern" "US Central" "US Mountain" "US Pacific" "US Arizona" "US Alaska"
"US Hawaii" "UK" "Central Europe" "Germany" "Italy" "Japan" "China" "UAE"
"Australia East" "New Zealand"
)
timezone_status() {
echo "Current timezone: $(timedatectl show -p Timezone --value)"
}
timezone_menu_builder() {
MENU_LABELS=()
MENU_HANDLERS=()
for i in "${!TIMEZONE_COMMON_ZONES[@]}"; do
MENU_LABELS+=("${TIMEZONE_COMMON_ZONES[$i]} (${TIMEZONE_COMMON_LABELS[$i]})")
MENU_HANDLERS+=(action_pick_common_timezone)
done
MENU_LABELS+=("Search for timezone by region" "Enter timezone manually")
MENU_HANDLERS+=(action_search_timezone action_manual_timezone)
}
timezone_menu() {
run_menu "TIMEZONE" timezone_menu_builder timezone_status
}
################################################################################
# Actions
################################################################################
# Called by run_menu as `action_pick_common_timezone "$choice"` - $choice is
# the 1-based menu number, which lines up directly with TIMEZONE_COMMON_ZONES.
action_pick_common_timezone() {
local choice="$1"
set_timezone "${TIMEZONE_COMMON_ZONES[$((choice - 1))]}"
}
action_search_timezone() {
echo
echo "Available regions:"
local regions
regions=($(timedatectl list-timezones | cut -d'/' -f1 | sort -u))
for i in "${!regions[@]}"; do
printf " %2d) %s\n" "$((i + 1))" "${regions[$i]}"
done
echo
local region_num
region_num=$(ask_integer "Select region number (0=cancel)" "0" 0 "${#regions[@]}")
[[ "$region_num" == "0" ]] && { echo "Cancelled"; return; }
local selected_region="${regions[$((region_num - 1))]}"
echo
echo "Timezones in $selected_region:"
local timezones
timezones=($(timedatectl list-timezones | grep "^${selected_region}/"))
for i in "${!timezones[@]}"; do
printf " %3d) %s\n" "$((i + 1))" "${timezones[$i]}"
done
echo
local tz_num
tz_num=$(ask_integer "Select timezone number (0=cancel)" "0" 0 "${#timezones[@]}")
[[ "$tz_num" == "0" ]] && { echo "Cancelled"; return; }
set_timezone "${timezones[$((tz_num - 1))]}"
}
action_manual_timezone() {
echo
local new_tz
read -r -p "Enter timezone (e.g., America/New_York): " new_tz
[[ -z "$new_tz" ]] && { echo "Cancelled"; return; }
set_timezone "$new_tz"
}
################################################################################
# Shared apply logic
################################################################################
set_timezone() {
local new_tz="$1"
# A few legacy US/* aliases users might type manually - normalize before
# validating against the canonical IANA list.
case "$new_tz" in
"US/Eastern") new_tz="America/New_York" ;;
"US/Central") new_tz="America/Chicago" ;;
"US/Mountain") new_tz="America/Denver" ;;
"US/Pacific") new_tz="America/Los_Angeles" ;;
"US/Alaska") new_tz="America/Anchorage" ;;
"US/Hawaii") new_tz="Pacific/Honolulu" ;;
"US/Arizona") new_tz="America/Phoenix" ;;
esac
if ! timedatectl list-timezones | grep -qx "$new_tz"; then
log_error "Invalid timezone: $new_tz"
return 1
fi
if sudo timedatectl set-timezone "$new_tz"; then
log_success "Timezone updated to $new_tz"
else
# Fallback: set timezone directly without D-Bus
sudo ln -sf "/usr/share/zoneinfo/$new_tz" /etc/localtime
echo "$new_tz" | sudo tee /etc/timezone > /dev/null
log_success "Timezone updated to $new_tz (direct)"
fi
}
+241
View File
@@ -0,0 +1,241 @@
#!/bin/bash
################################################################################
# menus/wifi.sh - "WiFi" configuration.
#
# HIGH RISK, unlike anything migrated so far: this changes live network
# configuration and, if run over SSH, can disconnect the very session
# configuring it. Every safety mechanism from the legacy configure_wifi
# is preserved exactly: a netplan backup before writing, a 60-second
# watchdog (armed only when $SSH_CONNECTION is set) that reverts to the
# backup if the new config never comes up, and an explicit "restore
# backup?" prompt if `netplan apply` itself fails outright.
#
# Netplan's directory is $NETPLAN_DIR (lib/config.sh) rather than a
# hardcoded /etc/netplan, so a test can point it at scratch space. But
# unlike every other migrated menu, there is deliberately no automated
# test - not even a stubbed one - that calls the real `netplan apply`,
# `nmcli`, `iw`, `wpa_cli`, or `sudo ip link set ... up`. Only the pure
# logic (SSID/password handling, YAML generation, backup naming) is
# covered by tests with those commands stubbed; the actual apply step
# is exercised by hand against real hardware only.
#
# Unlike the other migrated menus, this one has no sub-options - it's a
# single linear wizard, same as the legacy configure_wifi - so wifi_menu
# IS the action, not a run_menu wrapper.
#
# Depends on: lib/menu.sh, lib/config.sh being sourced first.
################################################################################
wifi_menu() {
echo
echo " ═══ WIFI CONFIGURATION ═══"
echo
local has_tools=false
if command -v nmcli &>/dev/null || command -v iw &>/dev/null || command -v wpa_cli &>/dev/null; then
has_tools=true
fi
if ! $has_tools; then
log_error "No WiFi tools found (nmcli, iw, or wpa_cli)"
echo "Install: sudo apt install network-manager wireless-tools wpasupplicant"
return 1
fi
local wifi_iface
wifi_iface=$(ls /sys/class/net 2>/dev/null | grep -E "^wl" | head -1)
if [[ -z "$wifi_iface" ]]; then
log_warning "No WiFi hardware detected"
echo "If you have a USB WiFi adapter, ensure it's plugged in."
return 1
fi
echo "Interface: $wifi_iface"
echo "Current IP: $(get_ip_address)"
echo
if [[ -n "${SSH_CONNECTION:-}" ]]; then
log_warning "SSH detected - changes auto-revert after 60s if the connection fails"
echo
fi
ask_yes_no "Configure WiFi?" "n" || return 0
echo "Bringing up interface..."
if ! sudo ip link set "$wifi_iface" up 2>/dev/null; then
log_error "Failed to bring up interface"
return 1
fi
sleep 3
echo "Scanning for networks (this takes 5-10 seconds)..."
local scan_results=""
if command -v nmcli &>/dev/null; then
if sudo nmcli device wifi rescan 2>/dev/null; then
sleep 5
scan_results=$(nmcli -t -f SSID,SIGNAL device wifi list 2>/dev/null | sort -t: -k2 -rn | cut -d: -f1 | grep -v "^$" | uniq)
fi
fi
if [[ -z "$scan_results" ]] && command -v iw &>/dev/null; then
local scan_tmp
scan_tmp=$(mktemp)
if sudo iw dev "$wifi_iface" scan 2>/dev/null | grep -E "^BSS|SSID:" > "$scan_tmp"; then
scan_results=$(grep "SSID:" "$scan_tmp" | sed 's/.*SSID: //' | grep -v "^$" | sort -u)
fi
rm -f "$scan_tmp"
fi
if [[ -z "$scan_results" ]]; then
sudo wpa_cli -i "$wifi_iface" scan >/dev/null 2>&1 || true
sleep 5
scan_results=$(sudo wpa_cli -i "$wifi_iface" scan_results 2>/dev/null | awk -F'\t' 'NR>1 && $5!="" {print $5}' | sort -u)
fi
local ssid=""
if [[ -z "$scan_results" ]]; then
log_warning "No networks found in scan"
echo "This could mean:"
echo " • WiFi is disabled in BIOS/UEFI"
echo " • Hardware WiFi switch is off"
echo " • Driver not loaded"
echo " • Networks out of range"
echo
if ask_yes_no "Enter SSID manually anyway?" "n"; then
read -r -p "SSID: " ssid
else
return 1
fi
else
echo
echo "Available networks (strongest first):"
echo "$scan_results" | nl -w2 -s'. '
echo " 0. Manual entry"
echo
local choice
read -r -p "Select network number or enter SSID: " choice
if [[ "$choice" == "0" ]]; then
read -r -p "SSID: " ssid
elif [[ "$choice" =~ ^[0-9]+$ ]]; then
ssid=$(echo "$scan_results" | sed -n "${choice}p")
else
ssid="$choice"
fi
fi
if [[ -z "$ssid" ]]; then
log_error "No SSID provided"
return 1
fi
local password
read -r -s -p "Password for '$ssid': " password
echo
if [[ -z "$password" ]]; then
log_error "No password provided"
return 1
fi
apply_wifi_config "$wifi_iface" "$ssid" "$password"
}
# apply_wifi_config IFACE SSID PASSWORD
# Split out from wifi_menu so a test can drive it directly without going
# through interface detection/scanning, which don't exist in a container.
apply_wifi_config() {
local wifi_iface="$1"
local ssid="$2"
local password="$3"
# `|| true`: under set -e + pipefail (this whole tool runs under both),
# `ls` matching nothing exits non-zero even with stderr silenced, which
# would abort this function outright instead of falling through to the
# default filename below. Same class of bug as the run_menu fix in
# lib/menu.sh - masked here in practice because cloud-init almost
# always leaves a *.yaml file behind, but not guaranteed.
local netplan_file
netplan_file=$(ls "$NETPLAN_DIR"/*.yaml 2>/dev/null | head -1) || true
[[ -z "$netplan_file" ]] && netplan_file="$NETPLAN_DIR/50-cloud-init.yaml"
local backup=""
if [[ -f "$netplan_file" ]]; then
backup="${netplan_file}.backup-$(date +%Y%m%d-%H%M%S)"
sudo cp "$netplan_file" "$backup"
log_success "Backup: $backup"
fi
local temp_plan
temp_plan=$(mktemp --suffix=.yaml)
cat > "$temp_plan" <<EOF
network:
version: 2
renderer: networkd
wifis:
$wifi_iface:
dhcp4: true
dhcp6: false
optional: true
access-points:
"$ssid":
password: "$password"
EOF
if [[ -n "${SSH_CONNECTION:-}" ]] && [[ -n "$backup" ]]; then
local watchdog
watchdog=$(mktemp --suffix=.sh)
cat > "$watchdog" <<'WATCHEOF'
#!/bin/bash
sleep 60
if [[ -f "$1" && -f "$2" ]]; then
ip=$(hostname -I | awk '{print $1}')
if [[ -z "$ip" ]] || ! ping -c 2 8.8.8.8 >/dev/null 2>&1; then
cp "$1" "$2"
netplan apply 2>/dev/null
echo "WiFi config reverted - connection failed" | wall
fi
fi
rm -f "$0"
WATCHEOF
chmod +x "$watchdog"
nohup sudo bash "$watchdog" "$backup" "$netplan_file" >/dev/null 2>&1 &
echo "Watchdog started - will revert in 60s if the connection fails"
fi
sudo cp "$temp_plan" "$netplan_file"
sudo chmod 0600 "$netplan_file"
rm -f "$temp_plan"
echo "Applying configuration..."
local netplan_log
netplan_log=$(mktemp)
if sudo netplan apply > "$netplan_log" 2>&1; then
cat "$netplan_log"
sleep 10
local new_ip
new_ip=$(get_ip_address)
if [[ -n "$new_ip" && "$new_ip" != "No IP" ]]; then
log_success "Connected: $ssid ($new_ip)"
[[ -n "${SSH_CONNECTION:-}" ]] && echo "Connection successful - watchdog will not revert"
else
log_warning "Config applied but no IP yet"
echo "Check: sudo journalctl -u systemd-networkd -f"
fi
else
log_error "netplan apply failed"
echo "Error log:"
cat "$netplan_log"
if [[ -n "$backup" ]] && ask_yes_no "Restore backup?" "y"; then
sudo cp "$backup" "$netplan_file"
# Last resort after everything else failed: report, don't crash
# the session if even the restore-and-reapply doesn't work.
if sudo netplan apply; then
log_success "Backup restored and applied"
else
log_error "Failed to reapply the restored backup - manual intervention needed"
fi
fi
fi
rm -f "$netplan_log"
}
@@ -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"
+143
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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"
-715
View File
@@ -1,715 +0,0 @@
#!/bin/bash
################################################################################
### UBK Mumble/Talkiepi Intercom Setup ###
### Simple CLI-based Installation ###
################################################################################
#
# This script provides a lightweight intercom solution using:
# - Murmur: The Mumble server
# - talkiepi: A simple, lightweight barnard-based Mumble client
#
# Advantages over talkkonnect:
# - Much simpler codebase and build process
# - Fewer dependencies
# - Direct CLI arguments for server, user, password, channel
# - More stable and less fragile
#
################################################################################
VERSION="2.0.0"
KIOSK_USER="${KIOSK_USER:-kiosk}"
################################################################################
### HELPER FUNCTIONS
################################################################################
log_error() {
echo "[ERROR] $*" >&2
}
log_success() {
echo "$*"
}
log_warning() {
echo "$*"
}
pause() {
read -r -p "Press Enter to continue..."
}
get_ip_address() {
hostname -I | awk '{print $1}' || echo "No IP"
}
################################################################################
### STATUS CHECK FUNCTIONS
################################################################################
check_murmur_status() {
local installed=false
local running=false
if systemctl list-unit-files | grep -q "mumble-server.service"; then
installed=true
if systemctl is-active --quiet mumble-server; then
running=true
fi
fi
echo "$installed:$running"
}
check_talkiepi_status() {
local installed=false
local running=false
if [[ -f /usr/local/bin/talkiepi ]] || [[ -f /etc/systemd/system/talkiepi.service ]]; then
installed=true
if systemctl is-active --quiet talkiepi; then
running=true
fi
fi
echo "$installed:$running"
}
################################################################################
### MURMUR SERVER INSTALLATION
################################################################################
install_murmur_server() {
echo
echo "═══════════════════════════════════════════════════════════════"
echo " INSTALLING MURMUR SERVER"
echo "═══════════════════════════════════════════════════════════════"
echo
echo "[1/5] Installing mumble-server package..."
sudo apt update
sudo apt install -y mumble-server
echo "[2/5] Getting configuration details..."
read -r -s -p "SuperUser password: " superuser_pass
echo
read -r -s -p "Server password (clients need this): " server_pass
echo
read -r -p "Welcome text [Welcome to Kiosk Intercom]: " welcome_text
welcome_text="${welcome_text:-Welcome to Kiosk Intercom}"
echo "[3/5] Creating configuration..."
local config_file="/etc/mumble-server.ini"
# Create config if it doesn't exist
if [[ ! -f "$config_file" ]]; then
sudo tee "$config_file" > /dev/null <<'MURMURCONF'
# Murmur configuration file
# Database location
database=/var/lib/mumble-server/mumble-server.sqlite
# Network settings
port=64738
host=0.0.0.0
# Logging
logfile=/var/log/mumble-server/mumble-server.log
# Limits
users=10
bandwidth=72000
# Welcome message
welcometext=Welcome
# Server password
serverpassword=
# Allow pings
allowping=true
# Enable HTML
allowhtml=true
MURMURCONF
log_success "Config file created"
fi
# Update configuration
sudo sed -i "s|^welcometext=.*|welcometext=$welcome_text|" "$config_file"
sudo sed -i "s|^port=.*|port=64738|" "$config_file"
sudo sed -i "s|^users=.*|users=10|" "$config_file"
sudo sed -i "s|^bandwidth=.*|bandwidth=72000|" "$config_file"
if [[ -n "$server_pass" ]]; then
sudo sed -i "s|^serverpassword=.*|serverpassword=$server_pass|" "$config_file"
fi
echo "[4/5] Setting SuperUser password..."
sudo systemctl stop mumble-server 2>/dev/null || true
sleep 2
echo "$superuser_pass" | sudo murmurd -ini "$config_file" -supw - 2>/dev/null || {
log_warning "Could not set SuperUser password via murmurd command"
echo "You can set it later with: sudo murmurd -ini $config_file -supw YOUR_PASSWORD"
}
echo "[5/5] Starting service..."
sudo systemctl enable mumble-server
sudo systemctl start mumble-server
# Configure firewall
sudo ufw allow 64738/tcp comment 'Mumble/Murmur' 2>/dev/null || true
sudo ufw allow 64738/udp comment 'Mumble/Murmur' 2>/dev/null || true
sleep 3
local server_ip=$(get_ip_address)
log_success "Murmur server installed"
echo
echo "═══════════════════════════════════════════════════════════════"
echo " Server: $server_ip:64738"
echo " SuperUser: SuperUser / $superuser_pass"
[[ -n "$server_pass" ]] && echo " Password: $server_pass"
echo "═══════════════════════════════════════════════════════════════"
pause
}
################################################################################
### TALKIEPI CLIENT INSTALLATION
################################################################################
install_talkiepi_with_config() {
local server_addr="$1"
local server_port="$2"
local tp_user="$3"
local tp_pass="$4"
local tp_channel="$5"
echo
echo "═══════════════════════════════════════════════════════════════"
echo " INSTALLING TALKIEPI CLIENT"
echo "═══════════════════════════════════════════════════════════════"
echo
echo "talkiepi is a lightweight Mumble client based on barnard"
echo "Much simpler and more stable than talkkonnect"
echo
local TARGET_USER="$KIOSK_USER"
local TARGET_UID=$(id -u "$TARGET_USER")
local TARGET_HOME="/home/$TARGET_USER"
# Verify home directory exists
if [ ! -d "$TARGET_HOME" ]; then
log_error "Home directory does not exist: $TARGET_HOME"
pause
return 1
fi
# --- System Prep ------------------------------------------------------
echo "[1/5] Installing system dependencies..."
sudo apt update
sudo apt install -y git golang libopenal-dev libopus-dev
# --- Clone and Build Talkiepi -----------------------------------------
echo "[2/5] Cloning talkiepi repository..."
cd "$TARGET_HOME"
if [ -d "talkiepi" ]; then
sudo rm -rf talkiepi
fi
# Clone as the target user
sudo -u "$TARGET_USER" git clone https://github.com/dchote/talkiepi.git
cd talkiepi
echo "[3/5] Building talkiepi..."
export GOPATH="$TARGET_HOME/gocode"
export GOBIN="$TARGET_HOME/bin"
# Install gopus dependency
sudo -u "$TARGET_USER" go get github.com/dchote/gopus
# Build talkiepi
sudo -u "$TARGET_USER" go build -o "$TARGET_HOME/bin/talkiepi" cmd/talkiepi/main.go
if [ ! -f "$TARGET_HOME/bin/talkiepi" ]; then
log_error "Build failed!"
pause
return 1
fi
# Stop any running instances
if systemctl is-active --quiet talkiepi 2>/dev/null; then
sudo systemctl stop talkiepi
fi
# Install binary
sudo cp "$TARGET_HOME/bin/talkiepi" /usr/local/bin/talkiepi
sudo chmod +x /usr/local/bin/talkiepi
log_success "Binary installed to /usr/local/bin/talkiepi"
# --- User Permissions -------------------------------------------------
echo "[4/5] Setting up permissions..."
if ! groups "$TARGET_USER" | grep -q audio; then
sudo usermod -a -G audio "$TARGET_USER"
log_success "Added $TARGET_USER to 'audio' group"
fi
# --- Create Systemd Service -------------------------------------------
echo "[5/5] Creating systemd service..."
# Build the command line arguments
local TALKIEPI_ARGS="-server ${server_addr}:${server_port} -username ${tp_user}"
if [[ -n "$tp_pass" ]]; then
TALKIEPI_ARGS="$TALKIEPI_ARGS -password ${tp_pass}"
fi
if [[ -n "$tp_channel" ]]; then
TALKIEPI_ARGS="$TALKIEPI_ARGS -channel ${tp_channel}"
fi
# Add insecure flag for local/self-signed certs
TALKIEPI_ARGS="$TALKIEPI_ARGS -insecure"
sudo tee /etc/systemd/system/talkiepi.service > /dev/null <<EOFSVC
[Unit]
Description=Talkiepi Lightweight Mumble Client
After=network-online.target sound.target
Wants=network-online.target
[Service]
Type=simple
User=$TARGET_USER
Group=$TARGET_USER
WorkingDirectory=$TARGET_HOME
ExecStart=/usr/local/bin/talkiepi $TALKIEPI_ARGS
Restart=always
RestartSec=5
StandardOutput=journal
StandardError=journal
Environment="XDG_RUNTIME_DIR=/run/user/$TARGET_UID"
[Install]
WantedBy=multi-user.target
EOFSVC
sudo systemctl daemon-reload
sudo systemctl enable talkiepi
sudo systemctl start talkiepi
sleep 3
log_success "talkiepi installed successfully!"
echo "═══════════════════════════════════════════════════════════════"
echo " Server: $server_addr:$server_port"
echo " Username: $tp_user"
echo " Channel: ${tp_channel:-Root}"
echo " Binary: /usr/local/bin/talkiepi"
echo "═══════════════════════════════════════════════════════════════"
echo
# Show status
if systemctl is-active --quiet talkiepi; then
log_success "Service is running"
echo "View logs: sudo journalctl -u talkiepi -f"
else
log_warning "Service may have issues"
echo "Check logs: sudo journalctl -u talkiepi -n 50"
fi
pause
}
install_talkiepi_only() {
echo
echo "═══════════════════════════════════════════════════════════════"
echo " TALKIEPI - CONNECT TO EXISTING SERVER"
echo "═══════════════════════════════════════════════════════════════"
echo
echo "Enter Murmur/Mumble server details:"
read -r -p "Server address (IP or domain): " server_addr
read -r -p "Port [64738]: " server_port
server_port="${server_port:-64738}"
read -r -p "Username: " tp_user
read -r -s -p "Password (leave empty if none): " tp_pass
echo
read -r -p "Channel [Root]: " tp_channel
tp_channel="${tp_channel:-Root}"
install_talkiepi_with_config "$server_addr" "$server_port" "$tp_user" "$tp_pass" "$tp_channel"
}
install_murmur_and_talkiepi() {
echo
echo "═══════════════════════════════════════════════════════════════"
echo " ALL-IN-ONE: SERVER + CLIENT"
echo "═══════════════════════════════════════════════════════════════"
echo
echo "Installing Murmur server..."
install_murmur_server
echo
echo "Now installing talkiepi client..."
echo "Configuring to connect to local server..."
# Auto-configure for local server
AUTO_SERVER="127.0.0.1"
AUTO_PORT="64738"
read -r -p "Username for talkiepi [kiosk]: " tp_user
tp_user="${tp_user:-kiosk}"
read -r -s -p "Server password: " tp_pass
echo
install_talkiepi_with_config "$AUTO_SERVER" "$AUTO_PORT" "$tp_user" "$tp_pass" "Root"
}
################################################################################
### SERVICE MANAGEMENT
################################################################################
toggle_murmur_service() {
echo
if systemctl is-active --quiet mumble-server; then
echo "Stopping Murmur server..."
sudo systemctl stop mumble-server
log_success "Murmur stopped"
else
echo "Starting Murmur server..."
sudo systemctl start mumble-server
sleep 2
if systemctl is-active --quiet mumble-server; then
log_success "Murmur started"
else
log_error "Failed to start - check logs: sudo journalctl -u mumble-server"
fi
fi
pause
}
toggle_talkiepi_service() {
echo
if systemctl is-active --quiet talkiepi; then
echo "Stopping talkiepi..."
sudo systemctl stop talkiepi
log_success "talkiepi stopped"
else
echo "Starting talkiepi..."
sudo systemctl start talkiepi
sleep 2
if systemctl is-active --quiet talkiepi; then
log_success "talkiepi started"
else
log_error "Failed to start - check logs: sudo journalctl -u talkiepi"
fi
fi
pause
}
################################################################################
### LOGS AND DIAGNOSTICS
################################################################################
view_talkiepi_logs() {
echo
echo "Recent talkiepi logs:"
echo "═══════════════════════════════════════════════════════════════"
sudo journalctl -u talkiepi -n 50 --no-pager
echo
pause
}
view_murmur_logs() {
echo
echo "Recent Murmur server logs:"
echo "═══════════════════════════════════════════════════════════════"
sudo journalctl -u mumble-server -n 50 --no-pager
echo
pause
}
################################################################################
### UNINSTALLATION
################################################################################
uninstall_murmur() {
echo
read -r -p "Uninstall Murmur server? (yes/no): " confirm
[[ "$confirm" != "yes" ]] && return
echo "Uninstalling..."
sudo systemctl stop mumble-server 2>/dev/null || true
sudo systemctl disable mumble-server 2>/dev/null || true
sudo apt remove -y mumble-server 2>/dev/null || true
read -r -p "Remove configuration and database? (y/n): " remove_data
if [[ "$remove_data" =~ ^[Yy]$ ]]; then
sudo rm -rf /var/lib/mumble-server
sudo rm -f /etc/mumble-server.ini
log_success "Murmur and data removed"
else
log_success "Murmur removed (data preserved)"
fi
pause
}
uninstall_talkiepi() {
echo
read -r -p "Uninstall talkiepi? (yes/no): " confirm
[[ "$confirm" != "yes" ]] && return
echo "Uninstalling talkiepi..."
# Stop and disable service
if systemctl is-active --quiet talkiepi 2>/dev/null; then
sudo systemctl stop talkiepi
fi
if systemctl is-enabled --quiet talkiepi 2>/dev/null; then
sudo systemctl disable talkiepi
fi
# Remove systemd service file
if [[ -f /etc/systemd/system/talkiepi.service ]]; then
sudo rm -f /etc/systemd/system/talkiepi.service
sudo systemctl daemon-reload
fi
# Remove binary
if [[ -f /usr/local/bin/talkiepi ]]; then
sudo rm -f /usr/local/bin/talkiepi
fi
# Remove source directory
read -r -p "Also remove source directory? (y/n): " remove_source
if [[ "$remove_source" =~ ^[Yy]$ ]] && [[ -d "/home/$KIOSK_USER/talkiepi" ]]; then
sudo rm -rf "/home/$KIOSK_USER/talkiepi"
sudo rm -rf "/home/$KIOSK_USER/gocode"
sudo rm -rf "/home/$KIOSK_USER/bin"
fi
log_success "talkiepi uninstalled"
pause
}
################################################################################
### MAIN MENU
################################################################################
show_status() {
local murmur_status=$(check_murmur_status)
local murmur_installed=$(echo "$murmur_status" | cut -d: -f1)
local murmur_running=$(echo "$murmur_status" | cut -d: -f2)
local tp_status=$(check_talkiepi_status)
local tp_installed=$(echo "$tp_status" | cut -d: -f1)
local tp_running=$(echo "$tp_status" | cut -d: -f2)
echo
echo "═══════════════════════════════════════════════════════════════"
echo " INTERCOM STATUS"
echo "═══════════════════════════════════════════════════════════════"
echo
# Murmur status
if [[ "$murmur_installed" == "true" ]]; then
echo "Murmur Server: ✓ Installed"
if [[ "$murmur_running" == "true" ]]; then
echo " Status: Running"
local server_ip=$(get_ip_address)
echo " Address: $server_ip:64738"
else
echo " Status: Stopped"
fi
echo
else
echo "Murmur Server: Not installed"
echo
fi
# talkiepi status
if [[ "$tp_installed" == "true" ]]; then
echo "talkiepi Client: ✓ Installed"
if [[ "$tp_running" == "true" ]]; then
echo " Status: Running"
else
echo " Status: Stopped"
fi
echo
else
echo "talkiepi Client: Not installed"
echo
fi
}
main_menu() {
while true; do
clear
echo "═══════════════════════════════════════════════════════════════"
echo " UBK INTERCOM SETUP v${VERSION}"
echo " Mumble/Talkiepi (Lightweight & Stable)"
echo "═══════════════════════════════════════════════════════════════"
show_status
local murmur_status=$(check_murmur_status)
local murmur_installed=$(echo "$murmur_status" | cut -d: -f1)
local murmur_running=$(echo "$murmur_status" | cut -d: -f2)
local tp_status=$(check_talkiepi_status)
local tp_installed=$(echo "$tp_status" | cut -d: -f1)
local tp_running=$(echo "$tp_status" | cut -d: -f2)
echo "═══════════════════════════════════════════════════════════════"
echo " INSTALLATION OPTIONS"
echo "═══════════════════════════════════════════════════════════════"
local menu_num=1
# Installation options (shown when nothing installed)
if [[ "$murmur_installed" == "false" && "$tp_installed" == "false" ]]; then
echo " $menu_num. Install All-in-One (Server + Client)"
local opt_all_in_one=$menu_num
((menu_num++))
echo " $menu_num. Install Murmur Server Only"
local opt_server_only=$menu_num
((menu_num++))
echo " $menu_num. Install talkiepi Client Only"
local opt_client_only=$menu_num
((menu_num++))
else
# Murmur management options
if [[ "$murmur_installed" == "true" ]]; then
echo
echo "Murmur Server:"
if [[ "$murmur_running" == "true" ]]; then
echo " $menu_num. Stop Murmur"
else
echo " $menu_num. Start Murmur"
fi
local opt_murmur_toggle=$menu_num
((menu_num++))
echo " $menu_num. View Murmur Logs"
local opt_murmur_logs=$menu_num
((menu_num++))
echo " $menu_num. Uninstall Murmur"
local opt_murmur_uninstall=$menu_num
((menu_num++))
else
echo
echo " $menu_num. Install Murmur Server"
local opt_install_murmur=$menu_num
((menu_num++))
fi
# talkiepi management options
if [[ "$tp_installed" == "true" ]]; then
echo
echo "talkiepi Client:"
if [[ "$tp_running" == "true" ]]; then
echo " $menu_num. Stop talkiepi"
else
echo " $menu_num. Start talkiepi"
fi
local opt_tp_toggle=$menu_num
((menu_num++))
echo " $menu_num. View talkiepi Logs"
local opt_tp_logs=$menu_num
((menu_num++))
echo " $menu_num. Uninstall talkiepi"
local opt_tp_uninstall=$menu_num
((menu_num++))
else
echo
echo " $menu_num. Install talkiepi Client"
local opt_install_tp=$menu_num
((menu_num++))
fi
fi
echo
echo "═══════════════════════════════════════════════════════════════"
echo " 0. Exit"
echo "═══════════════════════════════════════════════════════════════"
echo
read -r -p "Choose [0-$((menu_num-1))]: " choice
# Handle menu selection
case "$choice" in
0)
echo "Exiting..."
exit 0
;;
${opt_all_in_one:-})
install_murmur_and_talkiepi
;;
${opt_server_only:-})
install_murmur_server
;;
${opt_client_only:-})
install_talkiepi_only
;;
${opt_install_murmur:-})
install_murmur_server
;;
${opt_install_tp:-})
install_talkiepi_only
;;
${opt_murmur_toggle:-})
toggle_murmur_service
;;
${opt_murmur_logs:-})
view_murmur_logs
;;
${opt_murmur_uninstall:-})
uninstall_murmur
;;
${opt_tp_toggle:-})
toggle_talkiepi_service
;;
${opt_tp_logs:-})
view_talkiepi_logs
;;
${opt_tp_uninstall:-})
uninstall_talkiepi
;;
*)
echo "Invalid choice"
sleep 1
;;
esac
done
}
################################################################################
### ENTRY POINT
################################################################################
# Check if running as root
if [[ $EUID -eq 0 ]]; then
log_error "This script should not be run as root"
echo "Please run as: ./setup_intercom_simple.sh"
exit 1
fi
# Check if kiosk user exists
if ! id "$KIOSK_USER" &>/dev/null; then
log_warning "User '$KIOSK_USER' does not exist"
read -r -p "Enter the username to use for installation: " KIOSK_USER
if ! id "$KIOSK_USER" &>/dev/null; then
log_error "User '$KIOSK_USER' not found"
exit 1
fi
fi
# Run main menu
main_menu
-784
View File
@@ -1,784 +0,0 @@
#!/usr/bin/env bash
# ======================================================================
# File: install_talkkonnect_x86_complete.sh
# Purpose: Complete Talkkonnect installation for Ubuntu 24.04 x86_64
# Fixes Opus architecture issues and creates working config
# Author: Compiled from troubleshooting session
# Date: November 2025
# ======================================================================
set -e
echo "======================================================================="
echo "TalkKonnect Complete Installation Script for x86_64"
echo "This script will:"
echo " 1. Install system dependencies"
echo " 2. Install Go 1.24.1"
echo " 3. Clone and patch TalkKonnect for x86_64"
echo " 4. Build the binary"
echo " 5. Create configuration files"
echo " 6. Set up systemd service"
echo "======================================================================="
echo ""
# Check if this is a Chromebook or multi-user audio setup
echo "Audio Setup Detection:"
echo " - If you have audio working under a different user (e.g. 'kiosk'),"
echo " talkkonnect should run as that user to share the PipeWire session"
echo " - This also enables audio ducking between applications"
echo ""
read -p "Run talkkonnect as a different user? [kiosk]: " TARGET_USER
TARGET_USER=${TARGET_USER:-kiosk}
if [ "$TARGET_USER" != "$USER" ]; then
# Verify the target user exists
if ! id "$TARGET_USER" &>/dev/null; then
echo "[!] Error: User '$TARGET_USER' does not exist"
exit 1
fi
# Get target user's UID for XDG_RUNTIME_DIR
TARGET_UID=$(id -u "$TARGET_USER")
TARGET_HOME=$(eval echo ~"$TARGET_USER")
echo "[+] Will install for user: $TARGET_USER (UID: $TARGET_UID)"
echo "[+] Home directory: $TARGET_HOME"
else
TARGET_UID=$(id -u)
TARGET_HOME="$HOME"
echo "[+] Installing for current user: $USER"
fi
echo ""
read -p "Press Enter to continue or Ctrl-C to abort..."
# --- System Prep ------------------------------------------------------
echo ""
echo "[+] Updating system packages..."
sudo apt update
sudo apt upgrade -y
echo "[+] Installing dependencies..."
sudo apt install -y wget git build-essential pkg-config \
libasound2-dev libopus-dev libopus0 libopusfile-dev \
libpipewire-0.3-dev libevdev-dev libopenal-dev alsa-utils
# --- Go Installation --------------------------------------------------
echo ""
echo "[+] Installing Go 1.24.1..."
sudo rm -rf /usr/local/go /usr/lib/go* /usr/bin/go
wget https://go.dev/dl/go1.24.1.linux-amd64.tar.gz
sudo tar -C /usr/local -xzf go1.24.1.linux-amd64.tar.gz
sudo ln -sf /usr/local/go/bin/go /usr/bin/go
rm -f go1.24.1.linux-amd64.tar.gz
export PATH="/usr/local/go/bin:$PATH"
go version
# --- Clone TalkKonnect ------------------------------------------------
echo ""
echo "[+] Cloning TalkKonnect repository..."
cd ~
if [ -d "talkkonnect" ]; then
echo "[!] Removing existing talkkonnect directory..."
rm -rf talkkonnect
fi
git clone https://github.com/talkkonnect/talkkonnect.git
cd talkkonnect
echo "[+] Current version:"
git log --oneline -1
# --- Critical Fix: Patch gopus for x86_64 -----------------------------
echo ""
echo "======================================================================="
echo "[+] APPLYING X86_64 OPUS FIX"
echo "======================================================================="
echo "[+] The talkkonnect gopus package embeds Opus source for ARM"
echo "[+] but it's incomplete for x86_64. We'll patch it to use system libopus"
echo ""
# Create vendor directory
go mod vendor
# Check that gopus exists in vendor
if [ ! -d "vendor/github.com/talkkonnect/gopus" ]; then
echo "[!] ERROR: gopus not found in vendor!"
exit 1
fi
echo "[+] Backing up original opus_nonshared.go..."
cp vendor/github.com/talkkonnect/gopus/opus_nonshared.go \
vendor/github.com/talkkonnect/gopus/opus_nonshared.go.original
echo "[+] Creating x86_64-compatible opus_nonshared.go..."
cat > vendor/github.com/talkkonnect/gopus/opus_nonshared.go << 'EOFOPUS'
// +build amd64,cgo 386,cgo
package gopus
// #cgo pkg-config: opus
// #cgo LDFLAGS: -lm
//
// #include <stdio.h>
// #include <stdlib.h>
// #include <opus.h>
//
// enum {
// gopus_ok = OPUS_OK,
// gopus_bad_arg = OPUS_BAD_ARG,
// gopus_small_buffer = OPUS_BUFFER_TOO_SMALL,
// gopus_internal = OPUS_INTERNAL_ERROR,
// gopus_invalid_packet = OPUS_INVALID_PACKET,
// gopus_unimplemented = OPUS_UNIMPLEMENTED,
// gopus_invalid_state = OPUS_INVALID_STATE,
// gopus_alloc_fail = OPUS_ALLOC_FAIL,
// };
//
// enum {
// gopus_application_voip = OPUS_APPLICATION_VOIP,
// gopus_application_audio = OPUS_APPLICATION_AUDIO,
// gopus_restricted_lowdelay = OPUS_APPLICATION_RESTRICTED_LOWDELAY,
// gopus_bitrate_max = OPUS_BITRATE_MAX,
// };
//
// void gopus_setvbr(OpusEncoder *encoder, int vbr) {
// opus_encoder_ctl(encoder, OPUS_SET_VBR(vbr));
// }
//
// void gopus_setbitrate(OpusEncoder *encoder, int bitrate) {
// opus_encoder_ctl(encoder, OPUS_SET_BITRATE(bitrate));
// }
//
// opus_int32 gopus_bitrate(OpusEncoder *encoder) {
// opus_int32 bitrate;
// opus_encoder_ctl(encoder, OPUS_GET_BITRATE(&bitrate));
// return bitrate;
// }
//
// void gopus_setapplication(OpusEncoder *encoder, int application) {
// opus_encoder_ctl(encoder, OPUS_SET_APPLICATION(application));
// }
//
// opus_int32 gopus_application(OpusEncoder *encoder) {
// opus_int32 application;
// opus_encoder_ctl(encoder, OPUS_GET_APPLICATION(&application));
// return application;
// }
//
// void gopus_encoder_resetstate(OpusEncoder *encoder) {
// opus_encoder_ctl(encoder, OPUS_RESET_STATE);
// }
//
// void gopus_decoder_resetstate(OpusDecoder *decoder) {
// opus_decoder_ctl(decoder, OPUS_RESET_STATE);
// }
import "C"
import (
"errors"
"unsafe"
)
type Application int
const (
Voip Application = C.gopus_application_voip
Audio Application = C.gopus_application_audio
RestrictedLowDelay Application = C.gopus_restricted_lowdelay
)
const (
BitrateMaximum = C.gopus_bitrate_max
)
type Encoder struct {
data []byte
cEncoder *C.struct_OpusEncoder
}
func NewEncoder(sampleRate, channels int, application Application) (*Encoder, error) {
encoder := &Encoder{}
encoder.data = make([]byte, int(C.opus_encoder_get_size(C.int(channels))))
encoder.cEncoder = (*C.struct_OpusEncoder)(unsafe.Pointer(&encoder.data[0]))
ret := C.opus_encoder_init(encoder.cEncoder, C.opus_int32(sampleRate), C.int(channels), C.int(application))
if err := getErr(ret); err != nil {
return nil, err
}
return encoder, nil
}
func (e *Encoder) Encode(pcm []int16, frameSize, maxDataBytes int) ([]byte, error) {
pcmPtr := (*C.opus_int16)(unsafe.Pointer(&pcm[0]))
data := make([]byte, maxDataBytes)
dataPtr := (*C.uchar)(unsafe.Pointer(&data[0]))
encodedC := C.opus_encode(e.cEncoder, pcmPtr, C.int(frameSize), dataPtr, C.opus_int32(len(data)))
encoded := int(encodedC)
if encoded < 0 {
return nil, getErr(C.int(encodedC))
}
return data[0:encoded], nil
}
func (e *Encoder) SetVbr(vbr bool) {
var cVbr C.int
if vbr {
cVbr = 1
} else {
cVbr = 0
}
C.gopus_setvbr(e.cEncoder, cVbr)
}
func (e *Encoder) SetBitrate(bitrate int) {
C.gopus_setbitrate(e.cEncoder, C.int(bitrate))
}
func (e *Encoder) Bitrate() int {
return int(C.gopus_bitrate(e.cEncoder))
}
func (e *Encoder) SetApplication(application Application) {
C.gopus_setapplication(e.cEncoder, C.int(application))
}
func (e *Encoder) Application() Application {
return Application(C.gopus_application(e.cEncoder))
}
func (e *Encoder) ResetState() {
C.gopus_encoder_resetstate(e.cEncoder)
}
type Decoder struct {
data []byte
cDecoder *C.struct_OpusDecoder
channels int
}
func NewDecoder(sampleRate, channels int) (*Decoder, error) {
decoder := &Decoder{}
decoder.data = make([]byte, int(C.opus_decoder_get_size(C.int(channels))))
decoder.cDecoder = (*C.struct_OpusDecoder)(unsafe.Pointer(&decoder.data[0]))
ret := C.opus_decoder_init(decoder.cDecoder, C.opus_int32(sampleRate), C.int(channels))
if err := getErr(ret); err != nil {
return nil, err
}
decoder.channels = channels
return decoder, nil
}
func (d *Decoder) Decode(data []byte, frameSize int, fec bool) ([]int16, error) {
var dataPtr *C.uchar
if len(data) > 0 {
dataPtr = (*C.uchar)(unsafe.Pointer(&data[0]))
}
dataLen := C.opus_int32(len(data))
output := make([]int16, d.channels*frameSize)
outputPtr := (*C.opus_int16)(unsafe.Pointer(&output[0]))
var cFec C.int
if fec {
cFec = 1
} else {
cFec = 0
}
cRet := C.opus_decode(d.cDecoder, dataPtr, dataLen, outputPtr, C.int(frameSize), cFec)
ret := int(cRet)
if ret < 0 {
return nil, getErr(cRet)
}
return output[:ret*d.channels], nil
}
func (d *Decoder) ResetState() {
C.gopus_decoder_resetstate(d.cDecoder)
}
func GetSamplesPerFrame(data []byte, samplingRate int) (int, error) {
dataPtr := (*C.uchar)(unsafe.Pointer(&data[0]))
cSamplingRate := C.opus_int32(samplingRate)
cRet := C.opus_packet_get_samples_per_frame(dataPtr, cSamplingRate)
return int(cRet), nil
}
func CountFrames(data []byte) (int, error) {
dataPtr := (*C.uchar)(unsafe.Pointer(&data[0]))
cLen := C.opus_int32(len(data))
cRet := C.opus_packet_get_nb_frames(dataPtr, cLen)
if err := getErr(cRet); err != nil {
return 0, err
}
return int(cRet), nil
}
var (
ErrBadArgument = errors.New("bad argument")
ErrSmallBuffer = errors.New("buffer is too small")
ErrInternal = errors.New("internal error")
ErrInvalidPacket = errors.New("invalid packet")
ErrUnimplemented = errors.New("unimplemented")
ErrInvalidState = errors.New("invalid state")
ErrAllocFail = errors.New("allocation failed")
ErrUnknown = errors.New("unknown error")
)
func getErr(code C.int) error {
switch code {
case C.gopus_ok:
return nil
case C.gopus_bad_arg:
return ErrBadArgument
case C.gopus_small_buffer:
return ErrSmallBuffer
case C.gopus_internal:
return ErrInternal
case C.gopus_invalid_packet:
return ErrInvalidPacket
case C.gopus_unimplemented:
return ErrUnimplemented
case C.gopus_invalid_state:
return ErrInvalidState
case C.gopus_alloc_fail:
return ErrAllocFail
default:
return ErrUnknown
}
}
EOFOPUS
echo "[+] Patched opus_nonshared.go to use system libopus"
# --- Build TalkKonnect ------------------------------------------------
echo ""
echo "======================================================================="
echo "[+] BUILDING TALKKONNECT"
echo "======================================================================="
cd ~/talkkonnect/cmd/talkkonnect
export CGO_ENABLED=1
export CGO_CFLAGS="$(pkg-config --cflags opus)"
export CGO_LDFLAGS="$(pkg-config --libs opus) -lm"
echo "[+] Building (this may take a few minutes)..."
go build -mod=vendor -v -o ~/talkkonnect-binary . 2>&1 | tee /tmp/talkkonnect_build.log
if [ ! -f ~/talkkonnect-binary ]; then
echo ""
echo "[!] BUILD FAILED!"
echo "[!] Last 50 lines of build log:"
tail -50 /tmp/talkkonnect_build.log
exit 1
fi
echo ""
echo "[+] Installing binary..."
# Stop any running talkkonnect instances first
if systemctl is-active --quiet talkkonnect 2>/dev/null; then
echo "[+] Stopping existing talkkonnect service..."
sudo systemctl stop talkkonnect
fi
# Kill any stray processes
if pgrep -x talkkonnect > /dev/null; then
echo "[+] Killing running talkkonnect processes..."
sudo pkill -9 talkkonnect
sleep 1
fi
# Now install the binary
sudo cp ~/talkkonnect-binary /usr/local/bin/talkkonnect
sudo chmod +x /usr/local/bin/talkkonnect
rm ~/talkkonnect-binary
echo "[+] Binary installed successfully!"
echo "[+] Checking dependencies:"
ldd /usr/local/bin/talkkonnect | grep -E "(opus|alsa)" || echo " (may be statically linked)"
# --- User Permissions -------------------------------------------------
echo ""
echo "[+] Setting up user permissions..."
# Add target user to input group for keyboard PTT
if ! groups "$TARGET_USER" | grep -q input; then
sudo usermod -a -G input "$TARGET_USER"
echo "[+] Added $TARGET_USER to 'input' group"
if [ "$TARGET_USER" = "$USER" ]; then
echo "[!] IMPORTANT: Log out and back in for this to take effect!"
echo "[!] Or run: newgrp input"
NEEDS_RELOGIN=true
fi
else
echo "[+] User $TARGET_USER already in 'input' group"
NEEDS_RELOGIN=false
fi
# Add target user to audio group
if ! groups "$TARGET_USER" | grep -q audio; then
sudo usermod -a -G audio "$TARGET_USER"
echo "[+] Added $TARGET_USER to 'audio' group"
fi
# --- Configuration ----------------------------------------------------
echo ""
echo "======================================================================="
echo "[+] CREATING CONFIGURATION"
echo "======================================================================="
# Verify target user's home directory exists
if [ ! -d "$TARGET_HOME" ]; then
echo "[!] Error: Home directory for user '$TARGET_USER' does not exist: $TARGET_HOME"
echo "[!] Please ensure the user account is properly set up with a home directory"
echo "[!] You may need to run: sudo mkhomedir_helper $TARGET_USER"
exit 1
fi
CONFIG_DIR="$TARGET_HOME/.config/talkkonnect"
# Create config directory with appropriate permissions
if [ "$TARGET_USER" != "$USER" ]; then
# Need to use sudo to create directory in another user's home
sudo -u "$TARGET_USER" mkdir -p "$CONFIG_DIR"
echo "[+] Created config directory as user: $TARGET_USER"
else
mkdir -p "$CONFIG_DIR"
echo "[+] Created config directory"
fi
# Create config file (use tee with sudo to handle permissions)
sudo tee "$CONFIG_DIR/talkkonnect.xml" > /dev/null << 'EOFXML'
<?xml version="1.0" encoding="UTF-8"?>
<document type="talkkonnect/xml">
<global>
<software>
<settings outputdevice="default"
logfilenameandpath="/home/user/.config/talkkonnect/talkkonnect.log"
logging="both"
daemonize="false"
cancelconnect="false"
simplexwithvox="false"
nextserverindex="0"/>
<autoprovisioning enabled="false"/>
</software>
<hardware>
<settings targetboard="pc"
voiceactivitytimersecs="200"/>
<io>
<pins enabled="false"/>
</io>
</hardware>
</global>
<accounts>
<account name="default" default="true">
<serverandport>your.mumble.server:64738</serverandport>
<username>your_username</username>
<password>your_password</password>
<insecure>false</insecure>
<register>false</register>
<certificate></certificate>
<channel>Root</channel>
<ident></ident>
<tokens enabled="false"></tokens>
<voicetargets enabled="false">
<id id="1" iscurrent="false" name="default">
<channels></channels>
<users></users>
</id>
</voicetargets>
</account>
</accounts>
<beacon enabled="false"/>
<audio>
<input>
<settings enabled="true"
device="default"
samplerate="48000"
channels="1"
codec="opus"
framespersecond="50"/>
</input>
<output>
<settings enabled="true"
device="default"
samplerate="48000"
channels="1"/>
</output>
</audio>
<ptt enabled="false">
<usbkeyboard enabled="false"/>
</ptt>
<voiceactivity enabled="true">
<settings threshold="0.3"
holdtimems="1000"
holdtimeoutms="2000"/>
</voiceactivity>
<sounds enabled="false"/>
<txtts enabled="false"/>
<smtp enabled="false"/>
<api enabled="false"/>
<printxml enabled="false"/>
</document>
EOFXML
# Update the log path to use actual username (needs sudo since file was created with tee)
sudo sed -i "s|/home/user/|$TARGET_HOME/|g" "$CONFIG_DIR/talkkonnect.xml"
# Set proper ownership - always needed since we used sudo tee
sudo chown -R "$TARGET_USER:$TARGET_USER" "$CONFIG_DIR"
sudo chmod 755 "$CONFIG_DIR"
sudo chmod 644 "$CONFIG_DIR/talkkonnect.xml"
echo "[+] Set ownership of config directory to $TARGET_USER"
echo "[+] Created configuration file: $CONFIG_DIR/talkkonnect.xml"
# --- Audio Mixer Detection --------------------------------------------
echo ""
echo "[+] Detecting audio configuration for user: $TARGET_USER..."
# Detect audio devices as the target user
if [ "$TARGET_USER" != "$USER" ]; then
# Get target user's runtime directory
AUDIO_DEVICES=$(sudo -u "$TARGET_USER" XDG_RUNTIME_DIR=/run/user/$TARGET_UID aplay -l 2>/dev/null)
PIPEWIRE_SINKS=$(sudo -u "$TARGET_USER" XDG_RUNTIME_DIR=/run/user/$TARGET_UID pactl list sinks short 2>/dev/null)
else
AUDIO_DEVICES=$(aplay -l 2>/dev/null)
PIPEWIRE_SINKS=$(pactl list sinks short 2>/dev/null)
fi
# Determine best audio device
AUDIO_DEVICE="default"
AUDIO_BACKEND="alsa"
if echo "$AUDIO_DEVICES" | grep -q "card 0:"; then
# ALSA devices found
CARD_NAME=$(echo "$AUDIO_DEVICES" | grep "card 0:" | head -1 | sed 's/.*card 0: \([^[]*\).*/\1/' | xargs)
echo "[+] Found ALSA card: $CARD_NAME"
# Use hw:0,0 for direct ALSA access (better for Chromebooks)
AUDIO_DEVICE="hw:0,0"
echo "[+] Using ALSA device: $AUDIO_DEVICE"
elif echo "$PIPEWIRE_SINKS" | grep -v "auto_null" | grep -q "alsa_output"; then
# PipeWire/PulseAudio sinks found
SINK_NAME=$(echo "$PIPEWIRE_SINKS" | grep -v "auto_null" | grep "alsa_output" | head -1 | awk '{print $2}')
echo "[+] Found PipeWire sink: $SINK_NAME"
AUDIO_DEVICE="pulse"
AUDIO_BACKEND="pulse"
echo "[+] Using PulseAudio/PipeWire backend"
else
echo "[!] Warning: No specific audio device detected, using 'default'"
fi
# Try to unmute and set volume
MIXER_CONTROL=$(sudo -u "$TARGET_USER" amixer scontrols 2>/dev/null | grep -oP "Simple mixer control '\K[^']+(?=',0)" | head -1)
if [ -n "$MIXER_CONTROL" ]; then
echo "[+] Found mixer control: $MIXER_CONTROL"
# Try to unmute and set volume
sudo -u "$TARGET_USER" amixer set "$MIXER_CONTROL" unmute 2>/dev/null && echo "[+] Unmuted $MIXER_CONTROL" || echo "[!] Could not unmute $MIXER_CONTROL"
sudo -u "$TARGET_USER" amixer set "$MIXER_CONTROL" 80% 2>/dev/null && echo "[+] Set $MIXER_CONTROL to 80%" || echo "[!] Could not set volume"
else
echo "[!] Warning: No ALSA mixer controls found"
echo "[!] Audio may work through PipeWire, but you may need to configure it manually"
fi
echo "[+] Audio configuration summary:"
echo " Backend: $AUDIO_BACKEND"
echo " Device: $AUDIO_DEVICE"
if [ -n "$CARD_NAME" ]; then
echo " Card: $CARD_NAME"
fi
# --- Systemd Service --------------------------------------------------
echo ""
echo "[+] Creating systemd service for user: $TARGET_USER..."
SERVICE_FILE="/etc/systemd/system/talkkonnect.service"
sudo tee "$SERVICE_FILE" > /dev/null <<EOFSVC
[Unit]
Description=TalkKonnect Headless Mumble Transceiver
After=network-online.target sound.target
Wants=network-online.target
[Service]
Type=simple
User=$TARGET_USER
Group=$TARGET_USER
WorkingDirectory=$TARGET_HOME
ExecStart=/usr/local/bin/talkkonnect -config $CONFIG_DIR/talkkonnect.xml
Restart=always
RestartSec=5
StandardOutput=journal
StandardError=journal
# Use target user's PipeWire/PulseAudio session
Environment="XDG_RUNTIME_DIR=/run/user/$TARGET_UID"
[Install]
WantedBy=multi-user.target
EOFSVC
sudo systemctl daemon-reload
sudo systemctl enable talkkonnect
echo "[+] Systemd service created and enabled"
# --- Audio Ducking Setup (Optional) -----------------------------------
if [ "$TARGET_USER" != "$USER" ]; then
echo ""
echo "[+] Setting up audio ducking for user: $TARGET_USER..."
echo "[+] This will lower other audio when talkkonnect is transmitting"
# Create a script to enable ducking
DUCKING_SCRIPT="$TARGET_HOME/.config/talkkonnect/enable-ducking.sh"
cat > /tmp/enable-ducking.sh << 'EOFDUCKING'
#!/bin/bash
# Enable audio ducking - lowers music/media when on PTT
pactl load-module module-role-ducking \
trigger_roles=phone \
ducking_roles=music,video \
volume=30% 2>/dev/null || echo "Ducking already enabled"
EOFDUCKING
if [ "$TARGET_USER" != "$USER" ]; then
sudo cp /tmp/enable-ducking.sh "$DUCKING_SCRIPT"
sudo chown "$TARGET_USER:$TARGET_USER" "$DUCKING_SCRIPT"
sudo chmod +x "$DUCKING_SCRIPT"
else
cp /tmp/enable-ducking.sh "$DUCKING_SCRIPT"
chmod +x "$DUCKING_SCRIPT"
fi
rm /tmp/enable-ducking.sh
echo "[+] Ducking script created: $DUCKING_SCRIPT"
echo "[+] Run as $TARGET_USER to enable: $DUCKING_SCRIPT"
fi
# --- Completion -------------------------------------------------------
echo ""
echo "======================================================================="
echo "✓✓✓ INSTALLATION COMPLETE! ✓✓✓"
echo "======================================================================="
echo ""
if [ "$TARGET_USER" != "$USER" ]; then
echo "⚠️ IMPORTANT: Talkkonnect is configured to run as user: $TARGET_USER"
echo " This allows it to share the PipeWire audio session with your apps"
echo " All configuration files are in: $TARGET_HOME/.config/talkkonnect/"
echo ""
fi
if [ "$NEEDS_RELOGIN" = true ]; then
echo "⚠️ IMPORTANT: You were added to the 'input' group"
echo " You must LOG OUT and LOG BACK IN for keyboard PTT to work!"
echo " (Or run: newgrp input)"
echo ""
fi
echo "BEFORE RUNNING: Edit the configuration file with your Mumble server details"
echo ""
if [ "$TARGET_USER" != "$USER" ]; then
echo " 1. Edit config file (as $TARGET_USER or with sudo):"
echo " sudo nano $CONFIG_DIR/talkkonnect.xml"
else
echo " 1. Edit config file:"
echo " nano $CONFIG_DIR/talkkonnect.xml"
fi
echo ""
echo " 2. Update these settings:"
echo " - <serverandport>: Your Mumble server address and port"
echo " - <username>: Your Mumble username"
echo " - <password>: Your Mumble password (if required)"
echo " - <channel>: Channel to join on connect"
echo " - <insecure>: Set to 'true' if server has self-signed cert"
echo ""
echo " 3. Optional - Enable keyboard/USB button PTT (voice activation is enabled by default):"
echo " - Set <ptt enabled=\"true\">"
echo " - For USB mini keyboard: Find device with 'sudo evtest'"
echo " - Set <usbkeyboard enabled=\"true\" device=\"/dev/input/eventX\" keycode=\"KEY_F13\"/>"
echo " - Tip: Program your USB keyboard to send F13-F24 to avoid conflicts"
echo " - Set <voiceactivity enabled=\"false\"/>"
if [ "$TARGET_USER" = "$USER" ]; then
echo " - Make sure you're in 'input' group: groups | grep input"
fi
echo ""
echo "TESTING:"
if [ "$TARGET_USER" != "$USER" ]; then
echo " Test manually as $TARGET_USER:"
echo " sudo -u $TARGET_USER /usr/local/bin/talkkonnect -config $CONFIG_DIR/talkkonnect.xml"
else
echo " Test manually first:"
echo " /usr/local/bin/talkkonnect -config $CONFIG_DIR/talkkonnect.xml"
fi
echo ""
echo " Once working, start the service:"
echo " sudo systemctl start talkkonnect"
echo ""
echo " Check status:"
echo " sudo systemctl status talkkonnect"
echo ""
echo " View logs:"
echo " journalctl -u talkkonnect -f"
echo " cat $CONFIG_DIR/talkkonnect.log"
echo ""
if [ "$TARGET_USER" != "$USER" ]; then
echo "AUDIO DUCKING:"
echo " To enable audio ducking (lower other apps when transmitting):"
echo " sudo -u $TARGET_USER $CONFIG_DIR/enable-ducking.sh"
echo ""
fi
echo "FILES:"
echo " Binary: /usr/local/bin/talkkonnect"
echo " Config: $CONFIG_DIR/talkkonnect.xml"
echo " Service: /etc/systemd/system/talkkonnect.service"
echo " Source: ~/talkkonnect"
echo " Logs: $CONFIG_DIR/talkkonnect.log"
if [ "$TARGET_USER" != "$USER" ]; then
echo " Ducking: $CONFIG_DIR/enable-ducking.sh"
fi
echo ""
echo "TROUBLESHOOTING:"
echo " - Build log: /tmp/talkkonnect_build.log"
echo " - Runtime logs: $CONFIG_DIR/talkkonnect.log"
echo " - System logs: journalctl -u talkkonnect -f"
echo " - If audio issues: check 'aplay -L' and 'arecord -L'"
echo " - If 'unable to unmute' error: run 'amixer scontrols' to see controls"
echo " - If cert errors: set <insecure>true</insecure> in config"
echo " - For keyboard PTT: ensure you're in 'input' group (groups)"
echo " - If crash after connect: ensure voicetargets section exists in XML"
echo ""
echo "KNOWN ISSUES:"
echo " - Voice activation is enabled by default (PTT disabled)"
echo " - Keyboard PTT requires logout/login after installation"
echo " - Self-signed certs need <insecure>true</insecure>"
if [ "$TARGET_USER" != "$USER" ]; then
echo " - Config files owned by $TARGET_USER - edit with sudo or su"
echo " - Audio ducking must be enabled manually (see above)"
fi
echo ""
echo "CHROMEBOOK-SPECIFIC:"
echo " - If no audio: ensure $TARGET_USER can access /dev/snd devices"
echo " - Check audio as target user: sudo -u $TARGET_USER aplay -l"
echo " - PipeWire session: /run/user/$TARGET_UID/pipewire-0"
echo ""
echo "======================================================================="
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+12585
View File
File diff suppressed because it is too large Load Diff
+171
View File
@@ -0,0 +1,171 @@
'use strict';
// webui/lib/actions.js - the web UI's allow-list of privileged actions,
// and how to turn a web form's fields into the exact stdin sequence the
// real bash `action_*` function expects.
//
// Mirrors menus/addon_webui.sh's ALLOWED_ACTIONS array (inside the
// generated kiosk-webui-helper script) - kept in sync by hand rather
// than shared/generated, since both lists are short and deliberately
// curated. A mismatch between the two just means an action fails closed
// on whichever side is missing it, never open on both: server.js checks
// this list before spawning anything, and the helper script re-checks
// its own list before dispatching regardless of what server.js sent.
//
// Every buildStdin() below was verified against the real menus/*.sh
// source (prompt order, defaults, and which fields reject a blank
// answer and re-prompt) - see webui/test/actions.test.js, which drives
// the actual bash functions with this exact output and checks the real
// resulting state (cups_is_installed, lms_is_installed, etc), not just
// that the process exits 0.
const ACTIONS = {
install_cups: {
helperAction: 'action_install_cups',
label: 'Install CUPS Printing',
fields: [],
// action_install_cups's only prompt is "Install CUPS printing?
// (n)" - the web UI's own install button is the confirmation,
// so this always answers yes. No pause() in this function.
buildStdin() {
return 'y\n';
},
},
reconfigure_cups: {
helperAction: 'action_reconfigure_cups',
label: 'Reconfigure CUPS for network access',
fields: [],
// No prompts at all, no pause().
buildStdin() {
return '';
},
},
install_lms: {
helperAction: 'action_install_lms',
label: 'Install / reconfigure LMS Server',
// fields.alreadyInstalled must reflect real current state
// (server.js fills this in from lms_is_installed via the
// helper's own status check before offering the reconfigure
// fields) - action_install_lms branches on it internally and a
// wrong guess here desyncs the stdin sequence from what the
// real function actually prompts for.
fields: ['alreadyInstalled', 'reconfigurePort', 'newPort'],
buildStdin(f) {
if (f.alreadyInstalled) {
if (!f.reconfigurePort) {
return 'n\n\n'; // decline reconfigure, then pause()
}
const port = Number(f.newPort);
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error('newPort must be an integer 1-65535');
}
return `y\n${port}\n\n`; // accept, new port, pause()
}
return '\n'; // fresh install: fully automated except pause()
},
},
install_squeezelite: {
helperAction: 'action_install_squeezelite',
label: 'Install / reconfigure Squeezelite Player',
// If already installed, the real function first asks
// "Reconfigure?" (default n) and, if declined, returns
// immediately after just the pause() - it does NOT fall through
// to the player-name/server prompts. f.reconfigure must be
// explicit (not inferred from other fields) so the web UI can
// offer "leave it as-is" without also having to resend the
// current values.
fields: ['alreadyInstalled', 'reconfigure', 'playerName', 'lmsServer'],
buildStdin(f) {
if (f.alreadyInstalled && !f.reconfigure) {
return 'n\n\n'; // decline reconfigure, then pause()
}
const lines = [];
if (f.alreadyInstalled) lines.push('y'); // "Reconfigure?"
lines.push(f.playerName || ''); // blank -> "Kiosk" default
lines.push(f.lmsServer || ''); // blank -> auto-discovery
// "Reboot now?" is always answered "n" here regardless of
// what the UI shows - triggering a real `sudo reboot` from
// inside a one-click addon-install action is out of scope
// for this pass (see webui phase-2 plan). The UI surfaces
// "reboot required to start Squeezelite" as an info banner
// instead of a real remote reboot trigger.
lines.push('n');
lines.push(''); // pause()
return lines.join('\n') + '\n';
},
},
configure_asterisk_intercom: {
helperAction: 'action_configure_asterisk_intercom',
label: 'Configure Asterisk Intercom',
// Same shape as Squeezelite's reconfigure gate: if already
// installed, the real function asks "Reconfigure with a
// different server/extension?" (default n) and returns after
// just the pause() if declined - the rest of this sequence is
// never reached in that case.
fields: ['alreadyInstalled', 'reconfigure', 'serverIp', 'serverPort', 'extension', 'password', 'autoAnswer', 'useTls'],
buildStdin(f) {
if (f.alreadyInstalled && !f.reconfigure) {
return 'n\n\n'; // decline reconfigure, then pause()
}
const lines = [];
// Only present at all when baresip_is_installed is already
// true - a fresh install has no "Reconfigure?" prompt.
if (f.alreadyInstalled) lines.push('y');
// Server IP and extension reject a blank answer and
// re-prompt (a `while [[ -z ... ]]` loop in the real
// function) - sending an empty line here would desync the
// rest of the sequence by consuming a second prompt cycle,
// so these are validated up front instead.
if (!f.serverIp || !String(f.serverIp).trim()) throw new Error('serverIp is required');
lines.push(String(f.serverIp).trim());
lines.push(f.serverPort != null && f.serverPort !== '' ? String(f.serverPort) : '');
if (!f.extension || !String(f.extension).trim()) throw new Error('extension is required');
lines.push(String(f.extension).trim());
// Password also rejects blank and re-prompts, same reason.
if (!f.password) throw new Error('password is required');
lines.push(f.password);
lines.push(f.autoAnswer ? 'y' : 'n');
lines.push(f.useTls ? 'y' : 'n');
// "Proceed with installation?" (default y) - already
// confirmed by the web click that got us here.
lines.push('y');
lines.push(''); // pause()
return lines.join('\n') + '\n';
},
},
upgrade: {
helperAction: 'action_upgrade',
label: 'Check for and apply updates',
fields: [],
buildStdin() {
// action_upgrade's own flow: "Pull these changes...?" (y),
// then - only if there was anything to pull -
// "Restart kiosk display now...?" (y), then always
// "Check for and install the latest Electron...?", answered
// n here. That sub-flow's own prompts default to declining
// and aren't a good fit for one-click automation yet (see
// webui phase-2 plan, "Explicitly deferred"). Answering "n"
// to a prompt that never actually gets shown (nothing to
// pull, or the display-restart question) is harmless - a
// synthesized line bash never reads is simply left unread,
// not an error.
return 'y\ny\nn\n';
},
},
};
function getAction(name) {
return Object.prototype.hasOwnProperty.call(ACTIONS, name) ? ACTIONS[name] : undefined;
}
module.exports = { ACTIONS, getAction };
+161
View File
@@ -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 };
+97
View File
@@ -0,0 +1,97 @@
'use strict';
// webui/lib/jobs.js - runs one privileged action at a time via the
// allow-listed root helper (menus/addon_webui.sh's kiosk-webui-helper),
// and keeps an in-memory log so /api/actions/:name/run's caller and any
// number of SSE stream reconnects all see the same output. No database -
// this tool manages one kiosk, a Map is plenty.
//
// HELPER_PATH and SUDO_CMD are both overridable via environment (see
// webui/test/jobs.test.js): tests point HELPER_PATH at a small fake
// script and clear SUDO_CMD, so the real test suite never needs actual
// root or a real addon install - the same principle as every stubbed
// bash test in this project, just on the Node side.
const { spawn } = require('child_process');
const { randomUUID } = require('crypto');
const { getAction } = require('./actions');
const HELPER_PATH = process.env.HELPER_PATH || '/usr/local/bin/kiosk-webui-helper';
const SUDO_CMD = process.env.SUDO_CMD !== undefined ? process.env.SUDO_CMD : 'sudo';
const jobs = new Map();
let activeJobId = null;
function startJob(actionName, fields) {
const action = getAction(actionName);
if (!action) {
const err = new Error(`Unknown action: ${actionName}`);
err.status = 400;
throw err;
}
if (activeJobId) {
const err = new Error('Another action is already running - wait for it to finish first');
err.status = 409;
throw err;
}
// buildStdin() validates its own required fields and throws a plain
// Error with a human-readable message on bad input - treated as a
// 400 here, before anything is spawned.
let stdin;
try {
stdin = action.buildStdin(fields || {});
} catch (e) {
e.status = 400;
throw e;
}
const jobId = randomUUID();
const job = {
id: jobId,
name: actionName,
label: action.label,
status: 'running',
log: [],
exitCode: null,
listeners: new Set(),
};
jobs.set(jobId, job);
activeJobId = jobId;
const child = SUDO_CMD
? spawn(SUDO_CMD, [HELPER_PATH, action.helperAction])
: spawn(HELPER_PATH, [action.helperAction]);
const appendLine = (chunk) => {
const text = chunk.toString();
job.log.push(text);
for (const listener of job.listeners) listener(text);
};
child.stdout.on('data', appendLine);
child.stderr.on('data', appendLine);
const finish = (status, exitCode) => {
if (job.status !== 'running') return; // 'error' and 'close' can both fire
job.status = status;
job.exitCode = exitCode;
for (const listener of job.listeners) listener(null);
if (activeJobId === jobId) activeJobId = null;
};
child.on('close', (code) => finish(code === 0 ? 'success' : 'failed', code));
child.on('error', (err) => {
job.log.push(`\n[error] ${err.message}\n`);
finish('failed', null);
});
child.stdin.write(stdin);
child.stdin.end();
return job;
}
function getJob(jobId) {
return jobs.get(jobId);
}
module.exports = { startJob, getJob };
+828
View File
@@ -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"
}
}
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"name": "kiosk-webui",
"version": "1.0.0",
"main": "server.js",
"dependencies": {
"express": "^4.19.0"
}
}
+622
View File
@@ -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();
+240
View File
@@ -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 &amp; Timing
</button>
<button class="nav-item" data-page="display">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="14" rx="2"/><path d="M8 21h8M12 18v3"/></svg>
Display
</button>
<button class="nav-item" data-page="lockout">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="10" width="16" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/></svg>
Lockout
</button>
<button class="nav-item" data-page="addons">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2 3 7l9 5 9-5-9-5Z"/><path d="m3 12 9 5 9-5M3 17l9 5 9-5"/></svg>
Addons
</button>
<button class="nav-item" data-page="update">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-2.6-6.4M21 4v5h-5"/></svg>
Update
</button>
</div>
<div class="sidebar-footer">No login of its own - front this with your own reverse proxy + Authelia if it needs to be reachable beyond a trusted LAN.</div>
</nav>
<main class="main">
<div id="msg" class="banner" hidden></div>
<!-- Sites & Page Timing -->
<section class="page active" id="page-sites">
<div class="page-header">
<h1>Sites &amp; Page Timing</h1>
<p>The pages this kiosk rotates through, and how long each stays on screen.</p>
</div>
<div class="card">
<div id="sites-list"></div>
<div class="button-row">
<button type="button" id="add-site" class="secondary">Add a page</button>
<button type="button" id="save-sites">Save Sites &amp; Page Timing</button>
</div>
</div>
</section>
<!-- Display & Interaction -->
<section class="page" id="page-display">
<div class="page-header">
<h1>Display &amp; Interaction</h1>
<p>Touch gestures, link navigation, on-screen buttons, and the home page.</p>
</div>
<div class="card">
<form id="display-form">
<label>
Touch gesture mode
<select name="swipeMode">
<option value="dual">Dual-direction (recommended for touchscreens)</option>
<option value="standard">Standard</option>
</select>
</label>
<label>
Link navigation security
<select name="allowNavigation">
<option value="restricted">Restricted - only the loaded URL</option>
<option value="same-origin">Same-origin - links within the same domain (recommended)</option>
<option value="open">Open - any link</option>
</select>
</label>
<label class="checkbox"><input type="checkbox" name="enablePauseButton"> Pause button</label>
<label class="checkbox"><input type="checkbox" name="enableKeyboardButton"> On-screen keyboard button</label>
<label class="checkbox"><input type="checkbox" name="enableNavButton"> Navigation/help button</label>
<fieldset>
<legend>Home page</legend>
<label>
Home page
<select name="homeTabIndex" id="home-tab-select">
<option value="-1">Disabled</option>
</select>
</label>
<label>
Inactivity timeout (minutes)
<input type="number" name="inactivityTimeoutMinutes" min="1" max="240" step="1">
</label>
</fieldset>
<div class="button-row"><button type="submit">Save Display &amp; Interaction</button></div>
</form>
</div>
</section>
<!-- Password Protection & Lockout -->
<section class="page" id="page-lockout">
<div class="page-header">
<h1>Password Protection &amp; Lockout</h1>
<p>Blank the screen after inactivity and require a password to unlock.</p>
</div>
<div class="card">
<form id="lockout-form">
<label class="checkbox"><input type="checkbox" name="enablePasswordProtection" id="lockout-enabled"> Enable password protection</label>
<div id="lockout-fields">
<label>
<span id="password-label">Set lockout password</span>
<input type="password" name="newLockoutPassword" autocomplete="new-password">
</label>
<label>
Confirm password
<input type="password" id="lockout-password-confirm" autocomplete="new-password">
</label>
<label>
Inactivity lockout timeout (minutes, 0 = boot/wake only)
<input type="number" name="lockoutTimeoutMinutes" min="0" max="1440" step="1">
</label>
<label class="checkbox"><input type="checkbox" id="daily-lock-enabled"> Lock at a specific time daily</label>
<label>
Daily lock time
<input type="time" name="lockoutAtTime" id="lockout-at-time" disabled>
</label>
<label class="checkbox"><input type="checkbox" name="requirePasswordOnBoot"> Require password on system boot</label>
</div>
<div class="button-row"><button type="submit">Save Password Protection &amp; Lockout</button></div>
</form>
</div>
</section>
<!-- Addons -->
<section class="page" id="page-addons">
<div class="page-header">
<h1>Addons</h1>
<p>Install and configure the same addons the terminal menu offers. Uninstall isn't available here yet.</p>
</div>
<div id="addons-list"></div>
</section>
<!-- Update -->
<section class="page" id="page-update">
<div class="page-header">
<h1>Update</h1>
<p>Pull the latest code from git and re-apply setup (packages, app files, hardware config).</p>
</div>
<div class="card">
<div class="button-row"><button type="button" id="run-upgrade">Check for and apply updates</button></div>
<div class="job-panel" id="job-panel-upgrade">
<div class="job-panel-header">
<span>Update</span>
<span class="job-status" id="job-status-upgrade"></span>
</div>
<pre class="job-log" id="job-log-upgrade"></pre>
</div>
</div>
</section>
</main>
</div>
<template id="site-row-template">
<div class="site-row card-inset">
<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, &gt;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&hellip;</span>
</div>
<div class="button-row addon-actions"></div>
<form class="addon-form"></form>
<div class="job-panel">
<div class="job-panel-header">
<span>Log</span>
<span class="job-status"></span>
</div>
<pre class="job-log"></pre>
</div>
</div>
</template>
<script src="app.js"></script>
</body>
</html>
+395
View File
@@ -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
View File
@@ -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;
+170
View File
@@ -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();
+137
View File
@@ -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
View File
@@ -0,0 +1,30 @@
#!/bin/bash
# Variant of fake-helper.sh that also implements status_all with real,
# minimally stateful JSON (tracked via marker files alongside itself),
# for browser/manual smoke testing of the Addons page's pills/buttons
# actually flipping after a real install - not just that a job reports
# success. jobs.test.js intentionally uses the plainer fake-helper.sh
# instead, to exercise the malformed-status-response error path.
STATE_DIR="$(dirname "$0")/.fake-state"
mkdir -p "$STATE_DIR"
if [[ "$1" == "status_all" ]]; then
state() { [[ -f "$STATE_DIR/$1" ]] && echo true || echo false; }
echo "{\"cups\":$(state cups),\"lms\":$(state lms),\"squeezelite\":$(state squeezelite),\"asterisk_intercom\":$(state asterisk_intercom)}"
exit 0
fi
echo "fake-helper: action=$1"
stdin_content=$(cat)
echo "fake-helper: stdin-bytes=${#stdin_content}"
sleep 0.3
case "$1" in
action_install_cups | action_reconfigure_cups) touch "$STATE_DIR/cups" ;;
action_install_lms) touch "$STATE_DIR/lms" ;;
action_install_squeezelite) touch "$STATE_DIR/squeezelite" ;;
action_configure_asterisk_intercom) touch "$STATE_DIR/asterisk_intercom" ;;
esac
echo "fake-helper: done"
exit "${FAKE_HELPER_EXIT_CODE:-0}"
+13
View File
@@ -0,0 +1,13 @@
#!/bin/bash
# webui/test/fixtures/fake-helper.sh - stands in for the real, root-owned
# kiosk-webui-helper (menus/addon_webui.sh) in webui/test/jobs.test.js,
# so the job/SSE system can be tested without real root or a real addon
# install. Echoes what it received, sleeps briefly (long enough for the
# "another job is already running" test to reliably observe it), then
# exits with a controllable code.
echo "fake-helper: action=$1"
stdin_content=$(cat)
echo "fake-helper: stdin-bytes=${#stdin_content}"
sleep 0.2
echo "fake-helper: done"
exit "${FAKE_HELPER_EXIT_CODE:-0}"
+190
View File
@@ -0,0 +1,190 @@
'use strict';
// webui/test/jobs.test.js - integration test for the addon-install job
// system (/api/actions/*, /api/addons/status) against a fake helper
// script (test/fixtures/fake-helper.sh) instead of the real, root-owned
// kiosk-webui-helper - no real root, apt, or system mutation involved,
// matching this project's rule of never touching real system state in
// tests. Run with: node test/jobs.test.js
const fs = require('fs');
const os = require('os');
const path = require('path');
const assert = require('assert');
const scratchDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webui-jobs-test-'));
process.env.CONFIG_PATH = path.join(scratchDir, 'config.json');
process.env.HELPER_PATH = path.join(__dirname, 'fixtures', 'fake-helper.sh');
process.env.SUDO_CMD = ''; // run the fake helper directly, no real sudo
const app = require('../server');
let failures = 0;
async function check(label, fn) {
try {
await fn();
console.log(`PASS: ${label}`);
} catch (e) {
failures++;
console.log(`FAIL: ${label} - ${e.message}`);
}
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function waitForDone(base, jobId, timeoutMs = 3000) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const res = await fetch(`${base}/api/actions/jobs/${jobId}`);
const body = await res.json();
if (body.status !== 'running') return body;
await sleep(20);
}
throw new Error('timed out waiting for job to finish');
}
async function main() {
const server = app.listen(0, '127.0.0.1');
await new Promise((resolve) => server.once('listening', resolve));
const port = server.address().port;
const base = `http://127.0.0.1:${port}`;
await check('GET /api/actions lists the allow-listed actions with their fields', async () => {
const res = await fetch(`${base}/api/actions`);
assert.strictEqual(res.status, 200);
const body = await res.json();
const names = body.map((a) => a.name);
assert.ok(names.includes('install_cups'));
assert.ok(names.includes('configure_asterisk_intercom'));
const asterisk = body.find((a) => a.name === 'configure_asterisk_intercom');
assert.ok(asterisk.fields.includes('serverIp'));
});
await check('GET /api/addons/status returns the fake helper\'s status_all JSON', async () => {
// fake-helper.sh doesn't implement status_all specially - it just
// echoes/exits 0 with non-JSON text, so this exercises the
// malformed-response error path rather than a real status shape.
const res = await fetch(`${base}/api/addons/status`);
assert.strictEqual(res.status, 500);
});
let jobId;
await check('POST /api/actions/install_cups/run starts a job and returns its id', async () => {
const res = await fetch(`${base}/api/actions/install_cups/run`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
assert.strictEqual(res.status, 200);
const body = await res.json();
assert.ok(body.jobId);
assert.strictEqual(body.status, 'running');
jobId = body.jobId;
});
await check('a second action while one is running is rejected with 409', async () => {
const res = await fetch(`${base}/api/actions/reconfigure_cups/run`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
assert.strictEqual(res.status, 409);
});
await check('the job completes successfully and the log shows what the helper received', async () => {
const body = await waitForDone(base, jobId);
assert.strictEqual(body.status, 'success');
assert.strictEqual(body.exitCode, 0);
assert.ok(body.log.includes('fake-helper: action=action_install_cups'), body.log);
assert.ok(body.log.includes('fake-helper: stdin-bytes='), body.log);
assert.ok(body.log.includes('fake-helper: done'), body.log);
});
await check('after completion, a new action is accepted again (not stuck busy)', async () => {
const res = await fetch(`${base}/api/actions/reconfigure_cups/run`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
assert.strictEqual(res.status, 200);
const body = await res.json();
await waitForDone(base, body.jobId);
});
await check('unknown action name is rejected with 400, no job created', async () => {
const res = await fetch(`${base}/api/actions/definitely_not_real/run`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
assert.strictEqual(res.status, 400);
});
await check('missing required field is rejected with 400 before spawning anything', async () => {
const res = await fetch(`${base}/api/actions/configure_asterisk_intercom/run`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ alreadyInstalled: false, extension: '201', password: 'x' }),
});
assert.strictEqual(res.status, 400);
const body = await res.json();
assert.ok(/serverIp/.test(body.error), body.error);
});
await check('a failing helper is reflected as status failed with the real exit code', async () => {
process.env.FAKE_HELPER_EXIT_CODE = '1';
// jobs.js reads process.env.HELPER_PATH/SUDO_CMD once at module
// load, but FAKE_HELPER_EXIT_CODE is read fresh by the spawned
// shell script every time, so no re-require needed here.
const res = await fetch(`${base}/api/actions/reconfigure_cups/run`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
const { jobId: failId } = await res.json();
const body = await waitForDone(base, failId);
assert.strictEqual(body.status, 'failed');
assert.strictEqual(body.exitCode, 1);
delete process.env.FAKE_HELPER_EXIT_CODE;
});
await check('GET /api/actions/jobs/:id/stream (SSE) replays the log and sends a final done event', async () => {
const res = await fetch(`${base}/api/actions/install_lms/run`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ alreadyInstalled: false }),
});
const { jobId: streamJobId } = await res.json();
const streamRes = await fetch(`${base}/api/actions/jobs/${streamJobId}/stream`);
assert.strictEqual(streamRes.status, 200);
assert.strictEqual(streamRes.headers.get('content-type'), 'text/event-stream');
const reader = streamRes.body.getReader();
const decoder = new TextDecoder();
let raw = '';
const deadline = Date.now() + 3000;
while (!raw.includes('event: done') && Date.now() < deadline) {
const { value, done } = await reader.read();
if (done) break;
raw += decoder.decode(value, { stream: true });
}
assert.ok(raw.includes('event: log'), raw);
assert.ok(raw.includes('fake-helper: action=action_install_lms'), raw);
assert.ok(raw.includes('event: done'), raw);
assert.ok(/data: \{"status":"success","exitCode":0\}/.test(raw), raw);
});
server.close();
fs.rmSync(scratchDir, { recursive: true, force: true });
if (failures > 0) {
console.log(`${failures} FAILURE(S)`);
process.exit(1);
}
console.log('ALL DONE');
}
main();