#!/bin/bash ################################################################################ ### Ubuntu Based Kiosk v2.17.0 ### ################################################################################ # # RELEASE v2.17.0 - Web UI Now Installs by Default and Can Install/ # Reconfigure Addons + Check for Updates # - Web UI (Addons -> Web UI) now installs by default during first-time # provisioning (lib/provision.sh's provision_configure_webui), not # opt-in - fixed port 8090, no prompt (matches every other core step). # The Addons menu entry still works standalone for reconfiguring the # port or reinstalling it on a kiosk provisioned before this change. # - The web UI can now install/reconfigure CUPS Printing, LMS Server, # Squeezelite Player, and Asterisk Intercom, and check for updates - # the same four addons plus Update this project's user asked for by # name. Every one of these is the exact same interactive action_* # function the terminal menu already uses (action_install_cups, # action_install_lms, action_install_squeezelite, # action_configure_asterisk_intercom, action_upgrade) - no # prompt/mutation refactor of any addon file, driven instead by # piping the right answers on stdin, the same technique this # project's own bash tests already use to drive these functions. # - Privilege model: the web service itself still runs as $KIOSK_USER # with zero ambient sudo. A new narrow, allow-listed root helper # (menus/addon_webui.sh's webui_write_helper_script, reachable only # via a single-path passwordless sudo rule generated and validated # with `visudo -c -f` before being installed) is the only way the web # UI ever gains privilege, and it re-checks its own fixed action # allow-list before dispatching anything - a request that reaches it # can only ever trigger one of five vetted actions, never a root # shell. Chosen over running the whole service as root after asking # directly: this repo has no login of its own by design (Authelia # runs elsewhere), so a request that reaches it with no reverse proxy # in front is effectively unauthenticated - the allow-list bounds # what that can actually do. # - Long-running installs stream live output to the browser via # Server-Sent Events (webui/lib/jobs.js), with only one action # running at a time (a second request while one is in flight gets a # clear 409, not silently queued or dropped). # - Full visual redesign: a sidebar shell (Sites/Display/Lockout/ # Addons/Update) replacing the single scrolling page of three cards, # both light and dark themes via prefers-color-scheme, no external # font/CDN dependency. # - A real bug was found and fixed by actually driving the redesigned # UI in a headless browser, not just by reading the code: refreshing # an addon's pill/button after a successful install used to rebuild # the whole card, which raced (and usually lost to) the success # status/log that same job had just written a moment earlier. Fixed # to update pill/buttons in place, leaving the completed job's log # exactly as the user left it. # - Uninstall-via-web is deliberately still not offered, for any addon - # flagged as needing its own double-confirmation design, not bundled # into this pass. WiFi, Timezone, Power/Display/Quiet Hours, # Diagnostics, Remote Access, Authelia, Factory Reset, Virtual # Consoles, Emergency Hotspot, Clone Settings, and the fleet/ # multi-kiosk dashboard all remain out of scope for the web UI too, # each a named, sequenced follow-up rather than an oversight. # # RELEASE v2.16.0 - Web UI: Browser-Based Config Editor (install.sh -> # Addons -> Web UI) # - New: a small Node/Express app (webui/) installable via ./install.sh's # Addons menu, giving a browser-based editor for Sites & Page Timing, # Display & Interaction, and Password Protection & Lockout - the three # Core Settings menus that are pure config.json read/write with no # privileged system mutation involved. Runs as a systemd service under # $KIOSK_USER (the same user Electron runs as), so it never needs sudo # - it can read/write config.json directly with normal filesystem # permissions. webui/lib/config.js re-implements lib/config.sh's exact # schema and merge-on-save contract in JS (kiosk-app/main.js already # reads the same file directly in JS - established precedent, not a # new pattern), so it can never silently clobber fields it doesn't # track (Authelia's credentials, quiet-hours fields, etc) - the same # failure mode previously fixed in lib/config.sh's own history. # - No login of its own, by design - Authelia runs elsewhere, and the # expectation is a reverse proxy (e.g. Caddy) with Authelia forward- # auth in front of it, the same way other self-hosted apps get # protected. Direct LAN access with no proxy in front has no # authentication at all - treat it like SSH access to the kiosk. # - Deliberately narrow scope for this first pass: WiFi, Timezone, # Power/Display/Quiet Hours, Complete Uninstall, every other addon, # and everything in Advanced remain terminal-only - a network-facing # process shouldn't be handed sudo-level system mutation (netplan, # timedatectl, apt, systemd timers) without a lot more thought than # this pass gives it. A "restart kiosk display" action was left out # for the same reason - would need a narrow polkit grant, follow-up. # - Wired into Complete Uninstall (webui_do_uninstall) and Clone Settings # (webui addon-presence detection) the same way every other addon is. # - This is the single-kiosk piece of the planned web-based GUI # (mentioned in this repo's "Modular Management" notes for a while) - # a central multi-kiosk fleet dashboard is an intentional follow-up, # not part of this pass. # # RELEASE v2.15.0 - Upgrade Migrated to install.sh (Advanced -> Upgrade) # - New in ./install.sh's Advanced menu: Upgrade. Not a port of this # script's Upgrade - that one re-extracted main.js/preload.js/etc from # its own heredocs on every run, a mechanism that has no equivalent # here now that kiosk-app/ and provision/files/ are real files in the # git checkout. The modular Upgrade is `git pull` (only after # confirming the working tree is clean and the pull is a fast-forward # - never an automatic merge) followed by re-running the same # packages/kiosk-app/display/firewall/power-management steps # lib/provision.sh already has for a fresh install, reused rather than # reimplemented. Skips the interactive first-run settings wizard and # the "reboot now" prompt - those don't belong in a routine upgrade. # - Also offers an on-demand Electron version check/update regardless of # whether there was any code to pull, since Electron isn't versioned # by this repo - reuses the existing, already-tested # action_update_electron (menus/advanced_electron.sh) as-is. # - Requires a git checkout (not the no-git ZIP download option) and a # clean working tree; a diverged local history fails the pull cleanly # with a clear message instead of attempting an automatic merge. # - Full Reinstall dropped, not carried forward - it never worked # reliably in this script either, and the modular tool already covers # the same outcome more reliably as two already-tested pieces run back # to back: Complete Uninstall (Core Settings), then ./install.sh again # to provision fresh. No dedicated combined action needed. # # RELEASE v2.14.0 - install.sh Now Provisions a Kiosk From Scratch, # Not Just Manages an Existing One # - Until now, ./install.sh only worked against an already-installed # kiosk (this script was still the only path from a bare Ubuntu # Server box to a running one). It now provisions too: on a machine # with no kiosk-app directory yet, it installs packages, creates the # kiosk user, installs Node.js/Electron, sets up LightDM+Openbox # autologin, audio/video/HDMI/power-button hardware handling, the # firewall, then hands off to the same Core Settings menus below for # initial configuration - matching this script's own install-then- # configure flow, on the new modular codebase. # - New: lib/provision.sh (the provisioning steps, built almost # entirely by calling already-migrated menus - core_settings_menu, # action_configure_emergency_hotspot, action_disable_virtual_consoles # - rather than reimplementing that logic a third time), lib/electron.sh # (electron_install_binary, extracted out of menus/advanced_electron.sh # so both fresh provisioning and the existing "Fix blank screen" # action share one implementation), kiosk-app/ (the Electron app # source - main.js, preload.js, the dialog HTML files, package.json, # start.sh - extracted byte-for-byte out of this script's heredocs # into real files), provision/files/ (every other system template # file - X11 configs, udev rules, systemd units, the power-button and # HDMI-mirroring scripts, polkit rules - laid out mirroring their real # destination paths, e.g. provision/files/etc/X11/xorg.conf.d/foo.conf # installs to /etc/X11/xorg.conf.d/foo.conf). # - Reusing the already-migrated menus instead of reimplementing # first-time configuration cut lib/provision.sh down to roughly 300 # lines against this script's ~4,000-line first_time_install(). # - Fixed along the way: a bash `set -e` gotcha where testing a # multi-statement function as an if-condition (`if ! some_func; then`) # silently exempts everything inside that function from set -e for # the duration - found via direct testing while writing the new # provisioning code, then swept for elsewhere and also fixed in # menus/advanced_electron.sh's existing "Fix blank screen" action # (its electron_install_binary call had the same shape). # - Known, deliberate limitation carried over unchanged from this # script: a few of the extracted system scripts (start.sh, # kiosk-hotplug.sh, the power-button handler) hardcode the username # "kiosk" rather than substituting $KIOSK_USER, exactly as the # quoted heredocs here always did. Fine unless $KIOSK_USER is # overridden from its default, which in practice it almost never is. # - Still not ported to ./install.sh: Upgrade and Full Reinstall, both # coupled to this script's own heredoc self-extraction - a different # mechanism than the new provisioning (which copies real files from # kiosk-app/ and provision/files/, not heredocs). This script remains # the way to upgrade/reinstall an existing install for now. # # RELEASE v2.13.0 - Clone Settings: New MVP for Standing Up Several # Kiosks with the Same Settings # - New in ./install.sh's Advanced menu: Clone Settings # (menus/clone_settings.sh). Not a port of the legacy Export/Import # Settings - a narrower, deliberately-scoped feature for the "set up # one kiosk, then stamp out a dozen more like it" use case: export the # portable parts of config.json (sites, display/touch/navigation, # lockout, password protection) to a JSON file, apply that file to any # other already-installed kiosk. # - Explicitly does NOT copy machine-bound credentials, because copying # them would be actively wrong, not just incomplete: Authelia's # encrypted password is keyed off /etc/machine-id and decrypts to # garbage elsewhere; a WireGuard private key is a device identity and # reusing one across machines is a peer conflict; most Asterisk PBXes # reject two simultaneous registrations to the same extension. Apply # prints these as an explicit "needs a human" checklist instead of # silently skipping them or (worse) cloning them. # - Does not install missing addons - only records which addons were # present at export time and reports which of those are/aren't # present on the machine being applied to. Non-interactive addon # installation (so applying a profile needs zero prompts, scriptable # over SSH to a whole fleet) is deliberately left as a follow-up, not # bundled into this MVP. # # RELEASE v2.12.0 - Complete Uninstall Migrated (Last of the # "Destructive Trio"); Composed, Not Re-Implemented # - New in ./install.sh's Core Settings menu: Complete Uninstall # (menus/complete_uninstall.sh). Rather than re-implementing every # addon's teardown a second time (the shape this function had in the # legacy script - CUPS/VNC/WireGuard/Tailscale/Netbird/LMS/Squeezelite # removal logic all inlined again, independently of the same logic in # each addon's own uninstall action), it composes the *_do_uninstall # helpers each addon already has. If an addon's removal logic changes, # Complete Uninstall picks it up automatically instead of silently # drifting out of sync. # - Every addon menu that had an uninstall action (CUPS, VNC, WireGuard, # Tailscale, Netbird, LMS, Squeezelite, Asterisk Intercom) plus # power_schedule's "remove all schedules" and the Emergency Hotspot # disable action were each split into a confirm-and-call wrapper (the # existing interactive action, unchanged from the user's perspective) # and a silent do-the-removal helper that both the wrapper and # Complete Uninstall call - no duplicated removal logic anywhere. # - IMPORTANT bug found and fixed while composing these: several # *_do_uninstall helpers (CUPS's `apt autoremove`/`apt clean`, and # 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 (silently caught by run_menu's own guard) - a minor UX # blemish. Composed together as bare sequential calls inside Complete # Uninstall, the same failure would have silently truncated the # *entire* uninstall sequence partway through - e.g. the kiosk user # might never get removed because an already-uninstalled VPN client's # `apt remove` failed first. Guarded all of them with `|| true`, # fixing the risk in both the standalone action and the composition. # - 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, same as the legacy script, since no # single addon owns those paths. # - Upgrade and Full Reinstall remain in ubuntu-based-kiosk.sh only - # both are fundamentally coupled to this file's own heredoc self- # extraction of main.js/preload.js/etc, which has no equivalent in the # modular system yet. This closes out the "destructive trio." # # RELEASE v2.11.0 - 4 More Advanced Items Migrated (Electron Maintenance, # Factory Reset, Virtual Consoles, Emergency Hotspot) # - 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 under one # submenu, since both maintain the same installation and share 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 leaves Diagnostics' original 4 items plus these 4 covering 8 of # the legacy Advanced menu's 12 entries. Not migrated this round: # Export/Import Settings (kept in the legacy script pending a decision # on whether it's worth rebuilding around actual paths instead of a # hardcoded per-addon step list, or whether the future web UI replaces # the need for it) and Fix Squeezelite Audio (small and specific # enough that it may fold into menus/addon_lms_squeezelite.sh instead # of staying a standalone Advanced entry - not decided yet). # - Complete Uninstall (the last of the "destructive trio") is next, # composed from each addon's own uninstall action plus core teardown # rather than rewriting removal logic a second time. Upgrade and Full # Reinstall stay in this script for now: both are fundamentally # coupled to this file's own heredoc self-extraction of main.js/ # preload.js/etc, which has no equivalent yet in the modular system. # # RELEASE v2.10.0 - Asterisk Intercom Migrated, Redesigned as a SIP # Extension Client (No More PBX Server Install) # - New in ./install.sh: Asterisk Intercom (menus/addon_asterisk_intercom.sh). # The legacy addon offered three options: Client Only (a Baresip SIP # client), Server Only, and Full (server + client) - the latter two # 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 rather than carrying # a dependency on code that's moved on without it. The addon now does # only the client/endpoint piece: install Baresip and register it as # one SIP extension against an Asterisk server the user already has # running somewhere else. It never installs or manages Asterisk # itself. The legacy script's own three-option version is untouched - # both copies coexist deliberately, same as every other migrated menu. # - Dropped the legacy client path's dependency on the (now-reworked) # Easy Asterisk repo's GitHub API for version tracking. It now reads # the real installed `baresip` package version via dpkg instead - one # less network dependency and one less thing to keep in sync with an # external repo. # - New capability: an uninstall option for the Baresip client, which # the legacy addon never had at all. # - Bug fix (found while porting): `baresip_installed_version()`'s # `dpkg-query` call fails (as expected) when the package isn't # installed, and the unguarded `ver=$(...)` assignment around it would # have crashed the whole session under this tool's `set -e` the first # time status was checked before Baresip was installed. Guarded with # `|| true` - the same class of bug hunted throughout this migration, # caught by testing before it shipped. # # RELEASE v2.9.0 - LMS Server / Squeezelite Player Migrated; # is_service_enabled() Dead Pre-Check Fixed # - New in ./install.sh: LMS Server / Squeezelite Player # (menus/addon_lms_squeezelite.sh) - install/reconfigure/uninstall for # an LMS (Lyrion/Logitech Media Server) server the kiosk can host, and # a Squeezelite player the kiosk can run against any LMS server on the # LAN. Squeezelite's own start script and systemd unit now go through # $BIN_DIR/$SYSTEMD_DIR (lib/config.sh) instead of hardcoded # /usr/local/bin and /etc/systemd/system, matching every other addon; # LMS's own apt repo/GPG key/ufw rules stay at their real fixed system # paths, same as CUPS. # - Fixed a real unguarded-pipeline bug from the legacy install_lms(): # `sudo systemctl enable "$service_name" 2>&1 | tee /tmp/lms-enable.log` # made the whole statement's exit status depend on `tee` (always 0) # instead of `systemctl enable`, so a real enable/start failure was # silently swallowed rather than falling through to a warning. Now # uses the shared enable_and_start_units() helper instead. # - Fixed is_service_enabled() (shared by both scripts): its pre-check # `systemctl list-unit-files | grep -q "^${service}\s"` never matched, # since every call site passes a bare service name (e.g. # "squeezelite") while list-unit-files lines start with # "squeezelite.service" - so the function always fell through to # `return 1` regardless of the real enabled state. `systemctl # is-enabled` already reports "not found" as a failure on its own, so # the dead pre-check is simply dropped. Backported here since it's the # same shared function in both scripts and the fix is low-risk # (behavior-preserving for every state except the one it was silently # getting wrong). # # RELEASE v2.8.0 - Remote Access Migrated (VNC/WireGuard/Tailscale/ # Netbird); Framework-Level Status-Function Crash Fixed # - New in ./install.sh: Remote Access (menus/addon_remote_access.sh) - # VNC (x11vnc), WireGuard, Tailscale, and Netbird, each with its own # install/connect/status/uninstall flow. The biggest Addon migrated so # far (4 sub-areas). Tailscale and Netbird install via the vendors' # own documented `curl -fsSL | sh` method, preserved as-is. # - New $WIREGUARD_DIR (lib/config.sh), same pattern as $SYSTEMD_DIR # etc - nothing here hardcodes /etc/wireguard. # - Promoted power_schedule.sh's enable_and_start_timers() to a shared # enable_and_start_units() in lib/menu.sh (works for services now too, # not just timers) - Remote Access needed the identical pattern for # x11vnc and wg-quick@, so this is now fixed and reusable everywhere # instead of being duplicated a second time. # - IMPORTANT framework-level bug found and fixed in lib/menu.sh's # run_menu(): the *handler* call has been `|| true`-guarded since # v2.1.0, but the *status function* call was still bare and completely # unprotected. A status function's job is read-only display, but if # one 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 succeeds), that bare call would crash the *entire # session* - not just fail to show status. Found while writing # wireguard_status()'s `sudo wg show | grep ... | sed ...` and # confirming its exact failure mode before assuming it was already # covered. Fixed once in run_menu() itself, protecting every status # function across every menu, present and future - same "fix once at # the framework level" pattern as the v2.1.0 handler fix. Also audited # every existing status function across all menus for the same # specific shape (a bare `var=$(...)` assignment from a grep-based # pipeline, not embedded in an echo and not already guarded) and found # one real instance in power_schedule_status(), now fixed too. # # RELEASE v2.7.0 - Backported Fix: save_config() No Longer Deletes # Authelia Credentials (or Any Other Untracked Field) # - This script's own save_config() had the exact bug described under # v2.6.0 below: it rebuilt config.json from a fixed list of known # fields via `jq -n`, which silently deleted anything it didn't know # about - specifically autheliaURL/autheliaUsername/ # autheliaEncryptedPassword, written by configure_authelia()'s own # careful `. + {...}` merge. Configure Authelia, then visit Core # Settings → Sites/Touch Controls/Navigation/Password Protection (all # of which call save_config), and the Authelia credentials were # silently gone - a real credential-loss bug that was shipping in this # script independent of the modular migration. # - Fixed the same way as lib/config.sh's save_config: merge the known # fields onto whatever's already in config.json (`. + {...}`) instead # of rebuilding it from nothing, with a `jq empty` validity check # falling back to `{}` if the existing file is missing or corrupt. # Verified in isolation (the exact function extracted and exercised # against a stub config.json seeded with Authelia-style fields, # confirming they survive a second save_config call while an actual # settings change still takes effect, plus the corrupt/missing-file # edge cases) before touching the shipping copy. # - This is a standalone backport of one specific fix, not a wider # migration of the Sites/Touch/Navigation/Authelia menus into this # script - those still work exactly as before, just without the # credential-loss bug. The modular ./install.sh path (lib/config.sh) # got the equivalent fix in v2.6.0. # # RELEASE v2.6.0 - Authelia Migrated; Real Config-Clobbering Bug Fixed # - New in ./install.sh: Authelia Auto-Login (menus/addon_authelia.sh) - # encrypted SSO credentials (AES-256-CBC, key derived from this # machine's /etc/machine-id via scrypt - same algorithm main.js # decrypts with, verified by test with a real round-trip encrypt/ # decrypt, not just "some string came out"), plus the full Dockerized # server-side setup instructions, viewable again later without # reconfiguring. # - IMPORTANT bug found and fixed in lib/config.sh, NOT specific to # Authelia or to this migration: save_config() did a full `jq -n` # rebuild of config.json from known fields, exactly like the legacy # script's save_config still does. Authelia's own write is a careful # `. + {...}` merge that preserves everything - but the legacy # configure_authelia() writes autheliaURL/autheliaUsername/ # autheliaEncryptedPassword into config.json via that merge, and # *neither* the legacy save_config nor this project's own (before this # fix) had any idea those three fields existed. 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 is a real bug in the currently-shipping # single-file installer, not introduced by this migration; ported # faithfully into lib/config.sh's first version because no test # happened to set an untracked field before calling save_config. # Fixed here by changing save_config to merge its known fields onto # whatever's already in config.json (jq `. + {...}`) instead of # rebuilding the file from nothing, so any field this tool doesn't # track - Authelia's three today, anything else tomorrow - survives # automatically. autheliaURL/autheliaUsername/autheliaEncryptedPassword # are also now tracked fields in their own right, same as every other # config.json field this tool manages. NOTE: the equivalent bug still # exists in this script's own save_config below, unfixed - see # Readme.md ("Modular Management") for the open question of whether to # backport this specific fix here independent of the wider migration, # given it's a real, currently-shipping credential-loss bug. # # RELEASE v2.5.0 - First Addon Migrated (CUPS), Menu Restructured # - New in ./install.sh: CUPS Printing (menus/addon_cups.sh) - the first # Addon migrated. Install/reconfigure/complete uninstall (purge), # genuinely mutating 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 controls, there is no # scratch equivalent for a real apt-managed subsystem's own file # layout, so every test uses full command-level `sudo` stubbing # instead. Only the polkit rule's directory is parameterized # ($POLKIT_DIR, since that one is ours to place). # - install.sh's top-level menu is now grouped the same way the legacy # menu groups things - Core Settings / Addons / Advanced - instead of # one flat list, ahead of that list getting unwieldy as more Addons # and Advanced items migrate in. # - Two bugs caught and fixed before they ever shipped, both instructive # beyond this one file: # - A "wait for service to start" retry loop used a bare `cmd1 && # cmd2 && break` as its body. That's not safe merely because it's # inside a loop - a bare &&/|| list used as a standalone statement # (not the condition of if/while/until) is fully subject to set -e, # and cmd1 failing on an early iteration (near-certain right after # a fresh install) would have killed the whole session. Restored # the `if cmd1 && cmd2; then break; fi` form the legacy script # already used correctly, rather than "simplifying" it away. # - Resolved real uncertainty about how far run_menu's `handler || # true` guard (added in v2.1.0) actually reaches: verified with a # minimal isolated test that it protects against a bare failing # command no matter how many function calls deep it occurs - bash's # errexit exemption for the left side of `||` covers the entire # evaluation, not just the immediately-called 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 matter for a different reason: # without them a deep failure silently bubbles up past the menu # that's actually responsible for it to wherever the nearest `|| # true` happens to catch it, which may be several menu levels # higher than where the user actually was. # # RELEASE v2.4.0 - Diagnostics Migrated # - New in ./install.sh: Diagnostics (menus/diagnostics.sh) - system # status, log viewing (Electron/LightDM/journal), an 8-step audio # diagnostic, and a ping+DNS network test, pulled from the legacy # Advanced menu. Everything here is read-only except one optional # "play a test sound?" prompt - a deliberate change of pace after # Sites/WiFi/Power, with no destructive-action risk to design around. # Manual Electron Update, Factory Reset, Export/Import Settings, # Emergency Hotspot, and Fix Blank Screen are staying in the legacy # script for now - they're mutating/destructive, and some share # Upgrade's coupling to the legacy script's own self-extraction # mechanism (see v2.3.0 below for why Upgrade/Reinstall/Uninstall # aren't migrated either). # - Fixed (set -e safety, same class as v2.1.0/v2.3.0): every diagnostic # command whose failure is actually the expected, common case - no # lightdm running, no audio hardware, no network, missing log files, # `ping`/`nslookup` not even installed - was a bare unguarded # statement that would have crashed the whole session instead of # reporting "not found" and moving on. A diagnostics tool has to be # the most crash-proof code in the project, since it exists to run # *when something is already broken*; every one of these now reports # and continues instead. Also worth noting for future menus: writing # `local var;` and `var=$(cmd)` as separate statements (good practice, # and how earlier real bugs in this migration were caught) removes an # accidental safety net bash's `local x=$(cmd)` has on one line - that # form masks the substitution's exit code with `local`'s own # always-success status. Splitting them is correct, but each split # assignment needs its own explicit `|| true` (or real fallback) where # a failure is expected and non-fatal, rather than relying on that # quirk by accident. # # RELEASE v2.3.0 - WiFi and Power/Display/Quiet Hours Migrated # - New in ./install.sh: WiFi (menus/wifi.sh) and Power/Display/Quiet # Hours (menus/power_schedule.sh) - by far the biggest and riskiest # menus migrated so far. WiFi can rewrite live netplan config and, if # run over SSH, disconnect the very session configuring it; Power # schedule can shut the physical machine down and wake it via RTC. # Every safety mechanism from the legacy menus is preserved exactly: # netplan backup + 60s SSH watchdog + restore-on-apply-failure for # WiFi; RTC availability detection for power scheduling. New # $SYSTEMD_DIR/$CRON_D_DIR/$BIN_DIR/$NETPLAN_DIR variables (lib/config.sh) # mean nothing under menus/ hardcodes /etc/systemd/system, /etc/cron.d, # /usr/local/bin, or /etc/netplan - tests point them at scratch space. # - Fixed: the legacy dispatcher refused to open "Configure power # schedule" at all when no RTC wake was detected, even though # shutdown-only scheduling never needed RTC in the first place. # - Fixed: none of shutdown/wake/display-off/display-on/quiet-start/ # quiet-end/custom-Electron-reload times were validated as HH:MM in # the legacy menus (plain `read`, no format check) - now all go # through ask_time. # - Fixed (set -e safety, same class as v2.1.0's run_menu fix): several # bare, unguarded statements whose failure would have taken down the # entire session instead of just that action - `ls *.yaml` when no # netplan file exists (masked in practice by cloud-init usually # leaving one behind), the restore-and-reapply `netplan apply` after # an initial apply failure, and `systemctl enable`/`start` after # writing each of the four timer pairs. The last of these was caught # only by testing in an environment without a live systemd - a real # `enable`/`start` failure on actual hardware (bad unit, daemon-reload # skipped, ...) would have hit the same bug. All now report a clear # warning and return to the menu instead. # - Deliberately NOT migrated: the legacy dispatcher's "Test schedules & # system" led into a shared diagnostics submenu (audio/network/ # keyboard tests) that isn't specific to scheduling and belongs with a # future Advanced/Diagnostics migration instead. # # RELEASE v2.2.0 - Password Protection & Lockout Migrated # - New in ./install.sh: Password Protection & Lockout (menus/lockout.sh) - # enable/disable, change password (SHA-256 hashed before it's ever # written to disk, matching main.js's comparison logic - never # plaintext), inactivity timeout, daily lock time, boot password. # - Fixed: lib/menu.sh was missing ask_time/validate_time entirely (only # caught by testing this menu, before it shipped - "Set daily lock # time" would have failed with "ask_time: command not found" for every # user). Ported from the legacy script; also promoted the ON/OFF # toggle-label helper (previously private to menus/display.sh) to a # shared `onoff()` in lib/menu.sh so menus/lockout.sh doesn't have to # depend on menus/display.sh - menus should only ever depend on lib/. # # RELEASE v2.1.0 - Two More Menus Migrated, Menu Framework Hardened # - New in ./install.sh: Timezone (menus/timezone.sh) and Hidden Site PIN # (menus/hidden_pin.sh) menus, alongside Sites & Page Timing and Display # & Interaction from v2.0.0. Timezone doubles as a demonstration of the # framework: the old hand-numbered 18-entry case statement is now just # a data list plus one handler. # - Hardened lib/menu.sh: since this whole tool runs under `set -e`, a menu # action that legitimately fails (e.g. rejecting an invalid timezone) and # returns non-zero as its last statement could take down the *entire* # session, not just that one action - one typo would silently drop the # user back to their shell. Caught by testing menus/timezone.sh (its # set_timezone() does `return 1` on an invalid zone) before this ever # shipped; run_menu() now absorbs a failed handler's exit code so it # only redraws the menu, protecting every menu, present and future. # - The old (unmigrated) configure_sites/configure_touch_controls/ # configure_navigation_security/configure_optional_features functions # still live in this script, unchanged, and still have both v2.0.0 bugs # above - left in place deliberately until enough of Core Settings/ # Addons/Advanced is migrated to retire them in one pass. configure_ # timezone/configure_hidden_site_pin don't share the set -e hazard # (they never use a bare `return 1`), but are otherwise also still # here unchanged pending the same cleanup. See Readme.md ("Modular # Management") for current migration status. # # RELEASE v2.0.0 - Modular Management & Unversioned Filename # - New git-clone-based management path: lib/menu.sh (reusable numbered-menu # framework) + lib/config.sh (single config.json load/save) + menus/*.sh, # run via ./install.sh against an already-installed kiosk. Sites & Page # Timing and Display & Interaction are migrated; the rest of Core # Settings/Addons/Advanced still live here and will move over the same # way, one menu at a time. See Readme.md ("Modular Management"). # - Fixed: the old Sites menu could save config.json without first loading # swipe/navigation/lockout settings, silently resetting them to defaults. # - Fixed: reordering sites had an off-by-one that left the moved site one # slot short of the requested position. # - This script is now distributed as ubuntu-based-kiosk.sh (no version # number in the filename) so it can be updated in place; released # versions are tracked via git history and this changelog instead. # Older ubuntu-based-kiosk-v*.sh / install_kiosk_*.sh files remain in the # repo as archived releases. # # RELEASE v1.0.3 - Touch Screen Detection & Upgrade Reliability # - Authelia auto-login addon (Addons menu → 5) # Password encrypted with AES-256-CBC keyed from /etc/machine-id # Authenticates via Authelia API on every startup before sites load # Prints full Dockerized Authelia server-side setup after configuration # - Fixed PipeWire config dirs created as root (permission denied at step 5.5/27) # - README: dynamic install commands (no hardcoded version numbers) # - Node.js 20 → 22 LTS, Electron 41 → 42 # # RELEASE v1.0.1 - Node.js 22 & Electron 42 # - Node.js upgrade: 20 LTS → 22 LTS # - Electron upgrade: v39/41 → v42.x # # RELEASE v1.0.0 - Silent Upgrade & Power Button Fixes # - Silent upgrade: no user input required, extracts files from script # - Import now allows selecting backup by number instead of typing path # - Improved power button handler with better Electron process detection # - Power button now uses SIGUSR1 as primary method (more reliable) # - Upgrade automatically updates power button handler # - Added VPN configs (WireGuard, Netbird, Tailscale, OpenVPN) to backup # # RELEASE v0.9.9 - Settings Export/Import & Bug Fixes # - Added Export/Import All Settings feature (Advanced menu) # Backs up: sites, schedules, Squeezelite, VNC, Easy Asterisk configs # - Fixed power button not showing power menu (added SIGUSR1 handler) # - Fixed pause dialog staying open indefinitely (added 30s auto-close) # - Added PipeWire noise cancellation for microphone (reduces static) # # RELEASE v0.9.8 - Named Sites & Navigation Menu # - Added named websites feature for user-friendly site identification # - Added navigation menu with key icon (top left hot corner) # - Fixed virtual console menu display bug (now checks both getty and X11 settings) # - Fixed complete uninstall to properly remove LMS/Squeezelite # - Fixed full reinstall to clean all addons and settings (except saved VPN/VNC) # # Built with Claude Sonnet 4/.5 AI assistance # License: GPL v3 - Keep derivatives open source # Repository: https://github.com/outis1one/ubuntu-based-kiosk/ # # TARGET SYSTEMS: # - Ubuntu 24.04+ Server (minimal install recommended) # - Raspberry Pi 4+ (with or without touchscreen) # - Laptops, desktops, all-in-ones, 2-in-1s # - Touch support optional (works with keyboard/mouse) # # SECURITY NOTICE: # This is NOT suitable for secure locations or public kiosks. # Do NOT use as a replacement for hardened kiosk solutions. # Use entirely at your own risk. # # PURPOSE: # Home/office kiosk for reusing old hardware, displaying: # - Self-hosted services (Immich, MagicMirror2, Home Assistant) # - Web dashboards, digital signage # - Photo slideshows, family calendars # - Any web-based content # ################################################################################ set -euo pipefail ################################################################################ ### SECTION 1: CONSTANTS & GLOBALS ################################################################################ SCRIPT_VERSION="2.17.0" # Resolve the real path to this script file. # When piped (curl|bash or wget|bash), BASH_SOURCE[0] is a pipe descriptor, # not a real file. The upgrade function needs to read heredocs from a file, # so we detect the pipe case here and set SCRIPT_FILE accordingly. SCRIPT_FILE="$(readlink -f "${BASH_SOURCE[0]}" 2>/dev/null || true)" if [[ ! -f "$SCRIPT_FILE" ]]; then SCRIPT_FILE="" # running from a pipe — upgrade will warn the user fi KIOSK_USER="kiosk" BUILD_USER="${SUDO_USER:-$(whoami)}" KIOSK_HOME="/home/${KIOSK_USER}" KIOSK_DIR="${KIOSK_HOME}/kiosk-app" CONFIG_PATH="${KIOSK_DIR}/config.json" # DEFAULT VALUES AUTOSWITCH="true" SWIPE_MODE="dual" ALLOW_NAVIGATION="same-origin" declare -a URLS=() declare -a DURS=() declare -a USERS=() declare -a PASSES=() HOME_TAB_INDEX=-1 INACTIVITY_TIMEOUT=60 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" ################################################################################ ### SECTION 2: HELPER/UTILITY FUNCTIONS ################################################################################ log_info() { echo "[INFO] $*" } log_error() { echo "[ERROR] $*" >&2 } log_success() { echo "✓ $*" } log_warning() { echo "⚠ $*" } # INPUT VALIDATION HELPER ask_yes_no() { local prompt="$1" local default="${2:-n}" while true; do read -r -p "$prompt (y/n) [$default]: " answer answer="${answer:-$default}" # Convert to lowercase and check case "${answer,,}" in y|yes) return 0 ;; n|no) return 1 ;; *) echo "❌ Invalid input. Please enter 'y' or 'n' (yes/no)" echo ;; esac done } # Enhanced validation that accepts more formats 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 with better error messages ask_yes_no() { local prompt="$1" local default="${2:-n}" 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 } pause() { read -r -p "Press Enter to continue..." } ################################################################################ ### COMPREHENSIVE INPUT VALIDATION FUNCTIONS ################################################################################ # Validate time in HH:MM format validate_time() { local time="$1" if [[ $time =~ ^([0-1][0-9]|2[0-3]):([0-5][0-9])$ ]]; then return 0 else return 1 fi } # Ask for time with validation 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)" echo fi done } # Validate integer validate_integer() { local value="$1" local min="${2:--2147483648}" # Default to INT_MIN local max="${3:-2147483647}" # Default to INT_MAX if [[ $value =~ ^-?[0-9]+$ ]]; then if [[ $value -ge $min && $value -le $max ]]; then return 0 fi fi return 1 } # Ask for integer with validation 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" echo fi done } # Validate URL format validate_url() { local url="$1" # Basic URL validation - check for http(s):// or file:// or data: if [[ $url =~ ^(https?|file|data)://.*$ ]] || [[ $url =~ ^about: ]]; then return 0 else return 1 fi } # Ask for URL with validation 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:" echo fi done } # Validate menu choice validate_menu_choice() { local choice="$1" local max="$2" if validate_integer "$choice" 0 "$max"; then return 0 else return 1 fi } # Ask for menu choice with validation 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" echo fi done } is_service_active() { local service="$1" systemctl is-active --quiet "$service" 2>/dev/null } is_service_enabled() { local service="$1" # `systemctl is-enabled` already reports "not found" as a failure on # its own - no need for (and no correct way to write, given every # call site here passes a bare service name while list-unit-files # lines start with "$service.service") a pre-check via # list-unit-files. The previous "^${service}\s" pre-check never # matched, so this function always fell through to `return 1` # regardless of the real enabled state. systemctl is-enabled --quiet "$service" 2>/dev/null } get_ip_address() { hostname -I | awk '{print $1}' || echo "No IP" } 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=$(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=$(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=$(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" } kiosk_user_exists() { id "$KIOSK_USER" &>/dev/null 2>&1 } is_kiosk_installed() { kiosk_user_exists && sudo -u "$KIOSK_USER" test -f "$KIOSK_DIR/main.js" 2>/dev/null } backup_file() { local file="$1" if [[ -f "$file" ]]; then local backup="${file}.backup-$(date +%Y%m%d-%H%M%S)" sudo cp "$file" "$backup" log_success "Backup: $backup" echo "$backup" fi } get_battery_status() { if [[ -d /sys/class/power_supply/BAT0 ]] || [[ -d /sys/class/power_supply/BAT1 ]]; then local bat_path=$(ls -d /sys/class/power_supply/BAT* 2>/dev/null | head -1) if [[ -n "$bat_path" ]]; then local capacity=$(cat "$bat_path/capacity" 2>/dev/null || echo "N/A") local status=$(cat "$bat_path/status" 2>/dev/null || echo "Unknown") echo "${capacity}% (${status})" return 0 fi fi echo "No battery" return 1 } get_cpu_temp() { if [[ -f /sys/class/thermal/thermal_zone0/temp ]]; then local temp=$(cat /sys/class/thermal/thermal_zone0/temp) local temp_c=$((temp / 1000)) local temp_f=$((temp_c * 9 / 5 + 32)) echo "${temp_c}°C / ${temp_f}°F" elif command -v sensors &>/dev/null; then sensors 2>/dev/null | grep -i "core 0" | awk '{print $3}' | head -1 || echo "N/A" else echo "N/A" fi } get_cpu_info() { local cpu_model=$(lscpu | grep "Model name" | cut -d':' -f2 | xargs) if [[ -z "$cpu_model" ]]; then cpu_model=$(grep "model name" /proc/cpuinfo | head -1 | cut -d':' -f2 | xargs) fi local cores=$(nproc) local threads=$(lscpu | grep "^CPU(s):" | awk '{print $2}') echo "${cpu_model} (${cores} cores, ${threads} threads)" } ################################################################################ ### SECTION 2.5: STATUS DISPLAY FUNCTIONS ################################################################################ show_system_status() { echo " ══ SYSTEM STATUS ══" echo if is_kiosk_installed; then echo "Core System: ✓ Installed (v${SCRIPT_VERSION})" is_service_active lightdm && echo " LightDM: ✓ Running" || echo " LightDM: ✗ Stopped" else echo "Core System: ✗ Not installed" fi echo echo " ══ SYSTEM RESOURCES ══" local ip=$(get_ip_address) echo "IP Address: $ip" local vpn_ips=$(get_vpn_ips) if [[ "$vpn_ips" != "None" ]]; then echo "VPN IPs: $vpn_ips" fi local disk_info=$(df -h / | tail -1) local disk_used=$(echo "$disk_info" | awk '{print $3}') local disk_total=$(echo "$disk_info" | awk '{print $2}') local disk_avail=$(echo "$disk_info" | awk '{print $4}') local disk_pct=$(echo "$disk_info" | awk '{print $5}') echo "Disk: $disk_used used / $disk_total total ($disk_avail free) [$disk_pct]" local mem_info=$(free -h | grep "Mem:") local mem_total=$(echo "$mem_info" | awk '{print $2}') local mem_used=$(echo "$mem_info" | awk '{print $3}') local mem_avail=$(echo "$mem_info" | awk '{print $7}') local mem_pct=$(free | grep Mem | awk '{printf "%.0f", $3/$2 * 100}') echo "RAM: $mem_used used / $mem_total total ($mem_avail free) [${mem_pct}%]" local cpu=$(get_cpu_info) echo "CPU: $cpu" local temp=$(get_cpu_temp) echo "Temperature: $temp" local battery=$(get_battery_status) echo "Battery: $battery" local uptime=$(uptime -p | sed 's/up //') echo "Uptime: $uptime" echo } show_addon_status() { echo " ═══ INSTALLED ADDONS ═══" echo local any_addon=false # LMS/Squeezelite - FAST CHECK (just check if files exist) local lms_active=false local sq_active=false if [[ -f /lib/systemd/system/logitechmediaserver.service ]] || \ [[ -f /lib/systemd/system/lyrionmusicserver.service ]]; then lms_active=true any_addon=true fi if [[ -f /etc/systemd/system/squeezelite.service ]]; then sq_active=true any_addon=true fi # Check if services are actually running if is_service_active logitechmediaserver || is_service_enabled logitechmediaserver || \ is_service_active lyrionmusicserver || is_service_enabled lyrionmusicserver; then lms_active=true any_addon=true fi if is_service_active squeezelite || is_service_enabled squeezelite; then sq_active=true any_addon=true fi if $lms_active || $sq_active; then local status_text="LMS/Squeezelite: " if $lms_active && $sq_active; then status_text="${status_text}✓ Server+Player" elif $lms_active; then status_text="${status_text}✓ Server only" else status_text="${status_text}✓ Player only" fi if $lms_active; then local lms_ip=$(get_ip_address) status_text="${status_text} (Server: http://${lms_ip}:9000)" fi echo "$status_text" # Squeezelite player name if is_service_active squeezelite || is_service_enabled squeezelite; then local player_name="Unknown" if [[ -f /usr/local/bin/squeezelite-start.sh ]]; then player_name=$(grep '^PLAYER_NAME=' /usr/local/bin/squeezelite-start.sh 2>/dev/null | cut -d'=' -f2 | tr -d '"' || echo "Unknown") fi if [[ "$player_name" != "Unknown" ]]; then echo " Player: $player_name" fi fi fi # CUPS - FAST CHECK if dpkg -l 2>/dev/null | grep -q "^ii\s\+cups\s"; then any_addon=true local cups_ip=$(get_ip_address) echo "CUPS Printing: ✓ Installed (http://${cups_ip}:631)" fi # VNC - FAST CHECK if [[ -f /etc/systemd/system/x11vnc.service ]]; then any_addon=true local vnc_ip=$(get_ip_address) echo "VNC: ✓ Installed (${vnc_ip}:5900)" fi # VPNs - FAST CHECK [[ -x /usr/bin/wg ]] && { any_addon=true; echo "WireGuard: ✓ Installed"; } [[ -x /usr/bin/tailscale ]] && { any_addon=true; echo "Tailscale: ✓ Installed"; } [[ -x /usr/bin/netbird ]] && { any_addon=true; echo "Netbird: ✓ Installed"; } # Easy Asterisk Server - FAST CHECK if [[ -f "${EASY_ASTERISK_VERSION_FILE:-/opt/easy-asterisk/.version}" ]]; then any_addon=true local ea_version=$(cat "${EASY_ASTERISK_VERSION_FILE:-/opt/easy-asterisk/.version}" 2>/dev/null || echo "Unknown") if systemctl is-active asterisk &>/dev/null; then echo "Easy Asterisk Server: ✓ Running (v${ea_version})" else echo "Easy Asterisk Server: ✓ Installed (v${ea_version}) - Not running" fi fi # Easy Asterisk Client (Baresip) - FAST CHECK local client_version_file="/home/${KIOSK_USER}/.baresip/.client_version" if [[ -f "$client_version_file" ]] && command -v baresip &>/dev/null; then any_addon=true local client_ver=$(cat "$client_version_file" 2>/dev/null || echo "Unknown") echo "Easy Asterisk Client: ✓ Installed (v${client_ver})" fi if ! $any_addon; then echo "No addons installed" fi echo } show_schedule_status() { echo " ══ SCHEDULED TASKS ══" echo local any_schedule=false # FAST CHECK - just look for timer files, read them directly if [[ -f /etc/systemd/system/kiosk-shutdown.timer ]]; then any_schedule=true local ptime=$(grep "^OnCalendar=" /etc/systemd/system/kiosk-shutdown.timer 2>/dev/null | cut -d'=' -f2 | sed 's/\*-\*-\* //' | sed 's/:00$//') [[ -n "$ptime" ]] && echo "Power: Shutdown daily at $ptime" || echo "Power: Shutdown enabled" fi if [[ -f /etc/systemd/system/kiosk-display-off.timer ]]; then any_schedule=true local doff_time=$(grep "^OnCalendar=" /etc/systemd/system/kiosk-display-off.timer 2>/dev/null | cut -d'=' -f2 | sed 's/\*-\*-\* //' | sed 's/:00$//') local don_time=$(grep "^OnCalendar=" /etc/systemd/system/kiosk-display-on.timer 2>/dev/null | cut -d'=' -f2 | sed 's/\*-\*-\* //' | sed 's/:00$//') [[ -n "$doff_time" && -n "$don_time" ]] && echo "Display: Off at $doff_time, On at $don_time" || echo "Display: Enabled" fi if [[ -f /etc/systemd/system/kiosk-quiet-start.timer ]]; then any_schedule=true local qstart_time=$(grep "^OnCalendar=" /etc/systemd/system/kiosk-quiet-start.timer 2>/dev/null | cut -d'=' -f2 | sed 's/\*-\*-\* //' | sed 's/:00$//') local qend_time=$(grep "^OnCalendar=" /etc/systemd/system/kiosk-quiet-end.timer 2>/dev/null | cut -d'=' -f2 | sed 's/\*-\*-\* //' | sed 's/:00$//') [[ -n "$qstart_time" && -n "$qend_time" ]] && echo "Quiet: $qstart_time to $qend_time" || echo "Quiet: Enabled" fi if [[ -f /etc/systemd/system/kiosk-electron-reload.timer ]]; then any_schedule=true echo "Electron Reload: Enabled" fi if ! $any_schedule; then echo "No schedules configured" fi echo } ################################################################################ ### SECTION 3: CORE CONFIGURATION FUNCTIONS ################################################################################ show_current_config() { if sudo -u "$KIOSK_USER" test -f "$CONFIG_PATH" 2>/dev/null; then echo " ═══ CURRENT CONFIGURATION ═══" echo local autoswitch=$(sudo -u "$KIOSK_USER" jq -r '.autoswitch' "$CONFIG_PATH" 2>/dev/null) local swipe_mode=$(sudo -u "$KIOSK_USER" jq -r '.swipeMode' "$CONFIG_PATH" 2>/dev/null) local allow_nav=$(sudo -u "$KIOSK_USER" jq -r '.allowNavigation' "$CONFIG_PATH" 2>/dev/null) local tab_count=$(sudo -u "$KIOSK_USER" jq -r '.tabs | length' "$CONFIG_PATH" 2>/dev/null || echo "0") local home_tab=$(sudo -u "$KIOSK_USER" jq -r '.homeTabIndex // -1' "$CONFIG_PATH" 2>/dev/null) local inactivity_timeout=$(sudo -u "$KIOSK_USER" jq -r '.inactivityTimeout // 120' "$CONFIG_PATH" 2>/dev/null) echo "Auto-rotation: $autoswitch" echo "Touch control: $swipe_mode" echo "Navigation: $allow_nav" echo "Sites configured: $tab_count" if [[ "$home_tab" != "-1" ]]; then local timeout_min=$((inactivity_timeout / 60)) echo "Home URL: Site $((home_tab + 1)) (timeout: ${timeout_min} min)" else echo "Home URL: Not configured" fi if [[ "$tab_count" -gt 0 ]]; then echo echo "Sites:" local has_rotation=false for ((i=0; i 0" fi fi echo echo "Optional Features:" local pause_btn=$(sudo -u "$KIOSK_USER" jq -r '.enablePauseButton // true' "$CONFIG_PATH" 2>/dev/null) local keyboard_btn=$(sudo -u "$KIOSK_USER" jq -r '.enableKeyboardButton // true' "$CONFIG_PATH" 2>/dev/null) echo " Pause button: $pause_btn" echo " Keyboard button: $keyboard_btn" echo echo "Password Protection:" local password_enabled=$(sudo -u "$KIOSK_USER" jq -r '.enablePasswordProtection // false' "$CONFIG_PATH" 2>/dev/null) if [[ "$password_enabled" == "true" ]]; then local lockout_timeout=$(sudo -u "$KIOSK_USER" jq -r '.lockoutTimeout // 0' "$CONFIG_PATH" 2>/dev/null) local lockout_at_time=$(sudo -u "$KIOSK_USER" jq -r '.lockoutAtTime // ""' "$CONFIG_PATH" 2>/dev/null) local lockout_active_start=$(sudo -u "$KIOSK_USER" jq -r '.lockoutActiveStart // ""' "$CONFIG_PATH" 2>/dev/null) local lockout_active_end=$(sudo -u "$KIOSK_USER" jq -r '.lockoutActiveEnd // ""' "$CONFIG_PATH" 2>/dev/null) local boot_password=$(sudo -u "$KIOSK_USER" jq -r '.requirePasswordOnBoot // false' "$CONFIG_PATH" 2>/dev/null) echo " Status: Enabled" if [[ "$lockout_timeout" -gt 0 ]]; then echo " Lockout after: ${lockout_timeout} min inactivity" else echo " Lockout after: Only on display wake/boot" fi [[ -n "$lockout_at_time" ]] && echo " Lock at: $lockout_at_time daily" if [[ -n "$lockout_active_start" && -n "$lockout_active_end" ]]; then echo " Active hours: $lockout_active_start - $lockout_active_end" fi echo " Password on boot: $boot_password" else echo " Status: Disabled" fi echo else log_warning "No configuration found" echo fi } configure_timezone() { echo " ═══ TIMEZONE CONFIGURATION ═══" echo local current_tz=$(timedatectl show -p Timezone --value) echo "Current timezone: $current_tz" echo echo "Select timezone:" echo echo "Common Timezones:" echo " 1. America/New_York (US Eastern)" echo " 2. America/Chicago (US Central)" echo " 3. America/Denver (US Mountain)" echo " 4. America/Los_Angeles (US Pacific)" echo " 5. America/Phoenix (US Arizona)" echo " 6. America/Anchorage (US Alaska)" echo " 7. Pacific/Honolulu (US Hawaii)" echo " 8. Europe/London (UK)" echo " 9. Europe/Paris (Central Europe)" echo " 10. Europe/Berlin (Germany)" echo " 11. Europe/Rome (Italy)" echo " 12. Asia/Tokyo (Japan)" echo " 13. Asia/Shanghai (China)" echo " 14. Asia/Dubai (UAE)" echo " 15. Australia/Sydney (Australia East)" echo " 16. Pacific/Auckland (New Zealand)" echo " 17. Search for timezone" echo " 18. Enter timezone manually" echo " 0. Keep current ($current_tz)" echo read -r -p "Choose [0-18]: " tz_choice local new_tz="" case "$tz_choice" in 1) new_tz="America/New_York" ;; 2) new_tz="America/Chicago" ;; 3) new_tz="America/Denver" ;; 4) new_tz="America/Los_Angeles" ;; 5) new_tz="America/Phoenix" ;; 6) new_tz="America/Anchorage" ;; 7) new_tz="Pacific/Honolulu" ;; 8) new_tz="Europe/London" ;; 9) new_tz="Europe/Paris" ;; 10) new_tz="Europe/Berlin" ;; 11) new_tz="Europe/Rome" ;; 12) new_tz="Asia/Tokyo" ;; 13) new_tz="Asia/Shanghai" ;; 14) new_tz="Asia/Dubai" ;; 15) new_tz="Australia/Sydney" ;; 16) new_tz="Pacific/Auckland" ;; 17) echo echo "Available regions:" local regions=($(timedatectl list-timezones | cut -d'/' -f1 | sort -u)) for i in "${!regions[@]}"; do printf " %2d) %s\n" $((i+1)) "${regions[$i]}" done echo read -r -p "Select region number [1-${#regions[@]}]: " region_num if [[ "$region_num" =~ ^[0-9]+$ ]] && [[ "$region_num" -ge 1 ]] && [[ "$region_num" -le "${#regions[@]}" ]]; then local selected_region="${regions[$((region_num-1))]}" echo echo "Timezones in $selected_region:" local timezones=($(timedatectl list-timezones | grep "^${selected_region}/")) for i in "${!timezones[@]}"; do printf " %3d) %s\n" $((i+1)) "${timezones[$i]}" done echo read -r -p "Select timezone number [1-${#timezones[@]}]: " tz_num if [[ "$tz_num" =~ ^[0-9]+$ ]] && [[ "$tz_num" -ge 1 ]] && [[ "$tz_num" -le "${#timezones[@]}" ]]; then new_tz="${timezones[$((tz_num-1))]}" else log_error "Invalid timezone selection" return fi else log_error "Invalid region selection" return fi ;; 18) echo read -r -p "Enter timezone (e.g., America/New_York): " new_tz ;; 0|"") echo "Keeping current timezone" return ;; *) log_error "Invalid choice" return ;; esac 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 [[ -n "$new_tz" ]]; then if timedatectl list-timezones | grep -q "^${new_tz}$"; then 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 else log_error "Invalid timezone: $new_tz" fi fi } configure_touch_controls() { echo " ═══ TOUCH CONTROLS CONFIGURATION ═══" echo echo "Touch control modes:" echo echo " DUAL-DIRECTION (recommended for touchscreens):" echo " • Two-finger swipe left/right = Switch between sites" echo " • One-finger swipe left/right = Navigate within page (arrow keys)" echo " Allows both site switching AND page navigation" echo echo " STANDARD (simpler):" echo " • Two-finger swipe left/right = Switch between sites only" echo " • One-finger swipes do nothing" echo echo "NOTE: Touch controls are optional. Keyboard/mouse work without touch." echo load_config || true local current_mode="dual" if sudo -u "$KIOSK_USER" test -f "$CONFIG_PATH" 2>/dev/null; then current_mode=$(sudo -u "$KIOSK_USER" jq -r '.swipeMode' "$CONFIG_PATH" 2>/dev/null) fi echo "Current: $current_mode" read -r -p "Use dual-direction mode? (y/n): " use_dual if [[ "$use_dual" =~ ^[Nn]$ ]]; then SWIPE_MODE="standard" else SWIPE_MODE="dual" fi log_success "Touch mode: $SWIPE_MODE" } configure_navigation_security() { echo " ═══ NAVIGATION SECURITY ═══" echo echo "Controls what users can access by clicking links:" echo echo " RESTRICTED:" echo " • Only the exact URL loaded (no link clicking)" echo " • Use for locked-down kiosks" echo echo " SAME-ORIGIN (recommended):" echo " • Can click links within the same domain" echo " • Example: example.com can link to example.com/page2" echo " • Cannot go to different domains" echo echo " OPEN:" echo " • Can click any link, browse anywhere" echo " • Use only for trusted environments" echo load_config || true local current_nav="same-origin" if sudo -u "$KIOSK_USER" test -f "$CONFIG_PATH" 2>/dev/null; then current_nav=$(sudo -u "$KIOSK_USER" jq -r '.allowNavigation' "$CONFIG_PATH" 2>/dev/null) fi echo "Current: $current_nav" echo read -r -p "(r)estricted / (s)ame-origin / (o)pen [s]: " nav_choice case "${nav_choice:-s}" in [Rr]) ALLOW_NAVIGATION="restricted" ;; [Oo]) ALLOW_NAVIGATION="open" ;; *) ALLOW_NAVIGATION="same-origin" ;; esac log_success "Navigation: $ALLOW_NAVIGATION" } configure_optional_features() { echo " ═══ OPTIONAL FEATURES ═══" echo echo "Configure which optional features to enable:" echo # Load existing config if available if sudo -u "$KIOSK_USER" test -f "$CONFIG_PATH" 2>/dev/null; then local current_pause=$(sudo -u "$KIOSK_USER" jq -r '.enablePauseButton // true' "$CONFIG_PATH" 2>/dev/null) local current_keyboard=$(sudo -u "$KIOSK_USER" jq -r '.enableKeyboardButton // true' "$CONFIG_PATH" 2>/dev/null) local current_nav=$(sudo -u "$KIOSK_USER" jq -r '.enableNavButton // true' "$CONFIG_PATH" 2>/dev/null) echo "Current settings: Pause=${current_pause}, Keyboard=${current_keyboard}, Navigation=${current_nav}" fi echo echo "PAUSE BUTTON:" echo " Allows users to pause timed site rotation" if ask_yes_no "Enable pause button?" "y"; then ENABLE_PAUSE_BUTTON="true" log_success "Pause button enabled" else ENABLE_PAUSE_BUTTON="false" log_warning "Pause button disabled (functionality removed)" fi echo echo "KEYBOARD BUTTON:" echo " Shows on-screen keyboard for text input" if ask_yes_no "Enable keyboard button?" "y"; then ENABLE_KEYBOARD_BUTTON="true" log_success "Keyboard button enabled" else ENABLE_KEYBOARD_BUTTON="false" log_warning "Keyboard button disabled (functionality removed)" fi echo echo "NAVIGATION BUTTON:" echo " Shows site navigation menu and touch gesture cheat sheet" if ask_yes_no "Enable navigation button?" "y"; then ENABLE_NAV_BUTTON="true" log_success "Navigation button enabled" else ENABLE_NAV_BUTTON="false" log_warning "Navigation button disabled (functionality removed)" fi echo if ask_yes_no "Save these changes?" "y"; then return 0 else log_info "Changes discarded" return 1 fi } configure_password_protection() { echo " ═══ PASSWORD PROTECTION & SESSION LOCKOUT ═══" echo echo "Add password protection with automatic lockout:" echo " • Blank screen after inactivity period" echo " • Password required to unlock" echo " • Password required after display schedule wake-up" echo " • Optional: Lock at specific time" echo " • Optional: Only active during certain hours" echo " • Optional: Require password on system boot" echo # Load existing config if available if sudo -u "$KIOSK_USER" test -f "$CONFIG_PATH" 2>/dev/null; then local current_enabled=$(sudo -u "$KIOSK_USER" jq -r '.enablePasswordProtection // false' "$CONFIG_PATH" 2>/dev/null) local current_timeout=$(sudo -u "$KIOSK_USER" jq -r '.lockoutTimeout // 0' "$CONFIG_PATH" 2>/dev/null) if [[ "$current_enabled" == "true" ]]; then echo "Current: Enabled, lockout after ${current_timeout} minutes" else echo "Current: Disabled" fi fi echo if ask_yes_no "Enable password protection?" "n"; then ENABLE_PASSWORD_PROTECTION="true" # Ask for password echo echo "Set lockout password:" 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 # Hash the password using SHA-256 LOCKOUT_PASSWORD=$(echo -n "$pass1" | sha256sum | cut -d' ' -f1) break else echo "❌ Passwords don't match, try again" fi done # Ask for lockout timeout echo echo "Session lockout time (minutes of inactivity):" echo " Enter 0 to only require password after display schedule wake-up or boot" LOCKOUT_TIMEOUT=$(ask_integer "Lockout timeout in minutes" "30" 0 1440) # Ask for time-based lockout echo if ask_yes_no "Lock automatically at a specific time each day?" "n"; then read -r -p "Enter time to lock (HH:MM, 24-hour format, e.g., 17:00): " LOCKOUT_AT_TIME # Validate time format if [[ "$LOCKOUT_AT_TIME" =~ ^([0-1][0-9]|2[0-3]):[0-5][0-9]$ ]]; then log_success "Will lock at ${LOCKOUT_AT_TIME} daily" else echo "⚠ Invalid time format, time-based lockout disabled" LOCKOUT_AT_TIME="" fi else LOCKOUT_AT_TIME="" fi # No time-based lockout restrictions - password protection always active when enabled LOCKOUT_ACTIVE_START="" LOCKOUT_ACTIVE_END="" # Ask for boot password requirement echo if ask_yes_no "Require password on system boot/power on?" "y"; then REQUIRE_PASSWORD_ON_BOOT="true" log_success "Password will be required on boot" else REQUIRE_PASSWORD_ON_BOOT="false" log_info "Password only required after inactivity or display wake" fi log_success "Password protection enabled (lockout: ${LOCKOUT_TIMEOUT} min)" else ENABLE_PASSWORD_PROTECTION="false" LOCKOUT_PASSWORD="" LOCKOUT_TIMEOUT=0 LOCKOUT_AT_TIME="" LOCKOUT_ACTIVE_START="" LOCKOUT_ACTIVE_END="" REQUIRE_PASSWORD_ON_BOOT="false" log_info "Password protection disabled" fi echo if ask_yes_no "Save these changes?" "y"; then return 0 else log_info "Changes discarded" return 1 fi } configure_hidden_site_pin() { echo echo " ═══ HIDDEN SITE PIN ═══" echo echo "The PIN protects access to hidden sites (duration: -1)" echo "Hidden sites can be toggled via:" echo " • F10 key" echo " • 3-finger DOWN swipe (shows/hides)" echo local pin_file="$KIOSK_DIR/.jitsi-pin" local current_pin="" if sudo -u "$KIOSK_USER" test -f "$pin_file" 2>/dev/null; then current_pin=$(sudo -u "$KIOSK_USER" cat "$pin_file" 2>/dev/null) if [[ "$current_pin" == "NOPIN" ]]; then echo "Current: No PIN (hidden sites disabled)" else echo "Current: PIN is set (${#current_pin} digits)" fi else echo "Current: Not configured (default: 1234)" fi echo echo "Options:" echo " 1. Set new PIN (4-8 digits)" echo " 2. Disable PIN (allow access without PIN)" echo " 3. Reset to default (1234)" echo " 0. Cancel" echo read -r -p "Choose [0-3]: " pin_choice case "$pin_choice" in 1) echo 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 echo "$new_pin" | sudo -u "$KIOSK_USER" tee "$pin_file" > /dev/null sudo -u "$KIOSK_USER" chmod 600 "$pin_file" log_success "PIN updated" break else echo "❌ PINs don't match, try again" fi done ;; 2) echo "NOPIN" | sudo -u "$KIOSK_USER" tee "$pin_file" > /dev/null sudo -u "$KIOSK_USER" chmod 600 "$pin_file" log_success "PIN disabled - hidden sites accessible without PIN" ;; 3) echo "1234" | sudo -u "$KIOSK_USER" tee "$pin_file" > /dev/null sudo -u "$KIOSK_USER" chmod 600 "$pin_file" log_success "PIN reset to default (1234)" ;; 0) log_info "Cancelled" return ;; esac echo echo "ℹ️ Restart kiosk for changes to take effect" pause } configure_sites() { while true; do echo " ═══ SITES CONFIGURATION ═══" echo URLS=() DURS=() USERS=() PASSES=() NAMES=() if sudo -u "$KIOSK_USER" test -f "$CONFIG_PATH" 2>/dev/null; then HOME_TAB_INDEX=$(sudo -u "$KIOSK_USER" jq -r '.homeTabIndex // -1' "$CONFIG_PATH" 2>/dev/null) INACTIVITY_TIMEOUT=$(sudo -u "$KIOSK_USER" jq -r '.inactivityTimeout // 120' "$CONFIG_PATH" 2>/dev/null) local tab_count=$(sudo -u "$KIOSK_USER" jq -r '.tabs | length' "$CONFIG_PATH" 2>/dev/null || echo "0") if [[ "$tab_count" -gt 0 ]]; then echo "Current sites:" local has_rotation=false for ((i=0; i 0)" else echo "Auto-rotation: ✗ No sites configured for rotation" fi echo if [[ "$HOME_TAB_INDEX" != "-1" ]]; then local timeout_min=$((INACTIVITY_TIMEOUT / 60)) echo "Home URL: ✓ Enabled (${timeout_min} min timeout)" else echo "Home URL: ✗ Disabled" fi echo echo "Options:" echo " 1. Keep all sites as-is" echo " 2. Update durations" echo " 3. Update site names" echo " 4. Add more sites" echo " 5. Reorder sites" echo " 6. Delete a site" echo " 7. Configure Home URL" echo " 8. Clear all, start over" echo " 0. Return to menu" echo local site_choice=$(ask_menu_choice 8) case "$site_choice" in 1) log_success "Keeping existing sites" return ;; 2) update_site_durations save_config continue ;; 3) update_site_names save_config continue ;; 4) echo echo "Adding new sites:" add_new_sites_simple save_config continue ;; 5) reorder_sites save_config continue ;; 6) delete_site save_config continue ;; 7) configure_home_url save_config continue ;; 8) URLS=() DURS=() USERS=() PASSES=() NAMES=() HOME_TAB_INDEX=-1 INACTIVITY_TIMEOUT=120 add_new_sites save_config continue ;; 0) return ;; esac fi fi add_new_sites save_config return done } update_site_durations() { echo echo "Update site durations:" echo echo "ℹ Duration Guide:" echo " • 0 seconds = Manual only (won't auto-rotate)" echo " • 1-999 seconds = Auto-rotates every X seconds" echo " • -1 = Hidden tab (F10 to access)" echo local new_durs=() for idx in "${!URLS[@]}"; do echo "Site $((idx+1)): ${URLS[$idx]}" read -r -p " Duration in seconds (0=manual, >0=rotate) [${DURS[$idx]}]: " new_dur new_dur="${new_dur:-${DURS[$idx]}}" new_durs+=("$new_dur") echo done DURS=("${new_durs[@]}") log_success "Site durations updated" } update_site_names() { echo echo "Update site names (friendly labels for navigation menu):" echo echo "ℹ Name Guide:" echo " • Names help identify sites in the navigation menu" echo " • Especially useful for complex URLs or similar-looking addresses" echo " • Examples: 'Home Assistant', 'Photo Gallery', 'Security Cameras'" echo " • Leave blank to use URL as the display name" echo local new_names=() for idx in "${!URLS[@]}"; do local current_name="${NAMES[$idx]:-}" echo "Site $((idx+1)): ${URLS[$idx]}" if [[ -n "$current_name" ]]; then read -r -p " Name [${current_name}]: " new_name new_name="${new_name:-${current_name}}" else read -r -p " Name (optional): " new_name fi new_names+=("$new_name") echo done NAMES=("${new_names[@]}") log_success "Site names updated" } delete_site() { echo echo "Delete which site?" for idx in "${!URLS[@]}"; do echo " $((idx+1)). ${URLS[$idx]}" done echo local max_sites="${#URLS[@]}" local del_num=$(ask_integer "Enter number to delete (0=cancel)" "0" 0 "$max_sites") if [[ "$del_num" -ge 1 ]] && [[ "$del_num" -le "$max_sites" ]]; then local del_idx=$((del_num-1)) echo "Deleting: ${URLS[$del_idx]}" unset 'URLS[$del_idx]' unset 'DURS[$del_idx]' unset 'USERS[$del_idx]' unset 'PASSES[$del_idx]' unset 'NAMES[$del_idx]' URLS=("${URLS[@]}") DURS=("${DURS[@]}") USERS=("${USERS[@]}") PASSES=("${PASSES[@]}") NAMES=("${NAMES[@]}") # Adjust HOME_TAB_INDEX if necessary if [[ "$HOME_TAB_INDEX" == "$del_idx" ]]; then HOME_TAB_INDEX=-1 log_warning "Home URL was deleted - home feature disabled" elif [[ "$HOME_TAB_INDEX" -gt "$del_idx" ]]; then HOME_TAB_INDEX=$((HOME_TAB_INDEX - 1)) fi log_success "Site deleted" log_warning "Site numbers have changed! Review rotation settings." else echo "Cancelled" fi } reorder_sites() { if [[ "${#URLS[@]}" -lt 2 ]]; then log_warning "Need at least 2 sites to reorder" pause return fi echo echo " ═══ REORDER SITES ═══" echo echo "Current order:" for idx in "${!URLS[@]}"; do echo " $((idx+1)). ${URLS[$idx]}" done echo local max_sites="${#URLS[@]}" local from_num=$(ask_integer "Move which site? (0=cancel)" "0" 0 "$max_sites") if [[ "$from_num" == "0" ]]; then echo "Cancelled" return fi local to_num=$(ask_integer "Move to position? (1-$max_sites)" "1" 1 "$max_sites") 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 # Save the item to move local move_url="${URLS[$from_idx]}" local move_dur="${DURS[$from_idx]}" local move_user="${USERS[$from_idx]}" local move_pass="${PASSES[$from_idx]}" # Remove from old position unset 'URLS[$from_idx]' unset 'DURS[$from_idx]' unset 'USERS[$from_idx]' unset 'PASSES[$from_idx]' URLS=("${URLS[@]}") DURS=("${DURS[@]}") USERS=("${USERS[@]}") PASSES=("${PASSES[@]}") # Adjust to_idx if needed (if we removed an item before the target) if [[ "$from_idx" -lt "$to_idx" ]]; then to_idx=$((to_idx - 1)) fi # Insert at new position 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}") # Update HOME_TAB_INDEX if it was affected if [[ "$HOME_TAB_INDEX" == "$from_idx" ]]; then HOME_TAB_INDEX=$to_idx elif [[ "$from_idx" -lt "$HOME_TAB_INDEX" ]] && [[ "$HOME_TAB_INDEX" -le "$to_idx" ]]; then HOME_TAB_INDEX=$((HOME_TAB_INDEX - 1)) elif [[ "$from_idx" -gt "$HOME_TAB_INDEX" ]] && [[ "$HOME_TAB_INDEX" -ge "$to_idx" ]]; then HOME_TAB_INDEX=$((HOME_TAB_INDEX + 1)) fi echo echo "New order:" for idx in "${!URLS[@]}"; do echo " $((idx+1)). ${URLS[$idx]}" done log_success "Sites reordered" } configure_home_url() { echo echo " ═══ HOME URL CONFIGURATION ═══" echo echo "A HOME URL is where the kiosk returns after inactivity on other tabs." echo echo "How it works:" echo " • Choose one site to be the HOME tab" echo " • After X minutes on other tabs, user is prompted:" echo " \"Are you still here?\"" echo " • If no response in 10 seconds, returns to HOME tab" echo " • Good for: Main dashboard, photo slideshow, default screen" echo if sudo -u "$KIOSK_USER" test -f "$CONFIG_PATH" 2>/dev/null; then HOME_TAB_INDEX=$(sudo -u "$KIOSK_USER" jq -r '.homeTabIndex // -1' "$CONFIG_PATH" 2>/dev/null) INACTIVITY_TIMEOUT=$(sudo -u "$KIOSK_USER" jq -r '.inactivityTimeout // 120' "$CONFIG_PATH" 2>/dev/null) fi if [[ "$HOME_TAB_INDEX" != "-1" ]]; then local timeout_min=$((INACTIVITY_TIMEOUT / 60)) echo "Current: Site #$((HOME_TAB_INDEX + 1)) is HOME (${timeout_min} min timeout)" else echo "Current: Disabled" fi echo echo "Options:" echo " 1. Enable/Change Home URL" echo " 2. Disable Home URL" echo " 0. Cancel" echo read -r -p "Choose [0-2]: " home_choice case "$home_choice" in 1) echo echo "Select which site should be HOME:" for idx in "${!URLS[@]}"; do echo " $((idx+1)). ${URLS[$idx]}" done echo read -r -p "Site number [1]: " site_num site_num="${site_num:-1}" if [[ "$site_num" =~ ^[0-9]+$ ]] && [[ "$site_num" -ge 1 ]] && [[ "$site_num" -le "${#URLS[@]}" ]]; then HOME_TAB_INDEX=$((site_num - 1)) echo read -r -p "Inactivity timeout in minutes [2]: " timeout_min timeout_min="${timeout_min:-2}" INACTIVITY_TIMEOUT=$((timeout_min * 60)) log_success "Home URL: Site #${site_num} (${timeout_min} min timeout)" else log_error "Invalid site number" fi ;; 2) HOME_TAB_INDEX=-1 INACTIVITY_TIMEOUT=120 log_success "Home URL disabled" ;; 0) echo "Cancelled" ;; esac } add_new_sites() { echo " ═══ SITE ROTATION SETUP ═══" echo echo "HOW AUTO-ROTATION WORKS:" echo "────────────────────────────" echo "Each site has a DURATION:" echo echo " • Duration > 0 = Auto-rotates after X seconds" echo " • Duration = 0 = Manual only (swipe to access)" echo " • Duration = -1 = Hidden (F10 or 3-finger up + PIN)" echo echo "💡 Want NO auto-rotation? Set ALL sites to 0 seconds" echo " (You can still swipe between sites manually)" echo echo echo "EXAMPLES:" echo " Site A: 180s → Auto-rotates every 3 minutes" echo " Site B: 60s → Auto-rotates every 1 minute" echo " Site C: 0s → Manual access only" echo " Site D: -1s → Hidden behind PIN" echo pause local use_home_url=false local inactivity_minutes=2 local home_duration=180 echo " ═══ HOME URL FEATURE ═══" echo echo "A HOME URL returns the kiosk to a default screen after inactivity." echo echo "How it works:" echo " • First site = HOME" echo " • After X minutes of inactivity on OTHER sites:" echo " → Prompt: \"Are you still here?\"" echo " → No response = returns to HOME" echo echo "Good for: Photo slideshows, dashboards, screensavers" echo if ask_yes_no "Enable HOME URL feature?" "n"; then use_home_url=true read -r -p "Inactivity timeout in minutes [2]: " inactivity_minutes inactivity_minutes="${inactivity_minutes:-2}" read -r -p "Home display duration in seconds [180]: " home_duration home_duration="${home_duration:-180}" echo "✓ HOME: Returns after ${inactivity_minutes}min, displays ${home_duration}s" else echo "✓ HOME disabled" fi echo echo " ═══ ENTER SITES ═══" echo echo "Supported formats:" echo " • example.com → https://example.com" echo " • https://example.com" echo " • 192.168.1.3:8080 → http://192.168.1.3:8080" echo if $use_home_url; then echo "FIRST SITE = HOME (will display ${home_duration}s before rotating)" fi echo echo "Enter sites (blank when done):" echo local is_first=true while true; do echo "────────────────────────────" read -r -p "URL (blank=done): " raw_url [[ -z "$raw_url" ]] && break # Parse URL local url="" if [[ "$raw_url" =~ ^https?:// ]]; then url="$raw_url" elif [[ "$raw_url" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+ ]]; then url="http://${raw_url}" else url="https://${raw_url}" fi # Duration local dur="" local is_home=false if $is_first && $use_home_url; then is_home=true dur=$home_duration echo " → HOME site (${dur}s rotation)" else echo " Duration options:" echo " >0 = Auto-rotate (e.g., 180 = 3 minutes)" echo " 0 = Manual only" echo " -1 = Hidden (PIN protected)" read -r -p " Duration [180]: " dur dur="${dur:-180}" fi # Auth echo "" echo "❓ Does this website require login credentials?" echo "" echo " ONLY say yes if:" echo " • The website uses HTTP Basic Authentication" echo " • You get a browser popup asking for username/password" echo " • The website documentation says 'Basic Auth'" echo "" echo " Say NO if:" echo " • The website has a login page with forms" echo " • You don't know what Basic Auth is" echo " • The website is public (Google, YouTube, etc.)" echo "" read -r -p " Does this site use Basic Auth? (y/n): " needs_auth if [[ "$needs_auth" =~ ^[Yy]$ ]]; then echo "" echo " Enter credentials (saved in config.json)" read -r -p " Username: " auth_user read -r -s -p " Password: " auth_pass echo USERS+=("$auth_user") PASSES+=("$auth_pass") else USERS+=("") PASSES+=("") fi # Site name (optional) echo "" echo " Site name (optional - helps identify this site in navigation menu)" echo " Examples: 'Home Assistant', 'Photo Gallery', 'Security Cameras'" read -r -p " Name (or blank): " site_name NAMES+=("$site_name") URLS+=("$url") DURS+=("$dur") if $is_home; then HOME_TAB_INDEX=$((${#URLS[@]} - 1)) INACTIVITY_TIMEOUT=$((inactivity_minutes * 60)) echo " ✓ HOME configured" fi local dur_display="${dur}s" [[ "$dur" == "0" ]] && dur_display="manual" [[ "$dur" == "-1" ]] && dur_display="hidden" echo " ✓ Added: $url ($dur_display)" is_first=false done if [[ ${#URLS[@]} -eq 0 ]]; then URLS=("https://www.ubuntu.com") DURS=(180) USERS=("") PASSES=("") NAMES=("") log_success "Using default: ubuntu.com (180s)" fi echo log_success "${#URLS[@]} sites configured" # Auto-switch if any site has duration > 0 AUTOSWITCH="false" for dur in "${DURS[@]}"; do if [[ "$dur" != "0" && "$dur" != "-1" ]]; then AUTOSWITCH="true" break fi done echo if [[ "$AUTOSWITCH" == "true" ]]; then echo "✓ Auto-rotation ENABLED" echo " Sites with duration>0 will rotate" echo " Sites with duration=0 are manual-only" [[ "$use_home_url" == "true" ]] && echo " Home included in rotation (${home_duration}s)" else echo "✓ Auto-rotation DISABLED (all sites manual)" fi } add_new_sites_simple() { if sudo -u "$KIOSK_USER" test -f "$CONFIG_PATH" 2>/dev/null; then local current_autoswitch=$(sudo -u "$KIOSK_USER" jq -r '.autoswitch' "$CONFIG_PATH" 2>/dev/null) AUTOSWITCH="$current_autoswitch" fi local has_existing_timings=false local existing_timing="" for dur in "${DURS[@]}"; do if [[ "$dur" != "0" && "$dur" != "-1" ]]; then has_existing_timings=true existing_timing="$dur" break fi done echo "Enter new sites (blank URL when done):" echo echo "ℹ Duration: 0=manual only, >0=auto-rotate every X seconds" echo while true; do read -r -p "URL (blank=done): " raw_url [[ -z "$raw_url" ]] && break local url="" if [[ "$raw_url" =~ ^https?:// ]]; then url="$raw_url" elif [[ "$raw_url" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+ ]]; then url="http://${raw_url}" else url="https://${raw_url}" fi local dur="" if [[ "$AUTOSWITCH" == "true" ]]; then if $has_existing_timings; then read -r -p " Duration in seconds [$existing_timing]: " dur dur="${dur:-$existing_timing}" else read -r -p " Duration in seconds [180]: " dur dur="${dur:-180}" fi else dur=0 echo " Duration: manual (auto-rotation is disabled)" fi read -r -p " Basic auth? (y/n): " needs_auth if [[ "$needs_auth" =~ ^[Yy]$ ]]; then read -r -p " Username: " auth_user read -r -s -p " Password: " auth_pass echo USERS+=("$auth_user") PASSES+=("$auth_pass") else USERS+=("") PASSES+=("") fi read -r -p " Site name (optional): " site_name NAMES+=("$site_name") URLS+=("$url") DURS+=("$dur") done log_success "${#URLS[@]} total sites configured" } ################################################################################ ### SECTION 4: WIFI CONFIGURATION (3-method scan + better errors) ################################################################################ configure_wifi() { echo " ══ WIFI CONFIGURATION ══" echo # Verify we have necessary tools 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" pause return 1 fi local 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 USB WiFi adapter, ensure it's plugged in" pause 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 connection fails" echo fi read -r -p "Configure WiFi? (y/n): " do_wifi [[ ! "$do_wifi" =~ ^[Yy]$ ]] && 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" pause return 1 fi sleep 3 echo "Scanning for networks (this takes 5-10 seconds)..." local scan_results="" # Try nmcli first 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 # Fallback to iw if nmcli failed if [[ -z "$scan_results" ]] && command -v iw &>/dev/null; then if sudo iw dev "$wifi_iface" scan 2>/dev/null | grep -E "^BSS|SSID:" > /tmp/wifi_scan.txt; then scan_results=$(grep "SSID:" /tmp/wifi_scan.txt | sed 's/.*SSID: //' | grep -v "^$" | sort -u) rm -f /tmp/wifi_scan.txt fi fi # Fallback to wpa_cli 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 read -r -p "Enter SSID manually anyway? (y/n): " manual if [[ "$manual" =~ ^[Yy]$ ]]; 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 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 [[ -z "$ssid" ]] && { log_error "No SSID provided"; return 1; } read -r -s -p "Password for '$ssid': " password echo [[ -z "$password" ]] && { log_error "No password provided"; return 1; } local netplan_file=$(ls /etc/netplan/*.yaml 2>/dev/null | head -1) if [[ -z "$netplan_file" ]]; then netplan_file="/etc/netplan/50-cloud-init.yaml" fi if [[ -f "$netplan_file" ]]; then local backup="${netplan_file}.backup-$(date +%Y%m%d-%H%M%S)" sudo cp "$netplan_file" "$backup" log_success "Backup: $backup" fi local temp_plan="/tmp/netplan-$$.yaml" cat > "$temp_plan" < "$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 connection fails" fi sudo cp "$temp_plan" "$netplan_file" sudo chmod 0600 "$netplan_file" echo "Applying configuration..." if sudo netplan apply 2>&1 | tee /tmp/netplan-error.log; then sleep 10 local new_ip=$(get_ip_address) if [[ -n "$new_ip" && "$new_ip" != "No IP" ]]; then log_success "Connected: $ssid ($new_ip)" if [[ -n "${SSH_CONNECTION:-}" ]]; then echo "Connection successful - watchdog will not revert" fi 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 /tmp/netplan-error.log if [[ -f "$backup" ]]; then echo read -r -p "Restore backup? (y/n): " restore if [[ "$restore" =~ ^[Yy]$ ]]; then sudo cp "$backup" "$netplan_file" sudo netplan apply fi fi fi rm -f "$temp_plan" /tmp/netplan-error.log pause } ################################################################################ ### FIX 4.5: EMERGENCY HOTSPOT WITH ON-SCREEN NOTIFICATION ################################################################################ # ADD THIS NEW FUNCTION after configure_wifi() function (around line 2100) configure_emergency_hotspot() { echo echo "══ EMERGENCY HOTSPOT ══" echo echo "Creates a WiFi hotspot if no internet connection after boot." echo "Allows you to connect and reconfigure the kiosk remotely." echo local hotspot_enabled=false if [[ -f /usr/local/bin/kiosk-emergency-hotspot ]]; then hotspot_enabled=true echo "Status: ✓ Configured" local hotspot_ssid=$(grep '^HOTSPOT_SSID=' /usr/local/bin/kiosk-emergency-hotspot 2>/dev/null | cut -d'=' -f2 | tr -d '"') echo " SSID: $hotspot_ssid" echo echo "Options:" echo " 1. Keep as-is" echo " 2. Reconfigure" echo " 3. Disable" echo " 0. Return" else echo "Status: Not configured" echo echo "Options:" echo " 1. Enable emergency hotspot" echo " 0. Return" fi echo read -r -p "Choose: " choice case "$choice" in 1) if $hotspot_enabled; then echo "Keeping current configuration" pause return else install_emergency_hotspot fi ;; 2) if $hotspot_enabled; then install_emergency_hotspot fi ;; 3) if $hotspot_enabled; then disable_emergency_hotspot fi ;; 0) return ;; esac } install_emergency_hotspot() { echo echo "Installing emergency hotspot system..." # Install required packages sudo apt install -y hostapd dnsmasq iptables # Stop services for now sudo systemctl stop hostapd dnsmasq 2>/dev/null || true sudo systemctl disable hostapd dnsmasq 2>/dev/null || true # Get WiFi interface local wifi_iface=$(ls /sys/class/net 2>/dev/null | grep -E "^wl" | head -1) if [[ -z "$wifi_iface" ]]; then log_error "No WiFi interface found" pause return 1 fi echo "WiFi interface: $wifi_iface" echo # Get configuration read -r -p "Hotspot SSID [Kiosk-Emergency]: " hotspot_ssid hotspot_ssid="${hotspot_ssid:-Kiosk-Emergency}" read -r -s -p "Hotspot password (8+ chars): " hotspot_pass echo while [[ ${#hotspot_pass} -lt 8 ]]; do echo "Password must be at least 8 characters" read -r -s -p "Hotspot password: " hotspot_pass echo done local hotspot_ip="192.168.50.1" # Create emergency hotspot script sudo tee /usr/local/bin/kiosk-emergency-hotspot > /dev/null </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 < /tmp/hostapd-hotspot.conf < /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'
📡

Emergency Hotspot Active

No internet connection detected
Hotspot created for remote access
SSID: \$HOTSPOT_SSID
Password: \$HOTSPOT_PASS
Connect to: http://\$HOTSPOT_IP
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 \\ /home/\$KIOSK_USER/kiosk-app/node_modules/electron/dist/electron \\ /tmp/hotspot-notification.html & fi exit 0 EOF sudo chmod +x /usr/local/bin/kiosk-emergency-hotspot # Create systemd service sudo tee /etc/systemd/system/kiosk-emergency-hotspot.service > /dev/null <<'HOTSPOTSVC' [Unit] Description=Kiosk Emergency Hotspot After=network.target lightdm.service Wants=network.target [Service] Type=oneshot ExecStart=/usr/local/bin/kiosk-emergency-hotspot RemainAfterExit=yes StandardOutput=journal StandardError=journal [Install] WantedBy=multi-user.target HOTSPOTSVC # Enable service sudo systemctl daemon-reload sudo systemctl enable kiosk-emergency-hotspot.service echo log_success "Emergency hotspot configured" echo " SSID: $hotspot_ssid" echo " Password: $hotspot_pass" echo " IP: $hotspot_ip" echo echo "Hotspot will auto-start if no internet after 60 seconds of boot" echo "On-screen notification will show connection details" pause } disable_emergency_hotspot() { echo read -r -p "Disable emergency hotspot? (y/n): " confirm if [[ "$confirm" =~ ^[Yy]$ ]]; then 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 /etc/systemd/system/kiosk-emergency-hotspot.service sudo rm -f /usr/local/bin/kiosk-emergency-hotspot sudo systemctl daemon-reload log_success "Emergency hotspot disabled" fi 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 (both getty service AND X11 VT switching) local consoles_disabled=false local getty_masked=false local vt_switch_disabled=false # Check getty service if systemctl is-masked getty@tty1.service >/dev/null 2>&1; then getty_masked=true fi # Check X11 DontVTSwitch setting if [[ -f /etc/X11/xorg.conf.d/10-serverflags.conf ]]; then if grep -q 'Option.*"DontVTSwitch".*"true"' /etc/X11/xorg.conf.d/10-serverflags.conf; then vt_switch_disabled=true fi fi # Consoles are only truly enabled if BOTH conditions are met if [[ "$getty_masked" == "true" ]] || [[ "$vt_switch_disabled" == "true" ]]; 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..." # Enable getty services for i in {1..8}; do sudo systemctl unmask getty@tty$i.service 2>/dev/null || true done sudo systemctl daemon-reload # Enable VT switching in X11 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 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)" echo echo "⚠️ Changes will take effect after restarting display:" echo " sudo systemctl restart lightdm" pause ;; 2) echo read -r -p "Disable all virtual consoles? (y/n): " confirm if [[ "$confirm" =~ ^[Yy]$ ]]; then echo "Disabling virtual consoles..." # Disable getty services for i in {1..8}; do sudo systemctl mask getty@tty$i.service 2>/dev/null || true done sudo systemctl daemon-reload # Disable VT switching in X11 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 echo "Virtual console access has been disabled for security." echo "You can re-enable them from the Advanced menu if needed." echo echo "⚠️ Changes will take effect after restarting display:" echo " sudo systemctl restart lightdm" pause fi ;; 0) return ;; esac } ################################################################################ ### SECTION 5: POWER/DISPLAY/QUIET SCHEDULES ################################################################################ configure_power_display_quiet() { while true; do clear echo "════════════════════════════════════════════════════════════" echo " POWER / DISPLAY / QUIET HOURS " echo "════════════════════════════════════════════════════════════" echo show_schedule_status local rtc_available=false local rtc_status="Not available" if [[ -w /sys/class/rtc/rtc0/wakealarm ]] || sudo test -w /sys/class/rtc/rtc0/wakealarm 2>/dev/null; then rtc_available=true rtc_status="✓ Available and enabled" elif [[ -e /sys/class/rtc/rtc0/wakealarm ]]; then rtc_status="⚠ Available but not writable" fi echo " ═══ RTC Wake Capability ═══" echo "Status: $rtc_status" echo if $rtc_available; then echo "Can schedule: Power on/off + Display on/off" else echo "Can schedule: Display on/off only" if grep -qi "raspberry" /proc/cpuinfo 2>/dev/null; then echo " Raspberry Pi: Requires DS3231/DS1307 RTC module" fi fi echo echo "Options:" echo " 1. Configure power schedule" $rtc_available || echo " (Not available)" echo " 2. Configure display schedule" echo " 3. Configure quiet hours" echo " 4. Configure Electron reload" echo " 5. Remove all schedules" echo " 6. Test schedules & system" echo " 0. Return" echo read -r -p "Choose [0-6]: " choice case "$choice" in 1) if $rtc_available; then configure_power_schedule else log_warning "RTC not available" pause fi ;; 2) configure_display_schedule ;; 3) configure_quiet_hours ;; 4) configure_electron_reload ;; 5) remove_all_schedules ;; 6) show_testing_menu ;; 0) return ;; esac done } #!/bin/bash ################################################################################ ### COMPLETE SCHEDULE FUNCTIONS - DROP-IN REPLACEMENT ### Insert these functions around line 3300 in install_kiosk_v 09.9.1.sh ################################################################################ # CONTEXT: These functions are called from configure_power_display_quiet() # They replace the existing schedule configuration functions ################################################################################ ### POWER SCHEDULE FUNCTION (COMPLETE) ################################################################################ configure_power_schedule() { echo echo " ══ POWER SCHEDULING ══" echo # Check if RTC is available local rtc_available=false if [[ -w /sys/class/rtc/rtc0/wakealarm ]] || sudo test -w /sys/class/rtc/rtc0/wakealarm 2>/dev/null; then rtc_available=true echo "✓ RTC wake capability detected" else echo "⚠ RTC wake not available (shutdown only, no auto-wake)" fi echo read -r -p "Shutdown time (HH:MM) [22:00]: " shutdown_time shutdown_time="${shutdown_time:-22:00}" if $rtc_available; then read -r -p "Wake time (HH:MM) [06:00]: " wake_time wake_time="${wake_time:-06:00}" else wake_time="" fi # Remove any existing power schedule files sudo systemctl stop kiosk-shutdown.timer 2>/dev/null || true sudo systemctl disable kiosk-shutdown.timer 2>/dev/null || true sudo rm -f /etc/systemd/system/kiosk-shutdown.{service,timer} sudo rm -f /usr/local/bin/kiosk-power-off.sh sudo rm -f /usr/local/bin/rtc-wake.sh sudo rm -f /etc/cron.d/kiosk-rtc-wake # Create shutdown script sudo tee /usr/local/bin/kiosk-power-off.sh > /dev/null <<'EOF' #!/bin/bash logger "KIOSK: Scheduled shutdown initiated" systemctl poweroff EOF sudo chmod +x /usr/local/bin/kiosk-power-off.sh # Create shutdown service sudo tee /etc/systemd/system/kiosk-shutdown.service > /dev/null <<'EOF' [Unit] Description=Kiosk Scheduled Shutdown [Service] Type=oneshot ExecStart=/usr/local/bin/kiosk-power-off.sh EOF # Create shutdown timer sudo tee /etc/systemd/system/kiosk-shutdown.timer > /dev/null < /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 /usr/local/bin/rtc-wake.sh # Create cron job to set RTC wake 5 minutes before shutdown local shutdown_hour="${shutdown_time%%:*}" local shutdown_min="${shutdown_time##*:}" local wake_min=$((10#$shutdown_min - 5)) local wake_hour=$((10#$shutdown_hour)) # Handle negative minutes if [[ $wake_min -lt 0 ]]; then wake_min=$((wake_min + 60)) wake_hour=$((wake_hour - 1)) fi # Handle negative hour (before midnight) if [[ $wake_hour -lt 0 ]]; then wake_hour=$((wake_hour + 24)) fi sudo tee /etc/cron.d/kiosk-rtc-wake > /dev/null <> /var/log/kiosk-rtc.log 2>&1 EOF log_info "RTC wake cron job created" fi # Reload systemd and enable timer sudo systemctl daemon-reload sudo systemctl enable kiosk-shutdown.timer sudo systemctl start kiosk-shutdown.timer echo log_success "Power schedule configured" echo " Shutdown: $shutdown_time daily" if $rtc_available && [[ -n "$wake_time" ]]; then echo " Wake: $wake_time daily" fi # Show next activation echo echo "Next scheduled shutdown:" systemctl list-timers kiosk-shutdown.timer --no-pager | grep kiosk-shutdown || echo " (checking...)" # Test RTC wake if configured if $rtc_available && [[ -n "$wake_time" ]]; then echo read -r -p "Test RTC wake setup now? (y/n): " test_rtc if [[ "$test_rtc" =~ ^[Yy]$ ]]; then echo "Testing RTC wake for $wake_time..." sudo /usr/local/bin/rtc-wake.sh "$wake_time" echo "Check: cat /sys/class/rtc/rtc0/wakealarm" cat /sys/class/rtc/rtc0/wakealarm 2>/dev/null && echo "✓ RTC wake is set" || echo "✗ RTC wake failed" fi fi pause } ################################################################################ ### DISPLAY SCHEDULE FUNCTION (COMPLETE) ################################################################################ configure_display_schedule() { echo echo " ══ DISPLAY SCHEDULING ══" echo # Check if power schedule exists if systemctl is-enabled kiosk-shutdown.timer &>/dev/null 2>&1; then local ptime=$(systemctl cat kiosk-shutdown.timer 2>/dev/null | grep "^OnCalendar=" | cut -d'=' -f2 | sed 's/\*-\*-\* //' | sed 's/:00$//') echo "⚠ Power shutdown configured at $ptime" echo " Display will already be off when system shuts down" echo fi read -r -p "Display OFF time (HH:MM) [22:00]: " doff doff="${doff:-22:00}" read -r -p "Display ON time (HH:MM) [06:00]: " don don="${don:-06:00}" # Get kiosk user ID for DBUS local kiosk_uid=$(id -u "$KIOSK_USER") # Remove any existing display schedule files sudo systemctl stop kiosk-display-{on,off}.timer 2>/dev/null || true sudo systemctl disable kiosk-display-{on,off}.timer 2>/dev/null || true sudo rm -f /etc/systemd/system/kiosk-display-{on,off}.{service,timer} sudo rm -f /usr/local/bin/kiosk-display-{on,off}.sh # Create display-off script with multiple methods sudo tee /usr/local/bin/kiosk-display-off.sh > /dev/null </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 /usr/local/bin/kiosk-display-off.sh # Create display-on script with multiple methods sudo tee /usr/local/bin/kiosk-display-on.sh > /dev/null </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 DISPLAY=:0 XAUTHORITY=/home/kiosk/.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 touch /home/kiosk/kiosk-app/.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 /usr/local/bin/kiosk-display-on.sh # Create systemd services sudo tee /etc/systemd/system/kiosk-display-off.service > /dev/null <<'EOF' [Unit] Description=Kiosk Display Off [Service] Type=oneshot ExecStart=/usr/local/bin/kiosk-display-off.sh StandardOutput=journal StandardError=journal EOF sudo tee /etc/systemd/system/kiosk-display-on.service > /dev/null <<'EOF' [Unit] Description=Kiosk Display On [Service] Type=oneshot ExecStart=/usr/local/bin/kiosk-display-on.sh StandardOutput=journal StandardError=journal EOF # Create timers sudo tee /etc/systemd/system/kiosk-display-off.timer > /dev/null < /dev/null </dev/null 2>&1; then local ptime=$(systemctl cat kiosk-shutdown.timer 2>/dev/null | grep "^OnCalendar=" | cut -d'=' -f2 | sed 's/\*-\*-\* //' | sed 's/:00$//') echo "ℹ Power shutdown: $ptime" fi if systemctl is-enabled kiosk-display-off.timer &>/dev/null 2>&1; then local doff=$(systemctl cat kiosk-display-off.timer 2>/dev/null | grep "^OnCalendar=" | cut -d'=' -f2 | sed 's/\*-\*-\* //' | sed 's/:00$//') local don=$(systemctl cat kiosk-display-on.timer 2>/dev/null | grep "^OnCalendar=" | cut -d'=' -f2 | sed 's/\*-\*-\* //' | sed 's/:00$//') echo "ℹ Display: OFF at $doff, ON at $don" fi echo read -r -p "Quiet hours start (HH:MM) [22:00]: " qstart qstart="${qstart:-22:00}" read -r -p "Quiet hours end (HH:MM) [07:00]: " qend qend="${qend:-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}" # Remove any existing quiet hours files sudo systemctl stop kiosk-quiet-{start,end}.timer 2>/dev/null || true sudo systemctl disable kiosk-quiet-{start,end}.timer 2>/dev/null || true sudo rm -f /etc/systemd/system/kiosk-quiet-{start,end}.{service,timer} sudo rm -f /usr/local/bin/kiosk-quiet-{start,end}.sh # Create quiet start/end scripts based on mode case "$qmode" in 1) # Mute all audio sudo tee /usr/local/bin/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 /usr/local/bin/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 ;; 2) # Stop Squeezelite only sudo tee /usr/local/bin/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 /usr/local/bin/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 ;; esac sudo chmod +x /usr/local/bin/kiosk-quiet-{start,end}.sh # Create systemd services sudo tee /etc/systemd/system/kiosk-quiet-start.service > /dev/null <<'EOF' [Unit] Description=Kiosk Quiet Hours Start [Service] Type=oneshot ExecStart=/usr/local/bin/kiosk-quiet-start.sh StandardOutput=journal StandardError=journal EOF sudo tee /etc/systemd/system/kiosk-quiet-end.service > /dev/null <<'EOF' [Unit] Description=Kiosk Quiet Hours End [Service] Type=oneshot ExecStart=/usr/local/bin/kiosk-quiet-end.sh StandardOutput=journal StandardError=journal EOF # Create timers sudo tee /etc/systemd/system/kiosk-quiet-start.timer > /dev/null < /dev/null </dev/null 2>&1; then local reload_cal=$(systemctl cat kiosk-electron-reload.timer 2>/dev/null | grep "^OnCalendar=" | cut -d'=' -f2) local reload_time=$(echo "$reload_cal" | sed 's/\*-\*-\* //' | sed 's/:00$//') echo "Status: ✓ Enabled" echo "Schedule: $reload_cal" echo echo "Options:" echo " 1. Keep current schedule" echo " 2. Change schedule" echo " 3. Disable automatic reload" echo " 0. Return" else echo "Status: ✗ Not configured" echo echo "Options:" echo " 1. Daily at 3am" echo " 2. Every 3 days at 3am" echo " 3. Custom schedule" echo " 0. Return" fi echo read -r -p "Choose [0-3]: " choice case "$choice" in 0) return ;; 1) if systemctl is-enabled kiosk-electron-reload.timer &>/dev/null 2>&1; then echo "Keeping current schedule" pause return else setup_electron_reload_timer "*-*-* 03:00:00" "Daily at 3am" fi ;; 2) if systemctl is-enabled kiosk-electron-reload.timer &>/dev/null 2>&1; then custom_electron_reload_schedule else setup_electron_reload_timer "*-*-1,4,7,10,13,16,19,22,25,28,31 03:00:00" "Every 3 days at 3am" fi ;; 3) if systemctl is-enabled kiosk-electron-reload.timer &>/dev/null 2>&1; then disable_electron_reload_timer else custom_electron_reload_schedule fi ;; esac pause } setup_electron_reload_timer() { local schedule="$1" local description="$2" # Remove existing files 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 /etc/systemd/system/kiosk-electron-reload.{service,timer} sudo rm -f /usr/local/bin/kiosk-reload-electron # Create reload script sudo tee /usr/local/bin/kiosk-reload-electron > /dev/null <<'RELOADSCRIPT' #!/bin/bash logger "KIOSK: Scheduled Electron reload" systemctl restart lightdm RELOADSCRIPT sudo chmod +x /usr/local/bin/kiosk-reload-electron # Create service sudo tee /etc/systemd/system/kiosk-electron-reload.service > /dev/null <<'RELOADSVC' [Unit] Description=Reload Electron App [Service] Type=oneshot ExecStart=/usr/local/bin/kiosk-reload-electron RELOADSVC # Create timer sudo tee /etc/systemd/system/kiosk-electron-reload.timer > /dev/null </dev/null || true sudo systemctl disable kiosk-electron-reload.timer 2>/dev/null || true sudo rm -f /etc/systemd/system/kiosk-electron-reload.{service,timer} sudo rm -f /usr/local/bin/kiosk-reload-electron sudo systemctl daemon-reload log_success "Automatic Electron reload disabled" fi } ################################################################################ ### END OF SCHEDULE FUNCTIONS ################################################################################ # CONTEXT: These functions are called from the menu system # They should be inserted BEFORE the configure_power_display_quiet() function # and AFTER the show_schedule_status() function test_display_control() { echo echo " ═══ TEST DISPLAY CONTROL ═══" echo echo "This will test turning the display off and on." echo read -r -p "Test now? (y/n): " do_test [[ ! "$do_test" =~ ^[Yy]$ ]] && return echo echo "Testing display OFF in 3 seconds..." sleep 3 if [[ -f /usr/local/bin/kiosk-display-off.sh ]]; then sudo /usr/local/bin/kiosk-display-off.sh echo "Display should be OFF" else # Fallback if script doesn't exist yet local kiosk_uid=$(id -u "$KIOSK_USER") sudo -u "$KIOSK_USER" DISPLAY=:0 DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/$kiosk_uid/bus xset dpms force off echo "Display turned OFF (xset method)" fi echo echo "Waiting 5 seconds..." sleep 5 echo "Testing display ON..." if [[ -f /usr/local/bin/kiosk-display-on.sh ]]; then sudo /usr/local/bin/kiosk-display-on.sh echo "Display should be ON" else # Fallback local kiosk_uid=$(id -u "$KIOSK_USER") sudo -u "$KIOSK_USER" DISPLAY=:0 DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/$kiosk_uid/bus xset dpms force on echo "Display turned ON (xset method)" fi echo if systemctl is-enabled kiosk-display-off.timer &>/dev/null 2>&1; then echo "✓ Display timers are configured" echo echo "Schedule status:" systemctl list-timers kiosk-display-* --all --no-pager 2>/dev/null else echo "⚠ Display timers not configured yet" echo " Use option 2 to configure them" fi pause } remove_all_schedules() { echo echo "Removing all schedules..." # Stop timers sudo systemctl stop kiosk-shutdown.timer 2>/dev/null || true sudo systemctl stop kiosk-display-off.timer 2>/dev/null || true sudo systemctl stop kiosk-display-on.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 # Disable timers sudo systemctl disable kiosk-shutdown.timer 2>/dev/null || true sudo systemctl disable kiosk-display-off.timer 2>/dev/null || true sudo systemctl disable kiosk-display-on.timer 2>/dev/null || true sudo systemctl disable kiosk-quiet-start.timer 2>/dev/null || true sudo systemctl disable kiosk-quiet-end.timer 2>/dev/null || true sudo systemctl disable kiosk-electron-reload.timer 2>/dev/null || true # Remove files sudo rm -f /etc/systemd/system/kiosk-shutdown.{service,timer} sudo rm -f /etc/systemd/system/kiosk-display-*.{service,timer} sudo rm -f /etc/systemd/system/kiosk-quiet-*.{service,timer} sudo rm -f /etc/systemd/system/kiosk-electron-reload.{service,timer} sudo rm -f /usr/local/bin/kiosk-power-off.sh sudo rm -f /usr/local/bin/kiosk-display-off.sh sudo rm -f /usr/local/bin/kiosk-display-on.sh sudo rm -f /usr/local/bin/kiosk-quiet-*.sh sudo rm -f /usr/local/bin/rtc-wake.sh sudo rm -f /usr/local/bin/kiosk-reload-electron sudo rm -f /etc/cron.d/kiosk-rtc-wake sudo systemctl daemon-reload log_success "All schedules removed" pause } ############################################################################### ### testing submenu ############################################################################### show_testing_menu() { while true; do clear echo "════════════════════════════════════════════════════════════" echo " SCHEDULE & SYSTEM TESTING " echo "════════════════════════════════════════════════════════════" echo echo "Available Tests:" echo " 1. Display Control (on/off test)" echo " 2. Quiet Hours (mute/unmute test)" echo " 3. Power Schedule (show next shutdown time)" echo " 4. Audio System Test" echo " 5. Network Test" echo " 6. Keyboard Test" echo " 7. Run All Tests" echo " 0. Return" echo read -r -p "Choose [0-7]: " choice case "$choice" in 1) test_display_control ;; 2) test_quiet_hours ;; 3) test_power_schedule ;; 4) audio_test ;; 5) network_test ;; 6) test_keyboard ;; 7) run_all_tests ;; 0) return ;; esac done } test_quiet_hours() { echo echo " ═══ QUIET HOURS TEST ═══" echo if ! systemctl is-enabled kiosk-quiet-start.timer &>/dev/null; then echo "❌ Quiet hours not configured" pause return fi echo "Testing quiet hours mute/unmute..." echo echo "1. Getting current volume..." local current_vol=$(pactl get-sink-volume @DEFAULT_SINK@ | grep -oE '[0-9]+%' | head -1 | tr -d '%') echo " Current: ${current_vol}%" echo echo "2. Testing MUTE (quiet start)..." sudo /usr/local/bin/kiosk-quiet-start.sh sleep 2 local muted=$(pactl get-sink-mute @DEFAULT_SINK@ | grep -q "yes" && echo "✓ MUTED" || echo "✗ NOT MUTED") echo " Result: $muted" echo echo "3. Testing UNMUTE (quiet end)..." sudo /usr/local/bin/kiosk-quiet-end.sh sleep 2 local unmuted=$(pactl get-sink-mute @DEFAULT_SINK@ | grep -q "no" && echo "✓ UNMUTED" || echo "✗ STILL MUTED") echo " Result: $unmuted" echo echo "✓ Quiet hours test complete" echo systemctl list-timers kiosk-quiet-* --all --no-pager pause } test_power_schedule() { echo echo " ═══ POWER SCHEDULE TEST ═══" echo if ! systemctl is-enabled kiosk-shutdown.timer &>/dev/null; then echo "❌ Power schedule not configured" pause return fi echo "Power schedule status:" echo systemctl list-timers kiosk-shutdown.timer --all --no-pager echo echo "⚠️ Note: Cannot test actual shutdown without shutting down!" echo " To manually test: sudo systemctl start kiosk-shutdown.service" echo if [[ -f /usr/local/bin/rtc-wake.sh ]]; then echo "RTC wake script exists ✓" local wake_config=$(grep -h "rtc-wake" /etc/cron.d/kiosk-rtc-wake 2>/dev/null) if [[ -n "$wake_config" ]]; then echo "Wake schedule: $wake_config" fi else echo "No RTC wake configured" fi pause } test_keyboard() { echo echo " ═══ KEYBOARD TEST ═══" echo echo "Testing keyboard visibility and function..." echo echo "Please perform these tests on the kiosk display:" echo echo " 1. 2-finger swipe DOWN → keyboard should appear" echo " 2. Type some characters" echo " 3. 2-finger swipe DOWN again → keyboard should close" echo " 4. Tap a text field → keyboard should auto-appear" echo " 5. Click keyboard X button → keyboard should close" echo echo "Check electron log for keyboard events:" echo " sudo tail -f /home/kiosk/electron.log | grep -i keyboard" echo pause } run_all_tests() { echo echo " ═══ RUNNING ALL TESTS ═══" echo echo "Test 1/5: Display Control" test_display_control echo echo "Test 2/5: Quiet Hours" test_quiet_hours echo echo "Test 3/5: Power Schedule" test_power_schedule echo echo "Test 4/5: Audio" audio_test echo echo "Test 5/5: Network" network_test echo echo "✓ All tests complete!" pause } ################################################################################ ### SECTION 6: CONFIG SAVE/LOAD ################################################################################ load_config() { if ! sudo -u "$KIOSK_USER" test -f "$CONFIG_PATH" 2>/dev/null; then return 1 fi local loaded_autoswitch=$(sudo -u "$KIOSK_USER" jq -r '.autoswitch' "$CONFIG_PATH" 2>/dev/null) AUTOSWITCH="$loaded_autoswitch" local loaded_swipe=$(sudo -u "$KIOSK_USER" jq -r '.swipeMode' "$CONFIG_PATH" 2>/dev/null) SWIPE_MODE="$loaded_swipe" local loaded_nav=$(sudo -u "$KIOSK_USER" jq -r '.allowNavigation' "$CONFIG_PATH" 2>/dev/null) ALLOW_NAVIGATION="$loaded_nav" HOME_TAB_INDEX=$(sudo -u "$KIOSK_USER" jq -r '.homeTabIndex // -1' "$CONFIG_PATH" 2>/dev/null) INACTIVITY_TIMEOUT=$(sudo -u "$KIOSK_USER" jq -r '.inactivityTimeout // 120' "$CONFIG_PATH" 2>/dev/null) # Load new optional features local loaded_pause=$(sudo -u "$KIOSK_USER" jq -r '.enablePauseButton // true' "$CONFIG_PATH" 2>/dev/null) [[ "$loaded_pause" == "true" ]] && ENABLE_PAUSE_BUTTON="true" || ENABLE_PAUSE_BUTTON="false" local loaded_keyboard=$(sudo -u "$KIOSK_USER" jq -r '.enableKeyboardButton // true' "$CONFIG_PATH" 2>/dev/null) [[ "$loaded_keyboard" == "true" ]] && ENABLE_KEYBOARD_BUTTON="true" || ENABLE_KEYBOARD_BUTTON="false" local loaded_password=$(sudo -u "$KIOSK_USER" jq -r '.enablePasswordProtection // false' "$CONFIG_PATH" 2>/dev/null) [[ "$loaded_password" == "true" ]] && ENABLE_PASSWORD_PROTECTION="true" || ENABLE_PASSWORD_PROTECTION="false" LOCKOUT_PASSWORD=$(sudo -u "$KIOSK_USER" jq -r '.lockoutPassword // ""' "$CONFIG_PATH" 2>/dev/null) LOCKOUT_TIMEOUT=$(sudo -u "$KIOSK_USER" jq -r '.lockoutTimeout // 0' "$CONFIG_PATH" 2>/dev/null) LOCKOUT_AT_TIME=$(sudo -u "$KIOSK_USER" jq -r '.lockoutAtTime // ""' "$CONFIG_PATH" 2>/dev/null) LOCKOUT_ACTIVE_START=$(sudo -u "$KIOSK_USER" jq -r '.lockoutActiveStart // ""' "$CONFIG_PATH" 2>/dev/null) LOCKOUT_ACTIVE_END=$(sudo -u "$KIOSK_USER" jq -r '.lockoutActiveEnd // ""' "$CONFIG_PATH" 2>/dev/null) local loaded_boot_password=$(sudo -u "$KIOSK_USER" jq -r '.requirePasswordOnBoot // false' "$CONFIG_PATH" 2>/dev/null) [[ "$loaded_boot_password" == "true" ]] && REQUIRE_PASSWORD_ON_BOOT="true" || REQUIRE_PASSWORD_ON_BOOT="false" local tab_count=$(sudo -u "$KIOSK_USER" jq -r '.tabs | length' "$CONFIG_PATH" 2>/dev/null || echo "0") URLS=() DURS=() USERS=() PASSES=() for ((i=0; i 0 local auto_json="true" 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 # it from nothing (fixed in v2.7.0). The old `jq -n` rebuild silently # deleted any field this function doesn't explicitly know about - # notably 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 this # function) silently deleted the Authelia credentials. See the # RELEASE v2.7.0 note above. 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 \ --arg unit "s" \ --argjson autoswitch "$auto_json" \ --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" \ '. + {unit:$unit,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,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]:-}" # NOTE: No autoRotate field - duration controls rotation 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 echo "Configuration saved. Changes take effect after reload." read -r -p "Reload kiosk now? (y/n): " do_reload if [[ ! "$do_reload" =~ ^[Nn]$ ]]; 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 } load_existing_config() { # Load all existing configuration from config.json into bash variables # This MUST be called before any menu that calls save_config # Otherwise settings will be lost! if ! sudo -u "$KIOSK_USER" test -f "$CONFIG_PATH" 2>/dev/null; then # No existing config, use defaults return 0 fi # Load sites/tabs URLS=() DURS=() USERS=() PASSES=() NAMES=() local 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/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 // "horizontal"' "$CONFIG_PATH" 2>/dev/null || echo "horizontal") # Load optional features local 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" local 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" local 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" # Load password protection settings local 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 "") local 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" } ################################################################################ ### SECTION 7: INSTALL/UNINSTALL SYSTEM FUNCTIONS ################################################################################ full_reinstall() { echo "" echo "══════════════════════════════════════════════════════════════" echo " FULL REINSTALL - NUCLEAR OPTION" echo "══════════════════════════════════════════════════════════════" echo "" echo "⚠️ This will COMPLETELY WIPE:" 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 " • All other addons" echo "" echo "Then reinstall everything from scratch." echo "" read -p "Are you ABSOLUTELY SURE? (type YES): " CONFIRM if [ "$CONFIRM" != "YES" ]; then echo "Cancelled." return fi # ONLY ask about VPN/VNC echo "" echo "Keep VPN/VNC settings?" read -p "(y/n): " KEEP_VPN echo "" echo "Beginning nuclear reinstall..." # Stop everything echo "[1/9] Stopping all services..." sudo systemctl stop lightdm 2>/dev/null || true sudo systemctl stop squeezelite 2>/dev/null || true sudo systemctl stop lyrionmusicserver 2>/dev/null || true sudo systemctl stop logitechmediaserver 2>/dev/null || true # Backup ONLY VPN/VNC if requested VPN_BACKUP="" if [[ "$KEEP_VPN" =~ ^[Yy]$ ]]; then echo "[2/9] Backing up VPN/VNC..." VPN_BACKUP="/tmp/kiosk-vpn-backup-$(date +%s)" mkdir -p "$VPN_BACKUP" [ -d "/etc/openvpn" ] && sudo cp -r /etc/openvpn "$VPN_BACKUP/" 2>/dev/null || true if [ -f "/home/$KIOSK_USER/.vnc/passwd" ]; then sudo -u "$KIOSK_USER" mkdir -p "$VPN_BACKUP/vnc" sudo -u "$KIOSK_USER" cp /home/$KIOSK_USER/.vnc/passwd "$VPN_BACKUP/vnc/" 2>/dev/null || true fi echo "✓ VPN/VNC backed up" else echo "[2/9] Nuking VPN/VNC too" fi # NUCLEAR WIPE echo "[3/9] Wiping kiosk files..." sudo rm -rf "$KIOSK_DIR" sudo rm -f /home/$KIOSK_USER/.xsession sudo rm -f /home/$KIOSK_USER/electron.log sudo rm -rf /home/$KIOSK_USER/.cache sudo rm -rf /home/$KIOSK_USER/.config/Electron sudo rm -rf /home/$KIOSK_USER/.config/chromium echo "[4/9] 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 echo "[5/9] Removing Squeezelite and LMS..." sudo systemctl stop squeezelite 2>/dev/null || true sudo systemctl disable squeezelite 2>/dev/null || true sudo rm -f /etc/systemd/system/squeezelite.service sudo systemctl stop lyrionmusicserver 2>/dev/null || true sudo systemctl stop logitechmediaserver 2>/dev/null || true sudo systemctl disable lyrionmusicserver 2>/dev/null || true sudo systemctl disable logitechmediaserver 2>/dev/null || true sudo apt-get purge -y lyrionmusicserver logitechmediaserver squeezelite 2>/dev/null || true echo "[6/9] 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 if [[ ! "$KEEP_VPN" =~ ^[Yy]$ ]]; then echo "[7/9] Removing VPN/VNC..." sudo systemctl stop x11vnc openvpn* 2>/dev/null || true sudo systemctl disable x11vnc openvpn* 2>/dev/null || true sudo apt-get purge -y x11vnc openvpn 2>/dev/null || true sudo rm -rf /home/$KIOSK_USER/.vnc sudo rm -rf /etc/openvpn sudo rm -f /etc/systemd/system/x11vnc.service else echo "[7/9] Preserving VPN/VNC" fi echo "[8/9] System cleanup..." sudo systemctl daemon-reload sudo apt-get autoremove -y 2>/dev/null || true sudo apt-get autoclean 2>/dev/null || true echo "" echo "✓✓✓ EVERYTHING WIPED ✓✓✓" echo "" # Fresh install echo "[9/9] Fresh installation starting..." first_time_install # Restore ONLY VPN/VNC if backed up if [ -n "$VPN_BACKUP" ] && [ -d "$VPN_BACKUP" ]; then echo "" echo "Restoring VPN/VNC..." [ -d "$VPN_BACKUP/openvpn" ] && sudo cp -r "$VPN_BACKUP/openvpn" /etc/ 2>/dev/null || true if [ -f "$VPN_BACKUP/vnc/passwd" ]; then sudo -u "$KIOSK_USER" mkdir -p /home/$KIOSK_USER/.vnc sudo -u "$KIOSK_USER" cp "$VPN_BACKUP/vnc/passwd" /home/$KIOSK_USER/.vnc/ 2>/dev/null || true fi rm -rf "$VPN_BACKUP" echo "✓ VPN/VNC restored" fi echo "" log_success "Nuclear reinstall complete! System is FRESH." 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 " • Squeezelite and LMS (Lyrion Music Server)" 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/13] 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 sudo systemctl stop squeezelite 2>/dev/null || true sudo systemctl stop lyrionmusicserver 2>/dev/null || true sudo systemctl stop logitechmediaserver 2>/dev/null || true # Remove kiosk user echo "[2/13] 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/13] Removing kiosk files..." sudo rm -rf "$KIOSK_DIR" sudo rm -rf /home/$KIOSK_USER # Remove systemd services and timers echo "[4/13] 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/13] Removing scripts..." sudo rm -f /usr/local/bin/kiosk-* sudo rm -f /usr/local/bin/rtc-wake.sh sudo rm -f /etc/udev/rules.d/99-kiosk-hotplug.rules sudo udevadm control --reload-rules 2>/dev/null || true # Remove CUPS echo "[6/13] 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/13] 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/13] 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/13] 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 Squeezelite and LMS echo "[10/13] Removing Squeezelite and LMS..." sudo systemctl stop squeezelite 2>/dev/null || true sudo systemctl disable squeezelite 2>/dev/null || true sudo rm -f /etc/systemd/system/squeezelite.service sudo systemctl stop lyrionmusicserver 2>/dev/null || true sudo systemctl stop logitechmediaserver 2>/dev/null || true sudo systemctl disable lyrionmusicserver 2>/dev/null || true sudo systemctl disable logitechmediaserver 2>/dev/null || true sudo apt-get purge -y lyrionmusicserver logitechmediaserver squeezelite 2>/dev/null || true sudo systemctl daemon-reload # Remove polkit rules echo "[11/13] Removing polkit rules..." sudo rm -f /etc/polkit-1/localauthority/50-local.d/kiosk-power.pkla # System cleanup echo "[12/13] 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 "[13/13] 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 ################################################################################ first_time_install() { clear echo "════════════════════════════════════════════════════════════" echo " Ubuntu Based Kiosk (UBK) v${SCRIPT_VERSION} - 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 (v42.x)" echo " • Multi-site rotation with touch controls" echo " • Hardware video acceleration" echo " • Audio support (PipeWire)" echo " • Time synchronization (NTP)" echo echo "OPTIONAL (configure after install):" echo " • Lyrion Music Server (LMS) / Squeezelite" echo " • CUPS printing" echo " • Remote desktop (VNC)" echo " • VPN (WireGuard, Tailscale, Netbird)" echo read -r -p "Proceed with installation? (y/n): " proceed [[ ! "$proceed" =~ ^[Yy]$ ]] && exit 0 # Cache sudo credentials upfront so they don't expire mid-install sudo -v echo echo "[1/27] 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 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 fi echo "[2/27] Configuring time synchronization..." sudo systemctl enable systemd-timesyncd sudo systemctl start systemd-timesyncd log_success "NTP time sync enabled" echo "[3/27] 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" else log_success "Kiosk user already exists" fi echo "[4/27] Configuring timezone..." configure_timezone echo "[5/27] Setting up kiosk directories..." sudo mkdir -p "$KIOSK_DIR" sudo chown -R "$KIOSK_USER:$KIOSK_USER" "$KIOSK_HOME" # Configure PipeWire noise cancellation for microphone echo "[5.5/27] Configuring audio noise cancellation..." sudo -u "$KIOSK_USER" mkdir -p "$KIOSK_HOME/.config/pipewire/pipewire.conf.d" sudo -u "$KIOSK_USER" tee "$KIOSK_HOME/.config/pipewire/pipewire.conf.d/99-noise-cancellation.conf" > /dev/null <<'NOISECFG' # 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 } } } ] NOISECFG sudo chown -R "$KIOSK_USER:$KIOSK_USER" "$KIOSK_HOME/.config" log_success "Audio noise cancellation configured" echo "[6/27] 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)" echo "[7-10/27] Core configuration..." configure_touch_controls configure_navigation_security configure_optional_features configure_password_protection configure_sites echo "[12/27] Initial scheduling (optional)..." echo read -r -p "Configure power/display/quiet schedules now? (y/n): " do_schedules if [[ "$do_schedules" =~ ^[Yy]$ ]]; then configure_power_display_quiet else log_info "Schedules can be configured later from Core Settings menu" fi save_config ################################################################## ####################start-main.js################################# ################################################################### echo "[13/27] Installing Electron..." sudo -u "$KIOSK_USER" tee "$KIOSK_DIR/main.js" > /dev/null <<'MAINJS' const {app,BrowserWindow,BrowserView,globalShortcut,ipcMain,dialog,session}=require('electron'); const {exec}=require('child_process'); const fs=require('fs'); const path=require('path'); const os=require('os'); const crypto=require('crypto'); // Suppress EPIPE errors (happen when no terminal attached) process.stdout.on('error',(e)=>{if(e.code!=='EPIPE')throw e;}); process.stderr.on('error',(e)=>{if(e.code!=='EPIPE')throw e;}); process.on('uncaughtException',(e)=>{ if(e.code==='EPIPE')return; console.error('Uncaught:',e); }); const CONFIG_FILE=path.join(__dirname,'config.json'); const VERSION='1.0.3'; let mainWindow,views=[],hiddenViews=[],tabs=[],currentIndex=0,showingHidden=false; let pinWindow=null,promptWindow=null,pauseWindow=null,htmlKeyboardWindow=null; let pinWindowTimer=null,pauseWindowTimer=null; const DIALOG_TIMEOUT=30000; // 30 seconds for secondary screens let tabIndexToViewIndex=[]; let currentHiddenIndex=0; let masterTimer=null; let siteStartTime=Date.now(); let lastUserInteraction=Date.now(); let lastMediaCheck=Date.now(); let keyboardOpenTime=0; let keyboardLastUsed=0; let inactivityExtensionUntil=0; let manualNavigationMode=false; let programmaticNavigation=false; let mediaIsPlaying=false; let userRecentlyActive=false; let keyboardIsOpen=false; let keyboardClosePending=false; const USER_ACTIVITY_PAUSE=60000; const KEYBOARD_AUTO_CLOSE=30000; const MEDIA_CHECK_INTERVAL=3000; const MEDIA_GRACE_PERIOD=30000; const SAFETY_MAX_EXTENSION=14400000; const INACTIVITY_PROMPT_TIMEOUT=15000; let lastMediaStateChange=Date.now(); let homeTabIndex=-1; let inactivityTimeout=120000; let allowNavigation='same-origin'; let enablePauseButton=true; let enableKeyboardButton=true; let enableNavButton=true; let enablePasswordProtection=false; let lockoutPassword=""; let lockoutTimeout=0; let lockoutAtTime=""; let lockoutActiveStart=""; let lockoutActiveEnd=""; let requirePasswordOnBoot=false; // Password lockout state let isLockedOut=false; let lockoutWindow=null; let lockoutTimer=null; let lockoutActivityTime=Date.now(); let requirePasswordAfterDisplay=false; let lastScheduledLockCheck=0; // Authelia auto-login state let autheliaURL=''; let autheliaUsername=''; let autheliaEncryptedPassword=''; function loadConfig(){ try{ if(!fs.existsSync(CONFIG_FILE)){ console.log('[CONFIG] No config file found'); return []; } const data=fs.readFileSync(CONFIG_FILE,'utf8'); const config=JSON.parse(data); homeTabIndex=(config.homeTabIndex!=null)?config.homeTabIndex:-1; inactivityTimeout=(config.inactivityTimeout||120)*1000; allowNavigation=config.allowNavigation||'same-origin'; enablePauseButton=(config.enablePauseButton!==false); enableKeyboardButton=(config.enableKeyboardButton!==false); enableNavButton=(config.enableNavButton!==false); enablePasswordProtection=(config.enablePasswordProtection===true); lockoutPassword=config.lockoutPassword||""; lockoutTimeout=(config.lockoutTimeout||0)*60000; // Convert minutes to ms lockoutAtTime=config.lockoutAtTime||""; lockoutActiveStart=config.lockoutActiveStart||""; lockoutActiveEnd=config.lockoutActiveEnd||""; requirePasswordOnBoot=(config.requirePasswordOnBoot===true); autheliaURL=config.autheliaURL||''; autheliaUsername=config.autheliaUsername||''; autheliaEncryptedPassword=config.autheliaEncryptedPassword||''; console.log('[CONFIG] ═════════════════════════════════'); console.log('[CONFIG] Home tab index:',homeTabIndex); console.log('[CONFIG] Inactivity timeout:',inactivityTimeout/1000,'seconds'); console.log('[CONFIG] Navigation:',allowNavigation); console.log('[CONFIG] Pause button:',enablePauseButton); console.log('[CONFIG] Keyboard button:',enableKeyboardButton); console.log('[CONFIG] Password protection:',enablePasswordProtection); console.log('[CONFIG] Lockout timeout:',lockoutTimeout/60000,'minutes'); if(lockoutAtTime)console.log('[CONFIG] Lock at time:',lockoutAtTime); if(lockoutActiveStart&&lockoutActiveEnd)console.log('[CONFIG] Active hours:',lockoutActiveStart,'-',lockoutActiveEnd); console.log('[CONFIG] Require password on boot:',requirePasswordOnBoot); console.log('[CONFIG] Sites:',config.tabs?.length||0); console.log('[CONFIG] ╚═══════════════════════════════╝'); return config.tabs||[]; }catch(e){ console.error('[CONFIG] Load error:',e.message); return []; } } function markActivity(){ const now=Date.now(); const timeSinceLastActivity=now-lastUserInteraction; if(timeSinceLastActivity>5000){ console.log('[ACTIVITY] User interaction detected'); } lastUserInteraction=now; userRecentlyActive=true; if(promptWindow&&!promptWindow.isDestroyed()){ console.log('[ACTIVITY] Closing inactivity prompt'); promptWindow.close(); promptWindow=null; } } function markKeyboardActivity(){ const now=Date.now(); keyboardLastUsed=now; keyboardOpenTime=now; keyboardClosePending=false; } // Password lockout functions function showLockoutScreen(){ if(isLockedOut||!enablePasswordProtection||!lockoutPassword)return; isLockedOut=true; console.log('[LOCKOUT] Showing lockout screen'); // Detach all browser views to prevent content from being visible console.log('[LOCKOUT] Detaching all browser views for security'); views.forEach(view=>{ if(mainWindow&&!mainWindow.isDestroyed()){ try{ mainWindow.removeBrowserView(view); }catch(e){ console.log('[LOCKOUT] View already detached or error:',e.message); } } }); hiddenViews.forEach(view=>{ if(mainWindow&&!mainWindow.isDestroyed()){ try{ mainWindow.removeBrowserView(view); }catch(e){ console.log('[LOCKOUT] Hidden view already detached or error:',e.message); } } }); // Create lockout window lockoutWindow=new BrowserWindow({ fullscreen:true, frame:false, backgroundColor:'#000000', webPreferences:{ nodeIntegration:true, contextIsolation:false } }); lockoutWindow.loadURL('data:text/html;charset=utf-8,'+encodeURIComponent(`

Session Locked

Incorrect password
`)); lockoutWindow.on('closed',()=>{ lockoutWindow=null; }); } function unlockScreen(){ if(!isLockedOut)return; console.log('[LOCKOUT] Unlocking screen'); isLockedOut=false; requirePasswordAfterDisplay=false; if(lockoutWindow&&!lockoutWindow.isDestroyed()){ lockoutWindow.close(); lockoutWindow=null; } // Restore current view if(showingHidden&&hiddenViews[currentHiddenIndex]){ try{ const[w,h]=mainWindow.getContentSize(); mainWindow.addBrowserView(hiddenViews[currentHiddenIndex]); mainWindow.setTopBrowserView(hiddenViews[currentHiddenIndex]); hiddenViews[currentHiddenIndex].setBounds({x:0,y:0,width:w,height:h}); }catch(e){ console.error('[LOCKOUT] Error restoring hidden view:',e); if(views.length>0){ showingHidden=false; attachView(0); } } }else if(views.length>0){ const idx=(currentIndex>=0&¤tIndexendMinutes){ return currentTime>=startMinutes||currentTime=startMinutes&¤tTime0){ if(!isWithinActiveHours()){ if(Math.floor(now/60000)!==Math.floor((now-1000)/60000)){ console.log('[LOCKOUT] Outside active hours, lockout disabled'); } return; } if(inactivityExtensionUntil>0&&now0&&now>=inactivityExtensionUntil){ console.log('[LOCKOUT-INACT] ⏰ Extension expired - resetting lockout timer'); inactivityExtensionUntil=0; lockoutActivityTime=now; } const timeSinceActivity=now-lockoutActivityTime; const minutesSinceActivity=Math.floor(timeSinceActivity/60000); const lockoutMinutes=Math.floor(lockoutTimeout/60000); if(Math.floor(timeSinceActivity/30000)!==Math.floor((timeSinceActivity-1000)/30000)){ console.log('[LOCKOUT-INACT] Idle: '+minutesSinceActivity+'m / '+lockoutMinutes+'m'); } if(timeSinceActivity>=lockoutTimeout){ console.log('[LOCKOUT] Inactivity timeout reached, locking screen'); showLockoutScreen(); } } } function checkMediaPlayback(){ let view=null; if(showingHidden&&hiddenViews[currentHiddenIndex]){ view=hiddenViews[currentHiddenIndex]; }else if(views[currentIndex]){ view=views[currentIndex]; } if(!view||!view.webContents){ if(mediaIsPlaying){ mediaIsPlaying=false; lastMediaStateChange=Date.now(); } return; } if(view.webContents.isLoadingMainFrame()||!view.webContents.getURL()){ return; } view.webContents.executeJavaScript(` (function(){ try{ let playing=false; let method=''; let details=''; const videos=document.querySelectorAll("video"); for(let v of videos){ if(!v.paused&&!v.ended&&v.readyState>=2&&v.currentTime>0){ playing=true; method='video'; details='HTML5 video'; break; } } if(!playing){ const audios=document.querySelectorAll("audio"); for(let a of audios){ if(!a.paused&&!a.ended&&a.readyState>=2&&a.currentTime>0){ playing=true; method='audio'; details='HTML5 audio'; break; } } } if(!playing){ const iframes=document.querySelectorAll( 'iframe[src*="youtube"],iframe[src*="vimeo"],'+ 'iframe[src*="dailymotion"],iframe[src*="twitch"],'+ 'iframe[src*="plex"],iframe[src*="emby"],iframe[src*="jellyfin"]' ); for(let iframe of iframes){ const rect=iframe.getBoundingClientRect(); if(rect.width>200&&rect.height>100&&rect.top0){ playing=true; method='iframe'; const src=iframe.src||''; if(src.includes('youtube'))details='YouTube'; else if(src.includes('plex'))details='Plex'; else if(src.includes('emby'))details='Emby'; else if(src.includes('jellyfin'))details='Jellyfin'; else if(src.includes('vimeo'))details='Vimeo'; else details='Embedded player'; break; } } } if(!playing){ if(document.querySelector('.Player-progressBar')|| document.querySelector('[class*="PlayerControls"]')){ const plexVideo=document.querySelector('video'); if(plexVideo&&!plexVideo.paused){ playing=true; method='plex-app'; details='Plex Web'; } } if(document.querySelector('.videoPlayerContainer')|| document.querySelector('.nowPlayingBar')){ const jellyfinVideo=document.querySelector('video'); if(jellyfinVideo&&!jellyfinVideo.paused){ playing=true; method='jellyfin-app'; details='Jellyfin Web'; } } if(document.querySelector('.videoPlayerContainer')|| document.querySelector('.nowPlayingBar')){ const embyVideo=document.querySelector('video'); if(embyVideo&&!embyVideo.paused){ playing=true; method='emby-app'; details='Emby Web'; } } } return {playing:playing,method:method,details:details}; }catch(e){ return {playing:false,error:e.message}; } })(); `,true).then(result=>{ const wasPlaying=mediaIsPlaying; const now=Date.now(); if(result&&result.playing){ if(!wasPlaying){ console.log('[MEDIA] ▶ Started:',result.details||result.method); } mediaIsPlaying=true; lastMediaStateChange=now; }else{ if(wasPlaying){ console.log('[MEDIA] ⸻ Stopped'); } mediaIsPlaying=false; if(wasPlaying){ lastMediaStateChange=now; } } }).catch(err=>{}); } function startMasterTimer(){ if(masterTimer){ clearInterval(masterTimer); } console.log('[TIMER] ════ MASTER TIMER STARTED ════'); console.log('[TIMER] Home tab index:',homeTabIndex); console.log('[TIMER] Inactivity timeout:',inactivityTimeout/1000,'seconds'); console.log('[TIMER] Password protection:',enablePasswordProtection); if(enablePasswordProtection){ console.log('[TIMER] Lockout timeout:',lockoutTimeout/60000,'minutes'); if(lockoutAtTime)console.log('[TIMER] Scheduled lock time:',lockoutAtTime); if(lockoutActiveStart&&lockoutActiveEnd)console.log('[TIMER] Active hours:',lockoutActiveStart,'-',lockoutActiveEnd); } console.log('[TIMER] ╚═══════════════════════════════╝'); siteStartTime=Date.now(); lastUserInteraction=Date.now(); masterTimer=setInterval(()=>{ const now=Date.now(); // 1. KEYBOARD AUTO-CLOSE if(keyboardIsOpen&&!keyboardClosePending){ const idleTime=now-keyboardLastUsed; if(idleTime>KEYBOARD_AUTO_CLOSE){ keyboardClosePending=true; closeHTMLKeyboard(); } } // 1.5. LOCKOUT TIMER CHECK checkLockoutTimer(); // 1.6. CHECK FOR DISPLAY WAKE FLAG const displayWakeFlag=path.join(__dirname,'.display-wake'); if(enablePasswordProtection&&lockoutPassword&&fs.existsSync(displayWakeFlag)){ console.log('[LOCKOUT] Display wake detected, requiring password'); fs.unlinkSync(displayWakeFlag); if(!isLockedOut){ showLockoutScreen(); } } // CRITICAL: If locked out, skip all navigation/rotation logic if(isLockedOut){ return; } // 2. MEDIA CHECK if(now-lastMediaCheck>MEDIA_CHECK_INTERVAL){ checkMediaPlayback(); lastMediaCheck=now; } // 3. MEDIA BLOCKING if(mediaIsPlaying){ return; } // 4. GRACE PERIOD const timeSinceMediaStopped=now-lastMediaStateChange; if(timeSinceMediaStopped1){ if(pauseWindow&&!pauseWindow.isDestroyed()){ return; } if(promptWindow&&!promptWindow.isDestroyed()){ return; } if(inactivityExtensionUntil>0&&now=0&&tabs[currentTabIdx]){ const siteDuration=parseInt(tabs[currentTabIdx].duration)||0; if(siteDuration>0){ const timeOnSite=now-siteStartTime; if(timeOnSite>=siteDuration*1000){ rotateToNextSite(); return; } } } } // 7. HOME RETURN CHECK (manual and hidden sites) if(homeTabIndex>=0){ if(pauseWindow&&!pauseWindow.isDestroyed()){ return; } if(promptWindow&&!promptWindow.isDestroyed()){ return; } let needsInactivityCheck=false; let currentSiteDuration=-999; if(showingHidden){ needsInactivityCheck=true; currentSiteDuration=-1; }else{ const homeViewIdx=getHomeViewIndex(); const currentTabIdx=viewIndexToTabIndex(currentIndex); if(homeViewIdx>=0&¤tIndex!==homeViewIdx&¤tTabIdx>=0&&tabs[currentTabIdx]){ currentSiteDuration=parseInt(tabs[currentTabIdx].duration)||0; if(currentSiteDuration===0){ needsInactivityCheck=true; } } } if(needsInactivityCheck){ const idleTime=now-lastUserInteraction; let effectiveTimeout=inactivityTimeout; if(inactivityExtensionUntil>0&&now0&&now>=inactivityExtensionUntil){ console.log('[HOME] ⏰ Extension expired - resetting inactivity timer'); inactivityExtensionUntil=0; lastUserInteraction=now; siteStartTime=now; } if(Math.floor(idleTime/15000)!==Math.floor((idleTime-1000)/15000)){ const idleMinutes=Math.floor(idleTime/60000); const idleSeconds=Math.floor((idleTime%60000)/1000); const timeoutMinutes=Math.floor(effectiveTimeout/60000); const timeoutSeconds=Math.floor((effectiveTimeout%60000)/1000); const siteType=currentSiteDuration===-1?'HIDDEN':'MANUAL'; console.log('[HOME] 🏠 '+siteType+' IDLE: '+idleMinutes+'m '+idleSeconds+'s / '+timeoutMinutes+'m '+timeoutSeconds+'s'); } if(idleTime>=effectiveTimeout){ console.log('[HOME] 🔔 *** SHOWING PROMPT NOW ('+ (currentSiteDuration===-1?'hidden tab':'manual site')+') ***'); showInactivityPrompt(); } } } },1000); } function stopMasterTimer(){ if(masterTimer){ clearInterval(masterTimer); masterTimer=null; } } function rotateToNextSite(){ if(isLockedOut)return; if(!views.length||showingHidden)return; let nextIdx=(currentIndex+1)%views.length; const startIdx=nextIdx; let found=false; let attempts=0; do{ const tabIdx=viewIndexToTabIndex(nextIdx); if(tabIdx>=0&&tabs[tabIdx]){ const dur=parseInt(tabs[tabIdx].duration)||0; if(dur>0){ found=true; break; } } nextIdx=(nextIdx+1)%views.length; attempts++; }while(nextIdx!==startIdx&&attempts{ if(idx!==i&&mainWindow&&!mainWindow.isDestroyed()){ try{ mainWindow.removeBrowserView(view); }catch(e){ // View may not be attached, ignore } } }); try{ mainWindow.addBrowserView(views[i]); }catch(e){ console.log('[MAIN] View already attached or error:',e.message); } mainWindow.setTopBrowserView(views[i]); const[w,h]=mainWindow.getContentSize(); views[i].setBounds({x:0,y:0,width:w,height:h}); // Force repaint to ensure proper rendering if(views[i].webContents){ views[i].webContents.invalidate(); } const tabIdx=viewIndexToTabIndex(i); if(tabIdx>=0&&tabs[tabIdx]){ const configuredUrl=tabs[tabIdx].url; const currentUrl=views[i].webContents.getURL(); if(currentUrl&&!currentUrl.startsWith(configuredUrl)){ programmaticNavigation=true; views[i].webContents.loadURL(configuredUrl); } const siteDuration=parseInt(tabs[tabIdx].duration)||0; const shouldShow=enablePauseButton&&siteDuration>0; console.log('[MAIN] Sending pause-button-visibility to tab '+tabIdx+' ('+tabs[tabIdx].url+') - duration='+siteDuration+'s, shouldShow='+shouldShow); views[i].webContents.send('pause-button-visibility',shouldShow); } views[i].webContents.focus(); siteStartTime=Date.now(); } function nextTab(){ if(isLockedOut)return; if(!views.length||showingHidden)return; console.log('[MANUAL] User switched tab forward → manualNavigationMode=TRUE'); manualNavigationMode=true; currentIndex=(currentIndex+1)%views.length; attachView(currentIndex); markActivity(); inactivityExtensionUntil=0; console.log('[MANUAL] Extension cleared due to manual tab switch'); } function prevTab(){ if(isLockedOut)return; if(!views.length||showingHidden)return; console.log('[MANUAL] User switched tab backward → manualNavigationMode=TRUE'); manualNavigationMode=true; currentIndex=(currentIndex-1+views.length)%views.length; attachView(currentIndex); markActivity(); inactivityExtensionUntil=0; console.log('[MANUAL] Extension cleared due to manual tab switch'); } function getHomeViewIndex(){ if(homeTabIndex<0)return -1; if(homeTabIndex>=tabIndexToViewIndex.length)return -1; return tabIndexToViewIndex[homeTabIndex]; } function returnToHome(){ if(isLockedOut)return; const homeViewIdx=getHomeViewIndex(); if(homeViewIdx<0)return; console.log('[HOME] 🏠 RETURNING TO HOME → manualNavigationMode=FALSE'); if(showingHidden){ showingHidden=false; currentHiddenIndex=0; } if(promptWindow&&!promptWindow.isDestroyed()){ promptWindow.close(); promptWindow=null; } manualNavigationMode=false; currentIndex=homeViewIdx; attachView(currentIndex); inactivityExtensionUntil=0; if(enablePasswordProtection&&lockoutTimeout>0&&!isLockedOut){ lockoutActivityTime=Date.now(); } markActivity(); } function showInactivityPrompt(){ if(promptWindow&&!promptWindow.isDestroyed())return; promptWindow=new BrowserWindow({ width:800, height:600, frame:false, alwaysOnTop:true, parent:mainWindow, modal:true, webPreferences:{nodeIntegration:true,contextIsolation:false} }); promptWindow.loadFile(path.join(__dirname,'inactivity-prompt-extended.html')); promptWindow.on('closed',()=>{ promptWindow=null; }); setTimeout(()=>{ if(promptWindow&&!promptWindow.isDestroyed()){ console.log('[PROMPT] No response - returning home'); promptWindow.close(); promptWindow=null; returnToHome(); } },INACTIVITY_PROMPT_TIMEOUT); ipcMain.once('user-still-here',(event,minutes)=>{ if(promptWindow&&!promptWindow.isDestroyed()){ promptWindow.close(); } promptWindow=null; if(minutes===-1){ inactivityExtensionUntil=0; if(enablePasswordProtection&&lockoutTimeout>0&&!isLockedOut){ lockoutActivityTime=Date.now(); } returnToHome(); }else if(minutes===0){ inactivityExtensionUntil=0; if(enablePasswordProtection&&lockoutTimeout>0&&!isLockedOut){ lockoutActivityTime=Date.now(); } markActivity(); siteStartTime=Date.now(); console.log('[PROMPT] User confirmed presence - rotation timer reset'); }else{ const now=Date.now(); inactivityExtensionUntil=now+(minutes*60*1000); lastUserInteraction=now; siteStartTime=now; if(enablePasswordProtection&&lockoutTimeout>0&&!isLockedOut){ lockoutActivityTime=now; console.log('[PROMPT] Lockout timer also reset during extension'); } console.log('[PROMPT] Extended for '+minutes+' min until: '+new Date(inactivityExtensionUntil).toLocaleTimeString()); console.log('[PROMPT] Rotation timer reset - staying on current page'); } }); } function showPauseDialog(){ if(pauseWindow&&!pauseWindow.isDestroyed())return; pauseWindow=new BrowserWindow({ width:800, height:600, frame:false, alwaysOnTop:true, parent:mainWindow, modal:true, webPreferences:{nodeIntegration:true,contextIsolation:false} }); pauseWindow.loadFile(path.join(__dirname,'pause-dialog.html')); pauseWindow.on('closed',()=>{ if(pauseWindowTimer){clearTimeout(pauseWindowTimer);pauseWindowTimer=null;} pauseWindow=null; }); // Set 30-second auto-dismiss timer if(pauseWindowTimer){clearTimeout(pauseWindowTimer);} pauseWindowTimer=setTimeout(()=>{ console.log('[PAUSE] Auto-dismissing dialog after 30 seconds'); if(pauseWindow&&!pauseWindow.isDestroyed()){ pauseWindow.close(); } pauseWindow=null; pauseWindowTimer=null; },DIALOG_TIMEOUT); ipcMain.once('pause-time-selected',(event,minutes)=>{ if(pauseWindowTimer){clearTimeout(pauseWindowTimer);pauseWindowTimer=null;} if(pauseWindow&&!pauseWindow.isDestroyed()){ pauseWindow.close(); } pauseWindow=null; if(minutes===0){ console.log('[PAUSE] Cancelled'); }else{ const now=Date.now(); inactivityExtensionUntil=now+(minutes*60*1000); lastUserInteraction=now; siteStartTime=now; if(enablePasswordProtection&&lockoutTimeout>0&&!isLockedOut){ lockoutActivityTime=now; console.log('[PAUSE] Lockout timer also reset during extension'); } console.log('[PAUSE] Extended for '+minutes+' min until: '+new Date(inactivityExtensionUntil).toLocaleTimeString()); console.log('[PAUSE] Rotation and inactivity timers paused'); } }); } function showHTMLKeyboard(){ if(keyboardIsOpen){ keyboardLastUsed=Date.now(); keyboardOpenTime=Date.now(); keyboardClosePending=false; return; } if(htmlKeyboardWindow&&!htmlKeyboardWindow.isDestroyed()){ htmlKeyboardWindow.focus(); keyboardLastUsed=Date.now(); keyboardOpenTime=Date.now(); keyboardIsOpen=true; keyboardClosePending=false; return; } const{width,height}=mainWindow.getBounds(); const kbHeight=Math.floor(height*0.4); const kbY=height-kbHeight; htmlKeyboardWindow=new BrowserWindow({ width:width, height:kbHeight, x:0, y:kbY, frame:false, alwaysOnTop:true, skipTaskbar:true, focusable:false, webPreferences:{ nodeIntegration:true, contextIsolation:false, backgroundThrottling:false } }); htmlKeyboardWindow.loadFile(path.join(__dirname,'keyboard.html')); htmlKeyboardWindow.webContents.on('did-finish-load',()=>{ keyboardIsOpen=true; keyboardOpenTime=Date.now(); keyboardLastUsed=Date.now(); keyboardClosePending=false; notifyKeyboardState(true); if(mainWindow&&!mainWindow.isDestroyed()){ mainWindow.focus(); if(views[currentIndex]&&views[currentIndex].webContents){ views[currentIndex].webContents.focus(); } } }); htmlKeyboardWindow.on('closed',()=>{ keyboardIsOpen=false; keyboardClosePending=false; htmlKeyboardWindow=null; notifyKeyboardState(false); }); } function closeHTMLKeyboard(){ if(!keyboardIsOpen)return; const wasAutoClosed=keyboardClosePending; if(htmlKeyboardWindow&&!htmlKeyboardWindow.isDestroyed()){ htmlKeyboardWindow.close(); } htmlKeyboardWindow=null; keyboardIsOpen=false; keyboardClosePending=false; notifyKeyboardState(false); if(wasAutoClosed){ const allViews=[...views,...hiddenViews]; allViews.forEach(view=>{ if(view&&view.webContents){ view.webContents.send('keyboard-auto-closed'); } }); } if(mainWindow&&!mainWindow.isDestroyed()){ mainWindow.focus(); } } function notifyKeyboardState(visible){ const allViews=[...views,...hiddenViews]; allViews.forEach(view=>{ if(view&&view.webContents){ view.webContents.send('keyboard-state-changed',visible); } }); } function viewIndexToTabIndex(viewIdx){ for(let i=0;i=hiddenViews.length){ currentHiddenIndex=0; returnToTabs(); }else{ showHiddenTab(currentHiddenIndex); } }else{ currentHiddenIndex=0; showPinEntry(); } } function returnToTabs(){ if(!views.length)return; const[w,h]=mainWindow.getContentSize(); mainWindow.setTopBrowserView(views[currentIndex]); views[currentIndex].setBounds({x:0,y:0,width:w,height:h}); showingHidden=false; currentHiddenIndex=0; markActivity(); } function showPinEntry(){ if(pinWindow&&!pinWindow.isDestroyed()){ pinWindow.focus(); return; } pinWindow=new BrowserWindow({ width:500, height:650, frame:false, alwaysOnTop:true, parent:mainWindow, modal:true, webPreferences:{nodeIntegration:true,contextIsolation:false} }); pinWindow.loadFile(path.join(__dirname,'pin-entry.html')); pinWindow.on('closed',()=>{ if(pinWindowTimer){clearTimeout(pinWindowTimer);pinWindowTimer=null;} pinWindow=null; }); // Set 30-second auto-dismiss timer if(pinWindowTimer){clearTimeout(pinWindowTimer);} pinWindowTimer=setTimeout(()=>{ console.log('[PIN] Auto-dismissing after 30 seconds'); if(pinWindow&&!pinWindow.isDestroyed()){ pinWindow.close(); } pinWindow=null; pinWindowTimer=null; },DIALOG_TIMEOUT); ipcMain.once('pin-correct',()=>{ if(pinWindowTimer){clearTimeout(pinWindowTimer);pinWindowTimer=null;} if(pinWindow&&!pinWindow.isDestroyed()){ pinWindow.close(); } pinWindow=null; showHiddenTab(currentHiddenIndex); }); ipcMain.once('pin-cancelled',()=>{ if(pinWindowTimer){clearTimeout(pinWindowTimer);pinWindowTimer=null;} if(pinWindow&&!pinWindow.isDestroyed()){ pinWindow.close(); } pinWindow=null; }); } function showHiddenTab(index){ if(!hiddenViews[index])return; const[w,h]=mainWindow.getContentSize(); mainWindow.setTopBrowserView(hiddenViews[index]); hiddenViews[index].setBounds({x:0,y:0,width:w,height:h}); showingHidden=true; } function forceReturnToTabs(){ if(pinWindow&&!pinWindow.isDestroyed()){ pinWindow.close(); pinWindow=null; } returnToTabs(); } function showPowerMenu(){ const ipAddress=os.networkInterfaces(); let localIP='No IP'; let vpnIP=''; // Get local IP (exclude VPN interfaces) for(const name of Object.keys(ipAddress)){ // Skip VPN interfaces if(name.startsWith('tailscale')||name.startsWith('wg')||name.startsWith('netbird')||name.startsWith('tun')||name.startsWith('wt')){ continue; } for(const net of ipAddress[name]){ if(net.family==='IPv4'&&!net.internal){ localIP=net.address; break; } } if(localIP!=='No IP')break; } // Get VPN IPs (Tailscale, WireGuard, Netbird) for(const name of Object.keys(ipAddress)){ if(name.startsWith('tailscale')||name.startsWith('wg')||name.startsWith('netbird')||name.startsWith('tun')||name.startsWith('wt')){ for(const net of ipAddress[name]){ if(net.family==='IPv4'){ vpnIP+=net.address+' ('+name+') '; } } } } // For lockout mode, use native dialog (limited options, overlay not available) if(isLockedOut){ console.log('[SECURITY] Showing limited power menu - system is locked out'); let ipInfo='Local: '+localIP; if(vpnIP){ipInfo+='\nVPN: '+vpnIP.trim();} const targetWindow=(lockoutWindow&&!lockoutWindow.isDestroyed())?lockoutWindow:mainWindow; const r=dialog.showMessageBoxSync(targetWindow,{ type:'question', buttons:['Shutdown','Restart','Cancel'], defaultId:2, title:'Power Options', message:'System is locked. Limited options available.\n\nVersion: '+VERSION+'\n'+ipInfo, noLink:true }); if(r===0)exec('systemctl poweroff'); else if(r===1)exec('systemctl reboot'); return; } // For normal mode, use custom overlay with 30-second timeout console.log('[POWER] Showing custom power menu overlay'); const powerInfo={version:VERSION,localIP:localIP,vpnIP:vpnIP.trim()}; // Send to all views (preload.js runs in views, not mainWindow) for(const view of views){ if(view&&view.webContents&&!view.webContents.isDestroyed()){ view.webContents.send('display-power-menu',powerInfo); } } } async function autheliaAuthenticate(){ if(!autheliaURL||!autheliaUsername||!autheliaEncryptedPassword)return; try{ const machineId=fs.readFileSync('/etc/machine-id','utf8').trim(); const key=crypto.scryptSync(machineId,'kiosk-authelia-v1',32); const buf=Buffer.from(autheliaEncryptedPassword,'base64'); const iv=buf.subarray(0,16); const enc=buf.subarray(16); const decipher=crypto.createDecipheriv('aes-256-cbc',key,iv); const password=Buffer.concat([decipher.update(enc),decipher.final()]).toString('utf8'); const ctrl=new AbortController(); const timer=setTimeout(()=>ctrl.abort(),10000); const res=await session.defaultSession.fetch(`${autheliaURL}/api/firstfactor`,{ method:'POST', headers:{'Content-Type':'application/json','User-Agent':'kiosk/1.0'}, body:JSON.stringify({username:autheliaUsername,password,keepMeLoggedIn:true,requestMethod:'GET',targetURL:''}), signal:ctrl.signal }).finally(()=>clearTimeout(timer)); const body=await res.json().catch(()=>({})); if(res.ok&&body.status==='OK'){ console.log('[AUTHELIA] Authenticated as',autheliaUsername); }else{ console.error('[AUTHELIA] Auth failed:',res.status,body.message||''); } }catch(e){ console.error('[AUTHELIA] Error:',e.message); } } async function createWindow(){ tabs=loadConfig(); await autheliaAuthenticate(); mainWindow=new BrowserWindow({ fullscreen:true, kiosk:true, frame:false, show:false, webPreferences:{ nodeIntegration:false, contextIsolation:true, sandbox:false, preload:path.join(__dirname,'preload.js') } }); mainWindow.setMenu(null); mainWindow.show(); mainWindow.on('focus',()=>markActivity()); mainWindow.webContents.on('before-input-event',()=>markActivity()); if(!tabs.length){ mainWindow.loadURL('data:text/html,No Sites Configured'); return; } let viewIndex=0; tabs.forEach((t,tabIdx)=>{ const view=new BrowserView({ webPreferences:{ contextIsolation:true, sandbox:false, preload:path.join(__dirname,'preload.js'), backgroundThrottling:false } }); mainWindow.addBrowserView(view); let url=t.url; if(t.username&&t.password){ try{ const u=new URL(t.url); u.username=t.username; u.password=t.password; url=u.toString(); }catch(e){} } const initialOrigin=new URL(t.url).origin; if(allowNavigation==='restricted'){ view.webContents.on('will-navigate',(e,u)=>{ if(u!==url&&u!==t.url)e.preventDefault(); }); view.webContents.setWindowOpenHandler(()=>({action:'deny'})); }else if(allowNavigation==='same-origin'){ view.webContents.on('will-navigate',(e,u)=>{ try{ if(new URL(u).origin!==initialOrigin)e.preventDefault(); }catch(x){ e.preventDefault(); } }); } view.webContents.on('before-input-event',()=>markActivity()); view.webContents.on('did-start-loading',()=>{ if(!programmaticNavigation){ markActivity(); } }); view.webContents.on('did-navigate',()=>{ if(programmaticNavigation){ programmaticNavigation=false; }else{ markActivity(); } }); view.webContents.setAudioMuted(false); view.webContents.loadURL(url); view.webContents.on('did-finish-load',()=>{ view.webContents.executeJavaScript(` ["mousedown","keydown","touchstart","scroll","click"].forEach(e=>{ document.addEventListener(e,()=>{ if(window.electronAPI?.notifyActivity){ window.electronAPI.notifyActivity(); } },true); }); `).catch(()=>{}); const siteDuration=parseInt(t.duration)||0; const shouldShow=enablePauseButton&&siteDuration>0; view.webContents.send('pause-button-visibility',shouldShow); view.webContents.send('keyboard-button-enabled',enableKeyboardButton); console.log('[MAIN] Page loaded - sending keyboard-button-enabled: '+enableKeyboardButton); view.webContents.send('nav-button-enabled',enableNavButton); console.log('[MAIN] Page loaded - sending nav-button-enabled: '+enableNavButton); console.log('[MAIN] Page loaded - resending pause-button-visibility: '+shouldShow+' for '+t.url); }); const isHidden=parseInt(t.duration)===-1; if(isHidden){ hiddenViews.push(view); tabIndexToViewIndex[tabIdx]=-1; }else{ views.push(view); tabIndexToViewIndex[tabIdx]=viewIndex; viewIndex++; } }); const homeViewIdx=getHomeViewIndex(); const startIndex=homeViewIdx>=0?homeViewIdx:0; if(views.length){ setTimeout(()=>{ const bootFlag=path.join(__dirname,'.boot-flag'); if(enablePasswordProtection&&lockoutPassword&&requirePasswordOnBoot&&fs.existsSync(bootFlag)){ console.log('[LOCKOUT] Boot detected, requiring password BEFORE showing sites'); fs.unlinkSync(bootFlag); showLockoutScreen(); }else{ attachView(startIndex); startMasterTimer(); } },1000); } ipcMain.on('swipe-left',()=>{nextTab();}); ipcMain.on('swipe-right',()=>{prevTab();}); ipcMain.on('show-power-menu',showPowerMenu); ipcMain.on('power-action',(event,action)=>{ console.log('[POWER] Action requested:',action); if(action==='shutdown')exec('systemctl poweroff'); else if(action==='restart')exec('systemctl reboot'); else if(action==='reload'){app.relaunch();app.quit();} }); ipcMain.on('toggle-hidden',toggleHidden); ipcMain.on('return-to-tabs',forceReturnToTabs); ipcMain.on('user-activity',markActivity); ipcMain.on('show-keyboard',()=>{showHTMLKeyboard();}); ipcMain.on('close-keyboard',()=>{closeHTMLKeyboard();}); ipcMain.on('keyboard-activity',()=>{markKeyboardActivity();}); ipcMain.on('show-pause-dialog',()=>{showPauseDialog();}); ipcMain.on('check-lockout-password',(event,hash)=>{ if(hash===lockoutPassword){ unlockScreen(); }else{ if(lockoutWindow&&!lockoutWindow.isDestroyed()){ lockoutWindow.webContents.send('password-incorrect'); } } }); ipcMain.on('get-config',(event)=>{ try{ if(fs.existsSync(CONFIG_FILE)){ const data=fs.readFileSync(CONFIG_FILE,'utf8'); const config=JSON.parse(data); event.sender.send('config-data',config); console.log('[NAV] Sent config data to renderer'); }else{ console.error('[NAV] Config file not found'); event.sender.send('config-data',{tabs:[]}); } }catch(err){ console.error('[NAV] Error reading config:',err); event.sender.send('config-data',{tabs:[]}); } }); ipcMain.on('navigate-to-tab',(event,tabIndex)=>{ console.log('[NAV] Navigate to tab '+tabIndex); if(showingHidden){ forceReturnToTabs(); } const viewIndex=tabIndexToViewIndex[tabIndex]; if(viewIndex!==undefined&&viewIndex>=0&&viewIndex{ markKeyboardActivity(); let view=null; if(showingHidden&&hiddenViews[currentHiddenIndex]){ view=hiddenViews[currentHiddenIndex]; }else if(views[currentIndex]){ view=views[currentIndex]; } if(!view||!view.webContents)return; const safeKey=JSON.stringify(key).slice(1,-1); if(key==='Backspace'){ view.webContents.executeJavaScript(` (function(){ const el=document.activeElement; if(el&&(el.tagName==="INPUT"||el.tagName==="TEXTAREA")){ const s=el.selectionStart||0; if(s>0){ el.value=el.value.substring(0,s-1)+el.value.substring(el.selectionEnd||s); el.selectionStart=el.selectionEnd=s-1; el.dispatchEvent(new Event("input",{bubbles:true})); } } })(); `).catch(()=>{}); }else if(key==='Enter'){ view.webContents.executeJavaScript(` (function(){ const el=document.activeElement; if(el){ if(el.tagName==="TEXTAREA"){ const s=el.selectionStart||0; el.value=el.value.substring(0,s)+"\\n"+el.value.substring(el.selectionEnd||s); el.selectionStart=el.selectionEnd=s+1; el.dispatchEvent(new Event("input",{bubbles:true})); }else if(el.tagName==="INPUT"){ const form=el.closest("form"); if(form){ form.dispatchEvent(new Event("submit",{bubbles:true,cancelable:true})); } } } })(); `).catch(()=>{}); }else if(key===' '){ view.webContents.executeJavaScript(` (function(){ const el=document.activeElement; if(el&&(el.tagName==="INPUT"||el.tagName==="TEXTAREA")){ const s=el.selectionStart||0; el.value=el.value.substring(0,s)+" "+el.value.substring(el.selectionEnd||s); el.selectionStart=el.selectionEnd=s+1; el.dispatchEvent(new Event("input",{bubbles:true})); } })(); `).catch(()=>{}); }else if(key==='Control'||key==='Alt'){ // Ignore modifier keys - they don't work as standalone keys return; }else{ view.webContents.executeJavaScript(` (function(){ const text="${safeKey}"; const el=document.activeElement; if(el&&(el.tagName==="INPUT"||el.tagName==="TEXTAREA")){ const s=el.selectionStart||0; const e=el.selectionEnd||s; el.value=el.value.substring(0,s)+text+el.value.substring(e); el.selectionStart=el.selectionEnd=s+text.length; el.dispatchEvent(new Event("input",{bubbles:true})); el.dispatchEvent(new Event("change",{bubbles:true})); } })(); `).catch(()=>{}); } }); if(views.length>1){ globalShortcut.register('Control+Tab',()=>{nextTab();}); globalShortcut.register('Control+Shift+Tab',()=>{prevTab();}); globalShortcut.register('Control+]',()=>{nextTab();}); globalShortcut.register('Control+[',()=>{prevTab();}); globalShortcut.register('Alt+Right',()=>{nextTab();}); globalShortcut.register('Alt+Left',()=>{prevTab();}); } globalShortcut.register('F10',toggleHidden); globalShortcut.register('Control+H',toggleHidden); globalShortcut.register('Escape',forceReturnToTabs); globalShortcut.register('Control+Alt+Delete',showPowerMenu); globalShortcut.register('Control+Alt+P',showPowerMenu); globalShortcut.register('Control+Alt+Escape',showPowerMenu); globalShortcut.register('Control+K',()=>{ if(htmlKeyboardWindow&&!htmlKeyboardWindow.isDestroyed()){ closeHTMLKeyboard(); }else{ showHTMLKeyboard(); } }); mainWindow.on('resize',()=>{ const[w,h]=mainWindow.getContentSize(); if(showingHidden&&hiddenViews[currentHiddenIndex]){ hiddenViews[currentHiddenIndex].setBounds({x:0,y:0,width:w,height:h}); }else if(views[currentIndex]){ views[currentIndex].setBounds({x:0,y:0,width:w,height:h}); } }); } if(!app.requestSingleInstanceLock())app.quit(); // Handle SIGUSR1 from power button trigger script process.on('SIGUSR1',()=>{ console.log('[POWER] Received SIGUSR1 signal'); try{ if(mainWindow&&!mainWindow.isDestroyed()){ showPowerMenu(); }else{ console.log('[POWER] mainWindow not ready'); } }catch(e){ console.error('[POWER] Error:',e.message); } }); app.on('certificate-error',(e,w,u,er,c,cb)=>{ e.preventDefault(); cb(true); }); app.on('ready',createWindow); app.on('will-quit',()=>{ globalShortcut.unregisterAll(); stopMasterTimer(); if(htmlKeyboardWindow&&!htmlKeyboardWindow.isDestroyed()){ htmlKeyboardWindow.close(); } }); app.on('window-all-closed',()=>{ if(process.platform!=='darwin')app.quit(); }); app.on('activate',()=>{ if(BrowserWindow.getAllWindows().length===0)createWindow(); }); MAINJS ############################################################################# ###############################end of main.js################################ ############################################################################## echo "[14/27] Creating keyboard.html with shift display + 30s timeout..." sudo -u "$KIOSK_USER" tee "$KIOSK_DIR/keyboard.html" > /dev/null <<'KBHTML'
×
⌨️ Keyboard - Click icon or swipe to reopen
1
2
3
4
5
6
7
8
9
0
-
=
Tab
q
w
e
r
t
y
u
i
o
p
[
]
\
Caps
a
s
d
f
g
h
j
k
l
;
'
z
x
c
v
b
n
m
,
.
/
Ctrl
Alt
Space
Alt
Ctrl
KBHTML echo "[14.5/27] Creating pause-dialog.html..." sudo -u "$KIOSK_USER" tee "$KIOSK_DIR/pause-dialog.html" > /dev/null <<'PAUSEHTML'

⏸️ Pause Timers

Select how long to pause rotation and inactivity timers:
After the time expires, normal rotation and return-to-home logic will resume.
Auto-closing in 30 seconds...
PAUSEHTML echo "[15/27] Creating PIN entry dialog..." sudo -u "$KIOSK_USER" tee "$KIOSK_DIR/pin-entry.html" > /dev/null <<'PINHTML'

🔒 Enter PIN

••••
❌ Incorrect PIN
Default PIN: 1234 (4-8 digits)
PINHTML echo "[15/27] Creating inactivity prompt..." sudo -u "$KIOSK_USER" tee "$KIOSK_DIR/inactivity-prompt-extended.html" > /dev/null <<'INACTHTML'

👋 Are you still here?

No activity detected. Choose an option:
15
ℹ️ Extensions pause the inactivity timer
Media playback (video/audio) automatically pauses the timer
Maximum extension: 4 hours (safety timeout)
INACTHTML echo "1234" | sudo -u "$KIOSK_USER" tee "$KIOSK_DIR/.jitsi-pin" >/dev/null sudo -u "$KIOSK_USER" chmod 600 "$KIOSK_DIR/.jitsi-pin" log_success "Default PIN: 1234" ########################################################################### ############################start-preload################################## ########################################################################### sudo -u "$KIOSK_USER" tee "$KIOSK_DIR/preload.js" > /dev/null <<'PRELOAD' 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='
'; 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=''; 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=''; 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='

Sites

'; 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=`

Touch Gestures

2-Finger Horizontal Swipe
Switch between sites
1-Finger Horizontal Swipe
Navigate within page (arrow keys)
3-Finger Down Swipe
Toggle hidden tabs (PIN required)
Keyboard Shortcuts
Ctrl+Tab or Ctrl+] Next tab
Ctrl+Shift+Tab or Ctrl+[ Previous tab
F10 or Ctrl+H Toggle hidden tabs
Escape Return to normal tabs
Ctrl+Alt+Delete or Ctrl+Alt+P Power menu
Ctrl+K Toggle keyboard
`; 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+'
Local: '+info.localIP; if(info.vpnIP){ infoText+='
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='
No sites configured
'; 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&&absX0){ console.log('[TOUCH] 3-finger DOWN - toggle hidden tabs'); ipcRenderer.send('toggle-hidden'); }else if(fingerCount===2&&absX>SWIPE_THRESHOLD&&absY0?'swipe-right':'swipe-left'); }else if(fingerCount===1&&absX>SWIPE_THRESHOLD&&absY0?'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&&absX0){ console.log('[TOUCH] 3-finger DOWN (ptr) - toggle hidden tabs'); ipcRenderer.send('toggle-hidden'); }else if(ptrPeak===2&&absX>SWIPE_THRESHOLD&&absY0?'swipe-right':'swipe-left'); }else if(ptrPeak===1&&absX>SWIPE_THRESHOLD&&absY0?'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{ document.addEventListener(eventType,()=>handleUserInteraction(eventType),{passive:true,capture:true}); }); }); PRELOAD ############################################################################ ################################end-preload.js############################## ############################################################################ echo "[16/27] Creating package.json..." sudo -u "$KIOSK_USER" tee "$KIOSK_DIR/package.json" > /dev/null <<'PKGJSON' { "name": "kiosk-app", "version": "1.0.0", "main": "main.js", "dependencies": { "electron": "^42.0.0" } } PKGJSON echo "[17/27] Installing Electron packages..." echo "Note: npm may show deprecation warnings (safe to ignore)" echo "Note: Electron binary is ~120MB — download may take several minutes" # Increase npm fetch timeouts before install (default 60s is too short for ~120MB) 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 " sudo -u "$KIOSK_USER" bash -lc "cd '$KIOSK_DIR' && npm install --unsafe-perm" install_electron_binary || exit 1 sudo -u "$KIOSK_USER" tee "$KIOSK_DIR/start.sh" > /dev/null <<'LAUNCHER' #!/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 LAUNCHER sudo chmod +x "$KIOSK_DIR/start.sh" echo "[18/27] Configuring Openbox with AGGRESSIVE screen keep-alive..." sudo -u "$KIOSK_USER" mkdir -p "$KIOSK_HOME/.config/openbox" "$KIOSK_HOME/.config/pulse" # Force any touch screen to use the libinput driver. # Some drivers (notably wacom) only do single-touch pointer emulation and # never pass real multitouch through to Chromium, so app gestures (2-finger # swipe, 1-finger swipe) never fire. libinput delivers proper XI2 multitouch # which Chromium turns into real JS touch events, and is the correct driver # for any touch screen. MatchIsTouchscreen is set by udev from hardware # capabilities, so this matches finger touch screens only — never keyboards, # mice, or the pen/stylus (those stay on their existing driver). sudo mkdir -p /etc/X11/xorg.conf.d sudo tee /etc/X11/xorg.conf.d/99-finger-libinput.conf > /dev/null <<'TOUCHCFG' Section "InputClass" Identifier "Touch screen use libinput" MatchIsTouchscreen "on" Driver "libinput" EndSection TOUCHCFG # Create empty xbindkeysrc to prevent errors sudo -u "$KIOSK_USER" touch "$KIOSK_HOME/.xbindkeysrc" # Shared logic for mirroring any connected display beyond the primary at # the primary's exact resolution (forcing a custom CVT mode if the # external output doesn't natively list it). Called from both the # Openbox autostart below (already running as kiosk user in the X # session) and kiosk-hotplug.sh (root, via systemd/udev — wraps the call # with sudo -u kiosk DISPLAY=:0 XAUTHORITY=...). sudo tee /usr/local/bin/kiosk-mirror-display.sh > /dev/null <<'MIRRORSCRIPT' #!/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 MIRRORSCRIPT sudo chmod +x /usr/local/bin/kiosk-mirror-display.sh # Shared logic for routing audio to an HDMI audio sink whenever an # external (non-primary) display is connected/mirrored, and back to the # built-in sink when it isn't. Requires PipeWire/pipewire-pulse to # already be running (pactl needs a live socket), so unlike # kiosk-mirror-display.sh this is called later in autostart, after the # "wait for PipeWire/ALSA" steps below — and from kiosk-hotplug.sh, where # the system is already fully booted by the time it fires. sudo tee /usr/local/bin/kiosk-audio-route.sh > /dev/null <<'AUDIOSCRIPT' #!/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 AUDIOSCRIPT sudo chmod +x /usr/local/bin/kiosk-audio-route.sh sudo -u "$KIOSK_USER" tee "$KIOSK_HOME/.config/openbox/autostart" > /dev/null <<'AUTOSTART' #!/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 & AUTOSTART sudo chmod 750 "$KIOSK_HOME/.config/openbox/autostart" # HDMI/display hotplug: re-mirror any newly connected external display # without waiting for the next login. Triggered by udev on DRM "change" # events (monitor plugged/unplugged), which starts a oneshot systemd # service that re-runs the same kiosk-mirror-display.sh logic used at # Openbox autostart, as root, so it wraps the call with sudo -u kiosk. sudo tee /usr/local/bin/kiosk-hotplug.sh > /dev/null <<'EOF' #!/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 EOF sudo chmod +x /usr/local/bin/kiosk-hotplug.sh sudo tee /etc/systemd/system/kiosk-hotplug.service > /dev/null <<'EOF' [Unit] Description=Kiosk Display Hotplug Handler [Service] Type=oneshot ExecStart=/usr/local/bin/kiosk-hotplug.sh StandardOutput=journal StandardError=journal EOF sudo tee /etc/udev/rules.d/99-kiosk-hotplug.rules > /dev/null <<'EOF' SUBSYSTEM=="drm", ACTION=="change", TAG+="systemd", ENV{SYSTEMD_WANTS}="kiosk-hotplug.service" EOF sudo systemctl daemon-reload sudo udevadm control --reload-rules if lspci | grep -i "VGA.*Intel" >/dev/null 2>&1; then sudo mkdir -p /etc/X11/xorg.conf.d/ sudo tee /etc/X11/xorg.conf.d/20-intel.conf > /dev/null <<'EOF' Section "Device" Identifier "Intel Graphics" Driver "intel" Option "AccelMethod" "sna" Option "TearFree" "true" Option "DRI" "3" EndSection EOF fi # SECURITY: Disable VT switching and dangerous X server key combinations 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 echo "[19/27] Configuring autologin..." configure_lightdm_autologin echo "[20/27] Configuring firewall..." sudo ufw --force enable sudo ufw default deny incoming sudo ufw default allow outgoing sudo ufw allow ssh echo "[21/27] Configuring power management..." sudo mkdir -p /etc/polkit-1/localauthority/50-local.d sudo tee /etc/polkit-1/localauthority/50-local.d/kiosk-power.pkla > /dev/null <<'EOF' [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 EOF echo "[22/27] Configuring volume controls..." sudo tee /usr/local/bin/kiosk-volume-up > /dev/null <<'EOF' #!/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}% EOF sudo chmod +x /usr/local/bin/kiosk-volume-up sudo tee /usr/local/bin/kiosk-volume-down > /dev/null <<'EOF' #!/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}% EOF sudo chmod +x /usr/local/bin/kiosk-volume-down echo "[23/27] Configuring hardware buttons with enhanced detection..." # Install evtest for debugging sudo apt install -y evtest 2>/dev/null || true # Get kiosk UID local kiosk_uid=$(id -u "$KIOSK_USER") # Create simple, reliable power button trigger script (runs as root from ACPI) sudo tee /usr/local/bin/kiosk-power-button.sh > /dev/null <<'PWREOF' #!/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" PWREOF sudo chmod +x /usr/local/bin/kiosk-power-button.sh # Also create the old location for backwards compatibility 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" # Create test script for debugging sudo tee /usr/local/bin/test-power-button > /dev/null <<'TESTEOF' #!/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" TESTEOF sudo chmod +x /usr/local/bin/test-power-button # Remove old configs sudo rm -f /etc/acpi/events/powerbtn* /etc/acpi/events/power* 2>/dev/null # Create ACPI event handler for power button # The script runs as root (from acpid) and sends SIGUSR1 to Electron sudo tee /etc/acpi/events/kiosk-power-button > /dev/null <<'EOF' event=button/power.* action=/usr/local/bin/kiosk-power-button.sh EOF # Also catch specific variants sudo tee /etc/acpi/events/kiosk-power-pbtn > /dev/null <<'EOF' event=button/power PBTN action=/usr/local/bin/kiosk-power-button.sh EOF sudo tee /etc/acpi/events/kiosk-power-pwr > /dev/null <<'EOF' event=button/power PWRF action=/usr/local/bin/kiosk-power-button.sh EOF # Configure systemd to ignore power button (let acpid handle it) sudo mkdir -p /etc/systemd/logind.conf.d sudo tee /etc/systemd/logind.conf.d/power-button.conf > /dev/null <<'EOF' [Login] HandlePowerKey=ignore HandlePowerKeyLongPress=poweroff HandleSuspendKey=ignore HandleHibernateKey=ignore HandleLidSwitch=ignore EOF # Reload everything sudo systemctl daemon-reload sudo systemctl restart systemd-logind sudo systemctl enable acpid sudo systemctl restart acpid # Give it time to start sleep 2 # Verify setup if systemctl is-active --quiet acpid; then log_success "Power button configured with enhanced detection" echo echo " Test command: sudo -u $KIOSK_USER $KIOSK_HOME/trigger-power-menu.sh" echo " Debug tool: test-power-button" echo " Watch events: sudo acpi_listen" else log_warning "acpid may not be running properly" echo " Check status: sudo systemctl status acpid" echo " View events: sudo journalctl -u acpid -n 50" fi echo "[25/27] WiFi configuration..." configure_wifi echo 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 # Update X11 config to disable VT switching sudo tee /etc/X11/xorg.conf.d/10-serverflags.conf > /dev/null <<'EOF' Section "ServerFlags" Option "DontZap" "true" Option "DontVTSwitch" "true" Option "AllowClosedownGrabs" "false" EndSection EOF log_success "Virtual consoles disabled" echo echo "You can re-enable them later from Advanced menu (option 7)" else # Update X11 config to enable VT switching sudo tee /etc/X11/xorg.conf.d/10-serverflags.conf > /dev/null <<'EOF' Section "ServerFlags" Option "DontZap" "true" Option "DontVTSwitch" "false" Option "AllowClosedownGrabs" "false" EndSection EOF 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" echo read -r -p "Reboot now? (y/n): " do_reboot if [[ ! "$do_reboot" =~ ^[Nn]$ ]]; then echo "Rebooting..." sleep 3 sudo reboot fi } ################################################################################ ### SECTION 9: ADDON FUNCTIONS - LMS/SQUEEZELITE ################################################################################ addon_lms_squeezelite() { while true; do clear echo "════════════════════════════════════════════════════════════" echo " LMS SERVER / SQUEEZELITE PLAYER " echo "════════════════════════════════════════════════════════════" echo local lms_installed=false local sq_installed=false if is_service_active logitechmediaserver || is_service_enabled logitechmediaserver || \ is_service_active lyrionmusicserver || is_service_enabled lyrionmusicserver; then lms_installed=true local lms_ip=$(get_ip_address) echo "LMS Server: ✓ Installed" if is_service_active logitechmediaserver || is_service_active lyrionmusicserver; then echo " Status: Running" else echo " Status: Stopped" fi echo " Web: http://${lms_ip}:9000" echo fi if is_service_active squeezelite || is_service_enabled squeezelite; then sq_installed=true local player_name="Unknown" if [[ -f /usr/local/bin/squeezelite-start.sh ]]; then player_name=$(grep '^PLAYER_NAME=' /usr/local/bin/squeezelite-start.sh 2>/dev/null | cut -d'=' -f2 | tr -d '"' || echo "Unknown") fi echo "Squeezelite Player: ✓ Installed" is_service_active squeezelite && echo " Status: Running" || echo " Status: Stopped" 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." echo echo "Options:" local menu_num=1 local -A menu_actions echo " ${menu_num}. Install/Configure LMS Server" menu_actions[$menu_num]="install_lms" ((menu_num++)) echo " ${menu_num}. Install/Configure Squeezelite Player" menu_actions[$menu_num]="install_squeezelite" ((menu_num++)) if $lms_installed; then echo " ${menu_num}. Uninstall LMS Server" menu_actions[$menu_num]="uninstall_lms" ((menu_num++)) fi if $sq_installed; then echo " ${menu_num}. Uninstall Squeezelite Player" menu_actions[$menu_num]="uninstall_squeezelite" ((menu_num++)) fi echo " 0. Return" echo local max_option=$((menu_num-1)) read -r -p "Choose [0-$max_option]: " choice if [[ "$choice" == "0" ]]; then return elif [[ -n "${menu_actions[$choice]:-}" ]]; then ${menu_actions[$choice]} else log_error "Invalid choice" sleep 1 fi done } install_lms() { echo if is_service_active logitechmediaserver || is_service_active lyrionmusicserver; then echo "LMS is already installed." read -r -p "Reconfigure port? (y/n): " reconfig if [[ "$reconfig" =~ ^[Yy]$ ]]; then read -r -p "New HTTP port [9000]: " new_port new_port="${new_port:-9000}" 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 log_success "LMS reconfigured on port $new_port" fi pause return fi echo "Installing Lyrion Music Server..." # Try 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 sudo apt update 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 # Fallback to direct download if repository failed 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 # Detect which service name to use # Detect which service name actually exists local service_name="" if systemctl list-unit-files | grep -q "lyrionmusicserver.service"; then service_name="lyrionmusicserver" elif systemctl list-unit-files | grep -q "logitechmediaserver.service"; then service_name="logitechmediaserver" else # Check what was actually installed log_warning "Service file not found, checking installed files..." service_name=$(dpkg -L lyrionmusicserver logitechmediaserver 2>/dev/null | grep -m1 '\.service$' | xargs 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" sudo systemctl enable "$service_name" 2>&1 | tee /tmp/lms-enable.log sudo systemctl start "$service_name" 2>&1 | tee /tmp/lms-start.log 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 local lms_ip=$(get_ip_address) log_success "LMS installed" echo " Web interface: http://${lms_ip}:9000" pause } uninstall_lms() { echo read -r -p "Remove LMS Server? (y/n): " confirm [[ ! "$confirm" =~ ^[Yy]$ ]] && return # Detect which service name is in use local service_name="" if systemctl list-unit-files | grep -q "lyrionmusicserver.service"; then service_name="lyrionmusicserver" elif systemctl list-unit-files | grep -q "logitechmediaserver.service"; then service_name="logitechmediaserver" fi 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 sudo apt remove -y lyrionmusicserver 2>/dev/null || true sudo apt remove -y logitechmediaserver 2>/dev/null || true # Clean up repository sudo rm -f /etc/apt/sources.list.d/lms.list sudo rm -f /usr/share/keyrings/lms-keyring.gpg # Remove config/data (optional - ask user) read -r -p "Remove LMS data and configuration? (y/n): " remove_data if [[ "$remove_data" =~ ^[Yy]$ ]]; 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 pause } install_squeezelite() { echo if is_service_active squeezelite; then echo "Squeezelite is already installed." read -r -p "Reconfigure? (y/n): " reconfig [[ ! "$reconfig" =~ ^[Yy]$ ]] && { pause; return; } fi if ! command -v squeezelite &>/dev/null; then sudo apt install -y squeezelite fi read -r -p "Player name [Kiosk]: " player_name player_name="${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 read -r -p "LMS Server (e.g., 192.168.1.100:3483): " lms_server sudo tee /usr/local/bin/squeezelite-start.sh > /dev/null </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 /usr/local/bin/squeezelite-start.sh local kiosk_uid=$(id -u "$KIOSK_USER") sudo tee /etc/systemd/system/squeezelite.service > /dev/null </dev/null | grep -q "^ii.*cups\s"; then cups_installed=true fi if $cups_installed && systemctl is-active --quiet cups; then local cups_ip=$(get_ip_address) echo "Status: ✓ Installed and running" echo " Admin interface: http://${cups_ip}:631" echo echo "Options:" echo " 1. Keep as-is" echo " 2. Reconfigure for network access" echo " 3. Complete uninstall (purge)" echo " 0. Return" echo read -r -p "Choose [0-3]: " action case "$action" in 2) reconfigure_cups ;; 3) complete_cups_uninstall ;; 0) return ;; *) log_success "Keeping CUPS"; pause ;; esac elif $cups_installed; then echo "Status: ⚠ Installed but not running" echo echo "Options:" echo " 1. Start CUPS" echo " 2. Complete uninstall (purge)" echo " 0. Return" echo read -r -p "Choose [0-2]: " action case "$action" in 1) sudo systemctl enable cups sudo systemctl start cups log_success "CUPS started" pause ;; 2) complete_cups_uninstall ;; 0) return ;; esac else echo "Status: Not installed" echo read -r -p "Install CUPS printing? (y/N): " install if [[ "$install" =~ ^[Yy]$ ]]; then install_cups_fresh fi pause fi } complete_cups_uninstall() { echo 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 /etc/polkit-1/localauthority/50-local.d/kiosk-printing.pkla sudo apt autoremove -y sudo apt clean log_success "CUPS completely removed" pause } install_cups_fresh() { echo echo "Installing CUPS from scratch..." sudo apt update 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 sudo systemctl enable cups sudo systemctl start cups echo "Waiting for CUPS to start..." for i in {1..30}; do if systemctl is-active --quiet cups && lpstat -r &>/dev/null 2>&1; then break fi sleep 1 done sudo usermod -aG lpadmin "$BUILD_USER" [[ -n "${SUDO_USER:-}" ]] && sudo usermod -aG lpadmin "$SUDO_USER" 2>/dev/null || true reconfigure_cups local cups_ip=$(get_ip_address) log_success "CUPS installed" echo " Web interface: http://${cups_ip}:631" } reconfigure_cups() { [[ -f /etc/cups/cupsd.conf ]] && sudo cp /etc/cups/cupsd.conf /etc/cups/cupsd.conf.backup-$(date +%Y%m%d-%H%M%S) 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 sudo sed -i 's/^Listen 127.0.0.1:631/Port 631/' /etc/cups/cupsd.conf 2>/dev/null sudo tee /etc/polkit-1/localauthority/50-local.d/kiosk-printing.pkla > /dev/null <<'EOF' [Allow kiosk printing] Identity=unix-user:kiosk 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 log_success "CUPS configured for network access" pause } ################################################################################ ### SECTION 12: ADDON - VNC ################################################################################ addon_vnc() { clear echo "════════════════════════════════════════════════════════════" echo " VNC REMOTE DESKTOP " echo "════════════════════════════════════════════════════════════" echo if is_service_active x11vnc; then echo "Status: ✓ Running" local vnc_ip=$(get_ip_address) echo " Connect: ${vnc_ip}:5900" echo echo "Options:" echo " 1. Keep as-is" echo " 2. Reconfigure password" echo " 3. Uninstall" echo " 0. Return" echo read -r -p "Choose [0-3]: " action case "$action" in 2) echo read -r -s -p "New VNC password: " vnc_pass echo sudo -u "$KIOSK_USER" x11vnc -storepasswd "$vnc_pass" "$KIOSK_HOME/.vnc/passwd" sudo systemctl restart x11vnc log_success "VNC password updated" pause ;; 3) echo read -r -p "Remove VNC? (y/n): " confirm if [[ "$confirm" =~ ^[Yy]$ ]]; then sudo systemctl stop x11vnc sudo systemctl disable x11vnc sudo rm -f /etc/systemd/system/x11vnc.service sudo apt remove -y x11vnc log_success "VNC removed" fi pause ;; 0) return ;; *) log_success "Keeping VNC"; pause ;; esac else echo "Status: Not installed" read -r -p "Install x11vnc? (y/n): " install if [[ "$install" =~ ^[Yy]$ ]]; then sudo apt install -y x11vnc read -r -s -p "VNC password: " vnc_pass echo sudo -u "$KIOSK_USER" mkdir -p "$KIOSK_HOME/.vnc" sudo -u "$KIOSK_USER" x11vnc -storepasswd "$vnc_pass" "$KIOSK_HOME/.vnc/passwd" sudo tee /etc/systemd/system/x11vnc.service > /dev/null </dev/null || true local vnc_ip=$(get_ip_address) log_success "VNC installed" echo " Connect: ${vnc_ip}:5900" fi pause fi } ################################################################################ ### SECTION 13: ADDON - VPNs (with setup key support) ################################################################################ addon_wireguard() { clear echo "════════════════════════════════════════════════════════════" echo " WIREGUARD VPN " echo "════════════════════════════════════════════════════════════" echo if command -v wg &>/dev/null && sudo wg show 2>/dev/null | grep -q interface; then echo "Status: ✓ Connected" echo sudo wg show | grep -E "interface:|endpoint:|allowed ips:" | sed 's/^/ /' echo echo "Options:" echo " 1. Keep as-is" echo " 2. Show full config" echo " 3. Paste new config" echo " 4. Uninstall" echo " 0. Return" echo read -r -p "Choose [0-5]: " action case "$action" in 2) sudo wg show all; pause ;; 3) configure_wireguard_paste ;; 4) read -r -p "Remove WireGuard? (y/n): " confirm if [[ "$confirm" =~ ^[Yy]$ ]]; then 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 log_success "WireGuard removed" fi pause ;; 0) return ;; *) pause ;; esac else echo "Status: Not installed" echo read -r -p "Install WireGuard? (y/n): " install if [[ "$install" =~ ^[Yy]$ ]]; then sudo apt install -y wireguard wireguard-tools log_success "WireGuard installed" echo echo "Options:" echo " 1. Paste config now" echo " 2. Configure later" read -r -p "Choose [1-2]: " config_choice [[ "$config_choice" == "1" ]] && configure_wireguard_paste fi pause fi } configure_wireguard_paste() { echo echo "Paste your WireGuard config (Ctrl+D when done):" local config=$(cat) if [[ -z "$config" ]]; then log_error "No config provided" return fi read -r -p "Config name [wg0]: " wg_name wg_name="${wg_name:-wg0}" echo "$config" | sudo tee "/etc/wireguard/${wg_name}.conf" > /dev/null sudo chmod 600 "/etc/wireguard/${wg_name}.conf" sudo systemctl enable "wg-quick@${wg_name}" sudo systemctl start "wg-quick@${wg_name}" log_success "WireGuard configured: $wg_name" pause } addon_tailscale() { clear echo "════════════════════════════════════════════════════════════" echo " TAILSCALE VPN " echo "════════════════════════════════════════════════════════════" echo if command -v tailscale &>/dev/null; then local ts_status=$(tailscale status --json 2>/dev/null | jq -r '.BackendState' 2>/dev/null || echo "unknown") if [[ "$ts_status" == "Running" ]]; then echo "Status: ✓ Connected" local ts_ip=$(tailscale ip -4 2>/dev/null) local ts_name=$(tailscale status --json 2>/dev/null | jq -r '.Self.HostName' 2>/dev/null) echo " Hostname: $ts_name" echo " IP: $ts_ip" echo else echo "Status: ✓ Installed, not connected" echo fi echo "Options:" echo " 1. Keep as-is" echo " 2. Connect (interactive)" echo " 3. Connect with auth key" echo " 4. Show status" echo " 5. Uninstall" echo " 0. Return" echo read -r -p "Choose [0-5]: " action case "$action" in 2) echo sudo tailscale up log_success "Tailscale connected" pause ;; 3) echo echo "Get auth key from: https://login.tailscale.com/admin/settings/keys" read -r -p "Enter auth key: " authkey if [[ -n "$authkey" ]]; then sudo tailscale up --authkey="$authkey" log_success "Tailscale connected" fi pause ;; 4) tailscale status; pause ;; 5) read -r -p "Remove Tailscale? (y/n): " confirm if [[ "$confirm" =~ ^[Yy]$ ]]; then sudo tailscale down sudo apt remove -y tailscale log_success "Tailscale removed" fi pause ;; 0) return ;; *) pause ;; esac else echo "Status: Not installed" echo read -r -p "Install Tailscale? (y/n): " install if [[ "$install" =~ ^[Yy]$ ]]; then curl -fsSL https://tailscale.com/install.sh | sh log_success "Tailscale installed" echo echo "Options:" echo " 1. Connect now (interactive)" echo " 2. Connect with auth key" echo " 3. Connect later" read -r -p "Choose [1-3]: " connect_choice case "$connect_choice" in 1) sudo tailscale up ;; 2) echo "Get auth key from: https://login.tailscale.com/admin/settings/keys" read -r -p "Enter auth key: " authkey [[ -n "$authkey" ]] && sudo tailscale up --authkey="$authkey" ;; esac fi pause fi } addon_netbird() { clear echo "════════════════════════════════════════════════════════════" echo " NETBIRD VPN " echo "════════════════════════════════════════════════════════════" echo if command -v netbird &>/dev/null; then local nb_status=$(netbird status 2>/dev/null | grep "Status:" | awk '{print $2}') if [[ "$nb_status" == "Connected" ]]; then echo "Status: ✓ Connected" netbird status | grep -E "NetBird IP:|Public key:" | sed 's/^/ /' echo else echo "Status: ✓ Installed, not connected" echo fi echo "Options:" echo " 1. Keep as-is" echo " 2. Connect with setup key" echo " 3. Show status" echo " 4. Uninstall" echo " 0. Return" echo read -r -p "Choose [0-5]: " action case "$action" in 2) echo echo "Get setup key from Netbird dashboard" read -r -p "Enter setup key: " setup_key if [[ -n "$setup_key" ]]; then sudo netbird up --setup-key "$setup_key" log_success "Netbird connected" fi pause ;; 3) netbird status; pause ;; 4) read -r -p "Remove Netbird? (y/n): " confirm if [[ "$confirm" =~ ^[Yy]$ ]]; then sudo netbird down sudo apt remove -y netbird log_success "Netbird removed" fi pause ;; 0) return ;; *) pause ;; esac else echo "Status: Not installed" echo read -r -p "Install Netbird? (y/n): " install if [[ "$install" =~ ^[Yy]$ ]]; then curl -fsSL https://pkgs.netbird.io/install.sh | sh log_success "Netbird installed" echo read -r -p "Connect with setup key now? (y/n): " do_connect if [[ "$do_connect" =~ ^[Yy]$ ]]; then echo "Get setup key from Netbird dashboard" read -r -p "Enter setup key: " setup_key [[ -n "$setup_key" ]] && sudo netbird up --setup-key "$setup_key" fi fi pause fi } ################################################################################ ### SECTION 14: ADDON - HTML ON-SCREEN KEYBOARD ################################################################################ addon_onscreen_keyboard() { clear echo "════════════════════════════════════════════════════════════" echo " HTML ON-SCREEN KEYBOARD " echo "════════════════════════════════════════════════════════════" echo local keyboard_installed=false local auto_show_enabled=false if [[ -f "$KIOSK_DIR/keyboard.html" ]]; then keyboard_installed=true echo "Status: ✓ Installed" # Check if auto-show is enabled if sudo grep -q "Auto-shows on text fields" "$KIOSK_DIR/preload.js" 2>/dev/null; then auto_show_enabled=true echo " Mode: Auto-show on text fields" else echo " Mode: Manual (3-finger tap or icon)" fi else echo "Status: Not installed" fi echo echo "Options:" if ! $keyboard_installed; then echo " 1. Install HTML Keyboard" echo " 0. Return" echo read -r -p "Choose [0-1]: " choice case "$choice" in 1) install_html_keyboard ;; 0) return ;; esac else echo " 1. Toggle auto-show (currently: $([ $auto_show_enabled = true ] && echo 'enabled' || echo 'disabled'))" echo " 2. Test keyboard" echo " 3. View keyboard logs" echo " 4. SSH Credentials Helper" echo " 5. Uninstall" echo " 0. Return" echo read -r -p "Choose [0-5]: " choice case "$choice" in 1) toggle_keyboard_autoshow ;; 2) test_html_keyboard ;; 3) view_keyboard_logs ;; 4) ssh_credentials_helper ;; 5) uninstall_html_keyboard ;; 0) return ;; esac fi } install_html_keyboard() { echo echo "Installing HTML On-Screen Keyboard..." echo # Create keyboard.html sudo -u "$KIOSK_USER" tee "$KIOSK_DIR/keyboard.html" > /dev/null <<'KBHTML'
×
⌨️ Keyboard - 2-finger swipe down to reopen
1
2
3
4
5
6
7
8
9
0
-
=
Tab
q
w
e
r
t
y
u
i
o
p
[
]
\
Caps
a
s
d
f
g
h
j
k
l
;
'
z
x
c
v
b
n
m
,
.
/
Ctrl
Alt
Space
Alt
Ctrl
KBHTML sudo chown "$KIOSK_USER:$KIOSK_USER" "$KIOSK_DIR/keyboard.html" log_success "keyboard.html created" # Update keyboard button to show only when keyboard visible sudo -u "$KIOSK_USER" tee "$KIOSK_DIR/keyboard-button.html" > /dev/null <<'BTNHTML' BTNHTML sudo chown "$KIOSK_USER:$KIOSK_USER" "$KIOSK_DIR/keyboard-button.html" log_success "keyboard-button.html created" # Ask about auto-show echo echo "Keyboard Mode:" echo " 1. Auto-show on text fields (recommended)" echo " 2. Manual only (3-finger tap)" echo read -r -p "Choose mode [1]: " kb_mode kb_mode="${kb_mode:-1}" if [[ "$kb_mode" == "1" ]]; then enable_keyboard_autoshow else disable_keyboard_autoshow fi # Update main.js update_mainjs_keyboard log_success "HTML Keyboard installed" echo echo "Restart kiosk to activate: Main Menu → Option 4" pause } update_mainjs_keyboard() { local mainjs="$KIOSK_DIR/main.js" # Backup sudo cp "$mainjs" "${mainjs}.backup-htmlkb-$(date +%Y%m%d-%H%M%S)" # Add keyboard variables if not exists if ! sudo grep -q "let htmlKeyboardWindow" "$mainjs"; then sudo sed -i '/let keyboardWindow=null;/a let htmlKeyboardWindow=null;let keyboardButtonCheckInterval=null;' "$mainjs" fi # Add keyboard functions local tmpfunc=$(mktemp) cat > "$tmpfunc" <<'KBFUNC' function showHTMLKeyboard(){ if(htmlKeyboardWindow&&!htmlKeyboardWindow.isDestroyed()){ htmlKeyboardWindow.focus(); return; } const{width,height}=mainWindow.getBounds(); const kbHeight=Math.floor(height*0.4); const kbY=height-kbHeight; htmlKeyboardWindow=new BrowserWindow({ width:width, height:kbHeight, x:0, y:kbY, frame:false, alwaysOnTop:true, skipTaskbar:true, webPreferences:{nodeIntegration:true,contextIsolation:false} }); htmlKeyboardWindow.loadFile(path.join(__dirname,'keyboard.html')); htmlKeyboardWindow.on('closed',()=>{ htmlKeyboardWindow=null; }); // Focus first input field after keyboard shows setTimeout(()=>{ let view=null; if(showingHidden&&hiddenViews[currentHiddenIndex]){ view=hiddenViews[currentHiddenIndex]; }else if(views[currentIndex]){ view=views[currentIndex]; } if(view&&view.webContents){ view.webContents.executeJavaScript(` (function(){ const inputs=document.querySelectorAll('input[type="text"],input[type="email"],input[type="password"],input[type="search"],input[type="tel"],input[type="url"],input[type="number"],textarea'); if(inputs.length>0){ inputs[0].focus(); inputs[0].scrollIntoView({behavior:'smooth',block:'center'}); console.log('[KB] Focused first input field'); return true; } return false; })(); `).catch(e=>console.error('[KB] Focus error:',e)); } },300); console.log('[KEYBOARD] HTML keyboard shown'); } function closeHTMLKeyboard(){ if(!keyboardIsOpen){ return; } const wasAutoClosed=keyboardClosePending; if(htmlKeyboardWindow&&!htmlKeyboardWindow.isDestroyed()){ htmlKeyboardWindow.close(); } htmlKeyboardWindow=null; keyboardIsOpen=false; keyboardClosePending=false; notifyKeyboardState(false); // Notify all views if this was an auto-close if(wasAutoClosed){ console.log('[KB] Auto-closed - notifying views'); const allViews=[...views,...hiddenViews]; allViews.forEach(view=>{ if(view&&view.webContents){ view.webContents.send('keyboard-auto-closed'); } }); } if(mainWindow&&!mainWindow.isDestroyed()){ mainWindow.focus(); } console.log('[KB] Closed'+(wasAutoClosed?' (auto)':' (manual)')); } function toggleHTMLKeyboard(){ if(htmlKeyboardWindow&&!htmlKeyboardWindow.isDestroyed()){ closeHTMLKeyboard(); }else{ showHTMLKeyboard(); } } function toggleKeyboard(){ toggleHTMLKeyboard(); } KBFUNC sudo sed -i '/^function createWindow(){/e cat '"$tmpfunc" "$mainjs" rm "$tmpfunc" # Add keyboard button persistence if ! sudo grep -q "keyboardButtonCheckInterval=setInterval" "$mainjs"; then sudo sed -i '/createKeyboardButton();/a\ keyboardButtonCheckInterval=setInterval(()=>{if(!keyboardWindow||keyboardWindow.isDestroyed()){createKeyboardButton();}},3000);' "$mainjs" fi # Add IPC handlers if ! sudo grep -q "ipcMain.on('keyboard-type'" "$mainjs"; then sudo sed -i "/ipcMain.on('toggle-keyboard',toggleKeyboard);/a\ ipcMain.on('keyboard-type',(event,key)=> if(!mainWindow||mainWindow.isDestroyed())return;\ const view=mainWindow.getTopBrowserView();\ if(view){\ view.webContents.sendInputEvent({type:'keyDown',keyCode:key});\ view.webContents.sendInputEvent({type:'char',keyCode:key});\ view.webContents.sendInputEvent({type:'keyUp',keyCode:key});\ markActivity();\ }\ });\ ipcMain.on('close-keyboard',()=>{closeHTMLKeyboard();});\ ipcMain.on('show-keyboard',()=>{if(!htmlKeyboardWindow||htmlKeyboardWindow.isDestroyed()){showHTMLKeyboard();}});\ ipcMain.on('hide-keyboard',()=>{closeHTMLKeyboard();});" "$mainjs" fi sudo chown "$KIOSK_USER:$KIOSK_USER" "$mainjs" log_success "main.js updated" } enable_keyboard_autoshow() { sudo cp "$KIOSK_DIR/preload.js" "$KIOSK_DIR/preload.js.backup-autoshow-$(date +%Y%m%d-%H%M%S)" disable_keyboard_autoshow() { sudo cp "$KIOSK_DIR/preload.js" "$KIOSK_DIR/preload.js.backup-manual-$(date +%Y%m%d-%H%M%S)" # Set autoShowEnabled to false in the preload.js sudo sed -i 's/let autoShowEnabled = true/let autoShowEnabled = false/' "$KIOSK_DIR/preload.js" sudo chown "$KIOSK_USER:$KIOSK_USER" "$KIOSK_DIR/preload.js" log_success "Auto-show disabled" } sudo -u "$KIOSK_USER" tee "$KIOSK_DIR/preload.js" > /dev/null <<'PRELOAD' 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') }); 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; // Track keyboard state globally let keyboardVisible = false; let lastKeyboardRequest = 0; // Listen for keyboard state changes from main process ipcRenderer.on('keyboard-visible', (event, visible) => { keyboardVisible = visible; console.log(`[KEYBOARD] State: ${visible ? 'visible' : 'hidden'}`); }); // 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 && absX0){ console.log('[TOUCH] 3-finger DOWN - toggle hidden tabs'); ipcRenderer.send('toggle-hidden'); }else if(fingerCount===2 && absY>SWIPE_THRESHOLD && absX0){ console.log('[TOUCH] 2-finger DOWN - toggle keyboard'); ipcRenderer.send('show-keyboard'); lastKeyboardRequest=Date.now(); }else if(fingerCount===2 && absX>SWIPE_THRESHOLD && absY0?'swipe-right':'swipe-left'); }else if(fingerCount===1 && absX>SWIPE_THRESHOLD && absY0?'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 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&&absX0){ console.log('[TOUCH] 3-finger DOWN (ptr) - toggle hidden tabs'); ipcRenderer.send('toggle-hidden'); }else if(ptrPeak===2&&absY>SWIPE_THRESHOLD&&absX0){ console.log('[TOUCH] 2-finger DOWN (ptr) - toggle keyboard'); ipcRenderer.send('show-keyboard'); lastKeyboardRequest=Date.now(); }else if(ptrPeak===2&&absX>SWIPE_THRESHOLD&&absY0?'swipe-right':'swipe-left'); }else if(ptrPeak===1&&absX>SWIPE_THRESHOLD&&absY0?'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}); // Optional: Auto-show on text field focus (can be disabled) let autoShowEnabled = true; // Set to false to disable auto-show if (autoShowEnabled) { function isTextInput(el){ if(!el)return false; const tag=(el.tagName||'').toLowerCase(); const type=(el.type||'').toLowerCase(); const editable=el.isContentEditable===true||el.contentEditable==='true'; const isInput=tag==='input'&&['text','email','password','search','tel','url','number'].includes(type); const isTextArea=tag==='textarea'; return isInput||isTextArea||editable; } document.addEventListener('focusin',(e)=>{ if(isTextInput(e.target) && !keyboardVisible){ const now = Date.now(); if (now - lastKeyboardRequest > 1000) { console.log('[AUTO-KB] Text field focused - showing keyboard'); ipcRenderer.send('show-keyboard'); lastKeyboardRequest = now; } } },true); } }); PRELOAD sudo chown "$KIOSK_USER:$KIOSK_USER" "$KIOSK_DIR/preload.js" log_success "Manual mode enabled" } toggle_keyboard_autoshow() { if sudo grep -q "Auto-shows on text fields" "$KIOSK_DIR/preload.js" 2>/dev/null; then echo echo "Disabling auto-show..." disable_keyboard_autoshow echo "Keyboard will now only show via 3-finger tap" else echo echo "Enabling auto-show..." enable_keyboard_autoshow echo "Keyboard will auto-show on text fields" fi echo echo "Restart kiosk for changes: Main Menu → Option 4" pause } test_html_keyboard() { echo echo "Testing keyboard..." echo "The keyboard should appear when you restart the kiosk" echo "and tap on any text field (if auto-show enabled)" echo echo "Or use 3-finger tap to test manually" pause } view_keyboard_logs() { echo echo "Recent keyboard activity:" echo "─────────────────────────────────────────────" sudo tail -50 /home/kiosk/electron.log 2>/dev/null | grep -i "keyboard\|AUTO-SHOW\|3-finger TAP" || echo "No keyboard logs found" pause } ssh_credentials_helper() { clear echo "════════════════════════════════════════════════════════════" echo " SSH CREDENTIALS HELPER " echo "════════════════════════════════════════════════════════════" echo echo "This helps you paste complex passwords from SSH" echo local ip=$(get_ip_address) echo "Current IP: $ip" echo if systemctl is-active --quiet ssh; then echo "SSH: ✓ Running" echo echo "To use:" echo " 1. SSH into kiosk: ssh $(whoami)@$ip" echo " 2. Copy your password: echo 'your-complex-password'" echo " 3. Paste into website manually" echo echo "Or for auto-type (advanced):" echo " ssh $(whoami)@$ip 'export DISPLAY=:0; xdotool type \"password\"'" else echo "SSH: ✗ Not running" echo read -r -p "Enable SSH? (y/n): " enable_ssh if [[ "$enable_ssh" =~ ^[Yy]$ ]]; then sudo systemctl enable ssh sudo systemctl start ssh sudo ufw allow 22/tcp comment 'SSH' 2>/dev/null || true echo "✓ SSH enabled" echo echo "Connect: ssh $(whoami)@$ip" fi fi pause } uninstall_html_keyboard() { echo read -r -p "Remove HTML Keyboard? (y/n): " confirm [[ ! "$confirm" =~ ^[Yy]$ ]] && return sudo rm -f "$KIOSK_DIR/keyboard.html" sudo rm -f "$KIOSK_DIR/keyboard-button.html" # Keyboard code remains in main.js (harmless) echo echo "Note: Keyboard code remains in main.js (inactive)" echo "Run Core Settings → Full Reinstall for complete cleanup" log_success "HTML Keyboard removed" pause } ################################################################################ ### SECTION 14.5: ADDON - EASY ASTERISK INTERCOM ################################################################################ # GitHub repository details for Easy Asterisk EASY_ASTERISK_REPO="outis1one/easy-asterisk" EASY_ASTERISK_RAW_URL="https://raw.githubusercontent.com/${EASY_ASTERISK_REPO}/main" EASY_ASTERISK_API_URL="https://api.github.com/repos/${EASY_ASTERISK_REPO}/contents" # Local installation paths EASY_ASTERISK_INSTALL_DIR="/opt/easy-asterisk" EASY_ASTERISK_VERSION_FILE="${EASY_ASTERISK_INSTALL_DIR}/.version" EASY_ASTERISK_CONFIG_BACKUP="${EASY_ASTERISK_INSTALL_DIR}/config_backup" get_latest_easy_asterisk_version() { # Try to get file list from GitHub API local files_json=$(curl -s "${EASY_ASTERISK_API_URL}" 2>/dev/null) if [ $? -ne 0 ] || [ -z "$files_json" ]; then return 1 fi # Extract easy-asterisk-v*.sh files and find the latest version # Support both 3-part (1.2.3) and 4-part (0.9.8.7) version numbers local latest_version=$(echo "$files_json" | grep -oP 'easy-asterisk-v\K[0-9]+\.[0-9]+\.[0-9]+(\.[0-9]+)?(?=\.sh)' | sort -V | tail -1) if [ -z "$latest_version" ]; then return 1 fi echo "$latest_version" return 0 } get_installed_easy_asterisk_version() { if [ -f "$EASY_ASTERISK_VERSION_FILE" ]; then cat "$EASY_ASTERISK_VERSION_FILE" return 0 fi echo "" return 1 } backup_easy_asterisk_configs() { if [ ! -d "$EASY_ASTERISK_INSTALL_DIR" ]; then return 0 fi # Create backup directory with timestamp local backup_dir="${EASY_ASTERISK_CONFIG_BACKUP}/$(date +%Y%m%d_%H%M%S)" mkdir -p "$backup_dir" # Backup common config directories and files for item in config etc *.conf *.cfg; do if [ -e "${EASY_ASTERISK_INSTALL_DIR}/${item}" ]; then cp -r "${EASY_ASTERISK_INSTALL_DIR}/${item}" "$backup_dir/" 2>/dev/null || true fi done # Also backup Asterisk configs if they exist if [ -d "/etc/asterisk" ]; then mkdir -p "${backup_dir}/asterisk_etc" cp -r /etc/asterisk/*.conf "${backup_dir}/asterisk_etc/" 2>/dev/null || true fi log_success "Configuration backup created" return 0 } restore_easy_asterisk_configs() { local backup_dir="$1" if [ -z "$backup_dir" ] || [ ! -d "$backup_dir" ]; then return 0 fi # Restore backed up items for item in $(ls -A "$backup_dir" 2>/dev/null); do if [ "$item" != "asterisk_etc" ]; then cp -r "${backup_dir}/${item}" "${EASY_ASTERISK_INSTALL_DIR}/" 2>/dev/null || true fi done # Restore Asterisk configs if they were backed up if [ -d "${backup_dir}/asterisk_etc" ]; then cp -r "${backup_dir}/asterisk_etc"/*.conf /etc/asterisk/ 2>/dev/null || true fi log_success "Configuration restored" return 0 } download_and_install_easy_asterisk() { local version="$1" local script_name="easy-asterisk-v${version}.sh" local script_url="${EASY_ASTERISK_RAW_URL}/${script_name}" local temp_script="/tmp/${script_name}" echo "Downloading Easy Asterisk v${version}..." # Download the installation script if ! curl -fsSL "$script_url" -o "$temp_script"; then log_error "Failed to download Easy Asterisk installation script" log_error "URL: $script_url" return 1 fi # Verify the script was downloaded if [ ! -f "$temp_script" ] || [ ! -s "$temp_script" ]; then log_error "Downloaded script is empty or missing" return 1 fi # Make script executable chmod +x "$temp_script" echo "Running Easy Asterisk installation script..." echo "This may take several minutes..." echo # Run the installation script if bash "$temp_script"; then log_success "Easy Asterisk installation completed" # Save version information mkdir -p "$EASY_ASTERISK_INSTALL_DIR" echo "$version" > "$EASY_ASTERISK_VERSION_FILE" # Clean up temp file rm -f "$temp_script" return 0 else log_error "Easy Asterisk installation failed" rm -f "$temp_script" return 1 fi } # Client installation paths BARESIP_CONFIG_DIR="/home/${KIOSK_USER}/.baresip" BARESIP_CLIENT_VERSION_FILE="/home/${KIOSK_USER}/.baresip/.client_version" # Check if Baresip client is installed is_baresip_client_installed() { if [ -f "$BARESIP_CLIENT_VERSION_FILE" ] && command -v baresip &>/dev/null; then return 0 fi return 1 } # Get installed client version get_installed_client_version() { if [ -f "$BARESIP_CLIENT_VERSION_FILE" ]; then cat "$BARESIP_CLIENT_VERSION_FILE" return 0 fi echo "" return 1 } # Install Baresip SIP client packages install_baresip_packages() { echo "Installing Baresip SIP client..." # Update package lists apt-get update -qq # Install baresip and dependencies if ! apt-get install -y baresip 2>/dev/null; then log_error "Failed to install baresip package" return 1 fi # Install audio dependencies apt-get install -y pulseaudio-utils pipewire-pulse 2>/dev/null || true log_success "Baresip packages installed" return 0 } # Configure Baresip client configure_baresip_client() { local server_ip="$1" local server_port="$2" local extension="$3" local password="$4" local auto_answer="$5" local use_tls="$6" # Determine transport and answer mode local transport="udp" local answermode="manual" local media_enc="" if [ "$use_tls" = "yes" ]; then transport="tls" media_enc=";mediaenc=srtp" fi if [ "$auto_answer" = "yes" ]; then answermode="auto" fi # Create baresip config directory mkdir -p "$BARESIP_CONFIG_DIR" chown -R "${KIOSK_USER}:${KIOSK_USER}" "$BARESIP_CONFIG_DIR" # Create accounts file cat > "${BARESIP_CONFIG_DIR}/accounts" << EOF ;auth_pass=${password};answermode=${answermode}${media_enc} EOF # Create basic config file if it doesn't exist if [ ! -f "${BARESIP_CONFIG_DIR}/config" ]; then cat > "${BARESIP_CONFIG_DIR}/config" << 'EOF' # Baresip configuration for Easy 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 EOF fi chown -R "${KIOSK_USER}:${KIOSK_USER}" "$BARESIP_CONFIG_DIR" chmod 600 "${BARESIP_CONFIG_DIR}/accounts" log_success "Baresip client configured" return 0 } # Create systemd user service for Baresip create_baresip_service() { local user_service_dir="/home/${KIOSK_USER}/.config/systemd/user" # Create directory mkdir -p "$user_service_dir" # Create service file cat > "${user_service_dir}/baresip.service" << 'EOF' [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 EOF chown -R "${KIOSK_USER}:${KIOSK_USER}" "/home/${KIOSK_USER}/.config" # Enable and start the service as the kiosk user local kiosk_uid=$(id -u "$KIOSK_USER") local user_dbus="DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/${kiosk_uid}/bus" # Reload systemd user daemon and enable service sudo -u "$KIOSK_USER" XDG_RUNTIME_DIR="/run/user/${kiosk_uid}" $user_dbus systemctl --user daemon-reload 2>/dev/null || true sudo -u "$KIOSK_USER" XDG_RUNTIME_DIR="/run/user/${kiosk_uid}" $user_dbus systemctl --user enable baresip.service 2>/dev/null || true log_success "Baresip systemd service created" return 0 } # Start Baresip service start_baresip_service() { local kiosk_uid=$(id -u "$KIOSK_USER") local user_dbus="DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/${kiosk_uid}/bus" sudo -u "$KIOSK_USER" XDG_RUNTIME_DIR="/run/user/${kiosk_uid}" $user_dbus systemctl --user start baresip.service 2>/dev/null # Wait a moment and check status sleep 2 if sudo -u "$KIOSK_USER" XDG_RUNTIME_DIR="/run/user/${kiosk_uid}" $user_dbus systemctl --user is-active baresip.service &>/dev/null; then log_success "Baresip service started" return 0 else log_warning "Baresip service may not have started (will start on next login)" return 0 fi } # Client-only installation install_easy_asterisk_client() { clear echo "════════════════════════════════════════════════════════════" echo " EASY ASTERISK CLIENT INSTALLATION " echo "════════════════════════════════════════════════════════════" echo echo "This will install the Baresip SIP client to connect to an" echo "existing Easy Asterisk server." echo # Check if already installed if is_baresip_client_installed; then local installed_ver=$(get_installed_client_version) echo "Baresip client is already installed (v${installed_ver})" echo read -r -p "Do you want to reconfigure it? (y/N): " reconf_choice if [[ ! "$reconf_choice" =~ ^[Yy]$ ]]; then echo "Configuration cancelled" pause return 0 fi fi echo "Please enter your Easy Asterisk server details:" echo "(These should match what was configured on the server)" echo # Get server IP/hostname local server_ip="" while [ -z "$server_ip" ]; do read -r -p "Server IP or hostname: " server_ip if [ -z "$server_ip" ]; then log_error "Server address is required" fi done # Get server port local server_port="" read -r -p "Server port [5060]: " server_port server_port="${server_port:-5060}" # Get extension local extension="" while [ -z "$extension" ]; do read -r -p "Extension number (e.g., 201): " extension if [ -z "$extension" ]; then log_error "Extension is required" fi done # Get password local password="" while [ -z "$password" ]; do read -r -s -p "SIP Password: " password echo if [ -z "$password" ]; then log_error "Password is required" fi done # Auto-answer mode echo echo "Answer mode:" echo " 1. Manual - Phone will ring, requires user to answer" echo " 2. Auto - Automatically answers incoming calls (intercom mode)" local answer_choice="" read -r -p "Select answer mode [1]: " answer_choice local auto_answer="no" if [ "$answer_choice" = "2" ]; then auto_answer="yes" fi # TLS option echo read -r -p "Use TLS encryption? (y/N): " tls_choice local use_tls="no" if [[ "$tls_choice" =~ ^[Yy]$ ]]; then use_tls="yes" 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: $([ "$auto_answer" = "yes" ] && echo "Auto" || echo "Manual")" echo " TLS: $([ "$use_tls" = "yes" ] && echo "Yes" || echo "No")" echo read -r -p "Proceed with installation? (Y/n): " proceed if [[ "$proceed" =~ ^[Nn]$ ]]; then echo "Installation cancelled" pause return 0 fi echo echo "Installing Baresip client..." # Install packages if ! install_baresip_packages; then log_error "Failed to install Baresip packages" pause return 1 fi # Configure client if ! configure_baresip_client "$server_ip" "$server_port" "$extension" "$password" "$auto_answer" "$use_tls"; then log_error "Failed to configure Baresip client" pause return 1 fi # Create systemd service if ! create_baresip_service; then log_error "Failed to create Baresip service" pause return 1 fi # Save version info local latest_version=$(get_latest_easy_asterisk_version) echo "${latest_version:-1.0.0}-client" > "$BARESIP_CLIENT_VERSION_FILE" chown "${KIOSK_USER}:${KIOSK_USER}" "$BARESIP_CLIENT_VERSION_FILE" # Try to start the service start_baresip_service echo log_success "Easy Asterisk Client installed successfully!" echo echo "Client Configuration:" echo " Config dir: ${BARESIP_CONFIG_DIR}" echo " Server: ${server_ip}:${server_port}" echo " Extension: ${extension}" echo echo "Management commands:" echo " Check status: systemctl --user status baresip" echo " Restart: systemctl --user restart baresip" echo " View logs: journalctl --user -u baresip -f" echo echo "Note: The client will auto-start when the user logs in." pause return 0 } # Server-only installation (launches the Easy Asterisk interactive installer) install_easy_asterisk_server() { clear echo "════════════════════════════════════════════════════════════" echo " EASY ASTERISK SERVER INSTALLATION " echo "════════════════════════════════════════════════════════════" echo echo "This will download and run the Easy Asterisk installer." echo "Select 'Server only' when prompted in the installer." echo # Get latest version from GitHub echo "Checking for latest version..." local latest_version=$(get_latest_easy_asterisk_version) if [ -z "$latest_version" ]; then log_error "Could not determine latest version" log_error "Please check your internet connection" pause return 1 fi log_success "Latest version available: v${latest_version}" echo # Check if already installed local installed_version=$(get_installed_easy_asterisk_version) if [ -n "$installed_version" ]; then echo "Currently installed version: v${installed_version}" echo read -r -p "Re-run the installer? (y/N): " rerun_choice if [[ ! "$rerun_choice" =~ ^[Yy]$ ]]; then echo "Installation cancelled" pause return 0 fi backup_easy_asterisk_configs else read -r -p "Install Easy Asterisk Server v${latest_version}? (Y/n): " install_choice if [[ "$install_choice" =~ ^[Nn]$ ]]; then echo "Installation cancelled" pause return 0 fi fi echo if download_and_install_easy_asterisk "$latest_version"; then echo log_success "Easy Asterisk Server installation completed!" echo echo "Management commands:" echo " Check status: systemctl status asterisk" echo " Asterisk CLI: asterisk -rvvv" echo " Restart: systemctl restart asterisk" else log_error "Installation failed" fi pause } # Full installation (server + client) install_easy_asterisk_full() { clear echo "════════════════════════════════════════════════════════════" echo " EASY ASTERISK FULL INSTALLATION " echo "════════════════════════════════════════════════════════════" echo echo "This will download and run the Easy Asterisk installer." echo "Select 'Full' when prompted to install both server and client." echo # Get latest version from GitHub echo "Checking for latest version..." local latest_version=$(get_latest_easy_asterisk_version) if [ -z "$latest_version" ]; then log_error "Could not determine latest version" pause return 1 fi log_success "Latest version available: v${latest_version}" echo read -r -p "Install Easy Asterisk Full v${latest_version}? (Y/n): " install_choice if [[ "$install_choice" =~ ^[Nn]$ ]]; then echo "Installation cancelled" pause return 0 fi echo if download_and_install_easy_asterisk "$latest_version"; then echo log_success "Easy Asterisk Full installation completed!" else log_error "Installation failed" fi pause } # Show Easy Asterisk status show_easy_asterisk_status() { echo "Current Installation Status:" echo "────────────────────────────────────────────────────────────" # Check server local server_installed=false if [ -f "$EASY_ASTERISK_VERSION_FILE" ]; then local server_ver=$(cat "$EASY_ASTERISK_VERSION_FILE") server_installed=true if systemctl is-active asterisk &>/dev/null; then echo " Server: ✓ Installed (v${server_ver}) - Running" else echo " Server: ✓ Installed (v${server_ver}) - Not running" fi else echo " Server: ✗ Not installed" fi # Check client if is_baresip_client_installed; then local client_ver=$(get_installed_client_version) local kiosk_uid=$(id -u "$KIOSK_USER" 2>/dev/null) if [ -n "$kiosk_uid" ]; then local user_dbus="DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/${kiosk_uid}/bus" if sudo -u "$KIOSK_USER" XDG_RUNTIME_DIR="/run/user/${kiosk_uid}" $user_dbus systemctl --user is-active baresip.service &>/dev/null 2>&1; then echo " Client: ✓ Installed (v${client_ver}) - Running" else echo " Client: ✓ Installed (v${client_ver}) - Not running" fi else echo " Client: ✓ Installed (v${client_ver})" fi else echo " Client: ✗ Not installed" fi echo "────────────────────────────────────────────────────────────" } configure_authelia() { clear echo "════════════════════════════════════════════════════════════" echo " Authelia Auto-Login Setup" echo "════════════════════════════════════════════════════════════" 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 local config_file="$KIOSK_DIR/config.json" if ! sudo test -f "$config_file"; then echo "✗ config.json not found — run a full install first." read -r -p "Press Enter to return..." _; return fi # Show current status local current_url current_user current_url=$(sudo -u "$KIOSK_USER" jq -r '.autheliaURL // ""' "$config_file" 2>/dev/null) current_user=$(sudo -u "$KIOSK_USER" jq -r '.autheliaUsername // ""' "$config_file" 2>/dev/null) if [[ -n "$current_url" ]]; then echo "Current config:" echo " URL: $current_url" echo " Username: $current_user" echo read -r -p "Overwrite existing Authelia config? [y/N]: " confirm [[ "$confirm" != "y" && "$confirm" != "Y" ]] && return echo fi read -r -p "Authelia URL (e.g. https://auth.yourdomain.com): " authelia_url [[ -z "$authelia_url" ]] && echo "Cancelled." && read -r -p "Press Enter..." _; [[ -z "$authelia_url" ]] && return read -r -p "Authelia username: " authelia_user [[ -z "$authelia_user" ]] && echo "Cancelled." && read -r -p "Press Enter..." _; [[ -z "$authelia_user" ]] && return read -r -s -p "Authelia password: " authelia_pass echo [[ -z "$authelia_pass" ]] && echo "Cancelled." && read -r -p "Press Enter..." _; [[ -z "$authelia_pass" ]] && return echo "Encrypting with machine ID..." local encrypted encrypted=$(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')); " "$authelia_pass" 2>/dev/null) if [[ -z "$encrypted" ]]; then echo "✗ Encryption failed — is Node.js installed?" read -r -p "Press Enter..." _; return fi local tmp tmp=$(mktemp) sudo -u "$KIOSK_USER" jq --arg url "$authelia_url" \ --arg user "$authelia_user" \ --arg enc "$encrypted" \ '. + {autheliaURL: $url, autheliaUsername: $user, autheliaEncryptedPassword: $enc}' \ "$config_file" > "$tmp" && sudo mv "$tmp" "$config_file" sudo chown "$KIOSK_USER:$KIOSK_USER" "$config_file" echo echo "✓ Authelia config saved (password encrypted, NOT stored in plain text)." 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\$'" 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, remove autheliaURL /" echo " autheliaUsername / autheliaEncryptedPassword from:" echo " $config_file" echo read -r -p "Restart kiosk display now? [y/N]: " restart if [[ "$restart" == "y" || "$restart" == "Y" ]]; then sudo systemctl restart lightdm fi } # Main Easy Asterisk addon menu addon_easy_asterisk_intercom() { while true; do clear echo "════════════════════════════════════════════════════════════" echo " EASY ASTERISK INTERCOM " echo "════════════════════════════════════════════════════════════" echo show_easy_asterisk_status echo echo "Installation Options:" echo " 1. Install Client Only (Baresip SIP client)" echo " 2. Install Server Only (Asterisk PBX server)" echo " 3. Install Full (Server + Client)" echo echo " 0. Return to Addons Menu" echo read -r -p "Select option: " choice case "$choice" in 1) install_easy_asterisk_client ;; 2) install_easy_asterisk_server ;; 3) install_easy_asterisk_full ;; 0) return 0 ;; *) log_error "Invalid option" sleep 1 ;; esac done } ################################################################################ ### SECTION 15: ADVANCED MENU FUNCTIONS ################################################################################ manual_electron_update() { clear echo "══════════════════════════════════════════════════════════" echo " MANUAL ELECTRON UPDATE " echo "══════════════════════════════════════════════════════════" echo "" # Check if kiosk directory exists (use sudo in case of restricted permissions) if ! sudo test -d "$KIOSK_DIR" 2>/dev/null; then log_error "Kiosk directory not found: $KIOSK_DIR" pause return 1 fi # Function to get current electron version get_current_electron_version_local() { local package_json="$KIOSK_DIR/package.json" if ! sudo test -f "$package_json" 2>/dev/null; then echo "unknown" return 1 fi # Try to get version from package.json (use sudo to read) local version=$(sudo grep -oP '"electron"\s*:\s*"\^?\K[0-9.]+' "$package_json" 2>/dev/null || echo "") if [ -z "$version" ]; then # Try to get from installed node_modules 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 || echo "unknown") else version="not installed" fi fi echo "$version" } # Function to check if electron is running check_electron_running_local() { if pgrep -f "electron.*main.js" >/dev/null 2>&1; then return 0 elif pgrep -f "node.*electron" >/dev/null 2>&1; then return 0 else return 1 fi } # Function to get latest electron version get_latest_electron_version_local() { log_info "Fetching latest stable Electron version from npm..." >&2 local version="" # Method 1: Use npm view version=$(npm view electron version 2>/dev/null || echo "") # Method 2: Use curl to npm registry if npm view fails if [ -z "$version" ]; then version=$(curl -s https://registry.npmjs.org/electron/latest 2>/dev/null | grep -oP '"version"\s*:\s*"\K[0-9.]+' || echo "") fi # Method 3: Check GitHub releases as fallback if [ -z "$version" ]; then 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.]+' || echo "") fi if [ -z "$version" ]; then echo "unknown" return 1 fi echo "$version" } # Get current version log_info "Checking current Electron version..." CURRENT_VERSION=$(get_current_electron_version_local) if [ "$CURRENT_VERSION" = "not installed" ]; then log_error "Electron is not installed in $KIOSK_DIR/node_modules/" log_error "Please run the kiosk installation first" pause return 1 elif [ "$CURRENT_VERSION" = "unknown" ]; then log_warning "Could not determine current Electron version" else log_success "Current Electron version: $CURRENT_VERSION" fi echo "" # Check if electron is running if check_electron_running_local; then log_success "Electron app is running" else log_warning "Electron app does not appear to be running" fi echo "" # Ask if user wants to check for updates read -r -p "Check for latest Electron version? (y/n): " -n 1 echo if [[ ! $REPLY =~ ^[Yy]$ ]]; then log_info "Update check cancelled" pause return 0 fi # Get latest version LATEST_VERSION=$(get_latest_electron_version_local) if [ "$LATEST_VERSION" = "unknown" ]; then log_error "Could not fetch latest Electron version" log_error "Please check your internet connection" pause return 1 fi log_success "Latest stable Electron version: $LATEST_VERSION" echo "" # Compare versions if [ "$CURRENT_VERSION" = "$LATEST_VERSION" ]; then log_success "You are already running the latest version!" echo "" read -r -p "Do you want to reinstall Electron $LATEST_VERSION? (y/n): " -n 1 echo if [[ ! $REPLY =~ ^[Yy]$ ]]; then log_info "Update cancelled" pause return 0 fi fi # Show update summary echo "──────────────────────────────────────────────────────────" echo "UPDATE SUMMARY" echo "──────────────────────────────────────────────────────────" echo "Current version: $CURRENT_VERSION" echo "Target version: $LATEST_VERSION" echo "Installation: $KIOSK_DIR" echo "" # Extract major versions for breaking changes check CURRENT_MAJOR=$(echo "$CURRENT_VERSION" | cut -d'.' -f1) LATEST_MAJOR=$(echo "$LATEST_VERSION" | cut -d'.' -f1) # Show breaking changes warning log_warning "IMPORTANT: Check for breaking changes!" echo "" echo "Before updating, review the Electron release notes:" echo " https://www.electronjs.org/docs/latest/breaking-changes" echo "" if [ "$LATEST_MAJOR" != "$CURRENT_MAJOR" ]; then log_warning "MAJOR VERSION CHANGE DETECTED! (v$CURRENT_MAJOR → v$LATEST_MAJOR)" echo "" echo "Major version changes may include:" else echo "Changes may include:" fi echo " • API changes that require code updates" echo " • Node.js version updates" echo " • Chromium version updates" echo " • Deprecated feature removals" echo "" # Confirm after reviewing breaking changes read -r -p "Have you reviewed the breaking changes and want to proceed? (y/n): " -n 1 echo if [[ ! $REPLY =~ ^[Yy]$ ]]; then log_info "Update cancelled" pause return 0 fi # Create backup echo "" log_info "Creating backup..." local kiosk_owner=$(sudo stat -c '%U' "$KIOSK_DIR") local timestamp=$(date +%Y%m%d_%H%M%S) local backup_dir="${KIOSK_DIR}/backups/electron_backup_${timestamp}" sudo -u "$kiosk_owner" mkdir -p "$backup_dir" # Backup package.json and package-lock.json if sudo test -f "$KIOSK_DIR/package.json" 2>/dev/null; then sudo -u "$kiosk_owner" cp "$KIOSK_DIR/package.json" "$backup_dir/" log_success "Backed up package.json" 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/" log_success "Backed up package-lock.json" fi # Save current Electron version echo "$CURRENT_VERSION" | sudo -u "$kiosk_owner" tee "$backup_dir/electron_version.txt" > /dev/null log_success "Backup created at: $backup_dir" echo "" # Show restore instructions echo "──────────────────────────────────────────────────────────" echo "ROLLBACK INSTRUCTIONS (if update fails)" echo "──────────────────────────────────────────────────────────" echo "Backup location: $backup_dir" echo "" echo "If the update fails or causes issues, you can rollback:" echo "" echo " 1. Stop display:" echo " sudo systemctl stop lightdm" echo "" echo " 2. Restore backup files:" echo " sudo cp $backup_dir/package.json $KIOSK_DIR/" echo " sudo cp $backup_dir/package-lock.json $KIOSK_DIR/ 2>/dev/null || true" echo "" echo " 3. Remove failed Electron install:" echo " sudo rm -rf $KIOSK_DIR/node_modules/electron" echo "" echo " 4. Reinstall previous version:" echo " cd $KIOSK_DIR && sudo -u $KIOSK_USER npm install --unsafe-perm" echo "" echo " 5. Fix permissions:" echo " sudo chown root:root $KIOSK_DIR/node_modules/electron/dist/chrome-sandbox" echo " sudo chmod 4755 $KIOSK_DIR/node_modules/electron/dist/chrome-sandbox" echo "" echo " 6. Restart display:" echo " sudo systemctl start lightdm" echo "" echo "Previous Electron version: $CURRENT_VERSION" echo "" # Final confirmation read -r -p "Proceed with Electron update to version $LATEST_VERSION? (y/n): " -n 1 echo if [[ ! $REPLY =~ ^[Yy]$ ]]; then log_info "Update cancelled" log_info "Backup preserved at: $backup_dir" pause return 0 fi # Perform update echo "" echo "──────────────────────────────────────────────────────────" echo "UPDATING ELECTRON" echo "──────────────────────────────────────────────────────────" log_info "Stopping kiosk display..." sudo systemctl stop lightdm || true sleep 2 log_info "Updating Electron to version $LATEST_VERSION..." # Update package.json with new version sudo -u "$KIOSK_USER" sed -i "s/\"electron\": \".*\"/\"electron\": \"^$LATEST_VERSION\"/" "$KIOSK_DIR/package.json" # Remove old electron installation if sudo test -d "$KIOSK_DIR/node_modules/electron" 2>/dev/null; then log_info "Removing old Electron installation..." sudo -u "$KIOSK_USER" rm -rf "$KIOSK_DIR/node_modules/electron" fi # Install new version 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'" 2>&1 | tee /tmp/electron_install.log; then echo "" log_success "Electron updated successfully to version $LATEST_VERSION" # Fix chrome-sandbox permissions 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" log_success "Fixed chrome-sandbox permissions" fi # Verify new version NEW_VERSION=$(get_current_electron_version_local) echo "" log_success "Electron updated from $CURRENT_VERSION to $NEW_VERSION" echo "" # Restart kiosk display read -r -p "Restart kiosk display now? (y/n): " -n 1 echo if [[ $REPLY =~ ^[Yy]$ ]]; then log_info "Restarting kiosk display..." sudo systemctl start lightdm sleep 3 if systemctl is-active --quiet lightdm; then log_success "Kiosk display started successfully" else log_error "Kiosk display failed to start" log_error "Check logs with: sudo journalctl -u lightdm -n 50" echo "" log_warning "You may need to restore from backup" fi else log_info "Kiosk display not started" log_info "Start manually with: sudo systemctl start lightdm" fi echo "" log_success "Backup preserved at: $backup_dir" log_info "You can delete the backup after confirming everything works" else echo "" log_error "Failed to install Electron $LATEST_VERSION" log_error "Check /tmp/electron_install.log for details" echo "" log_warning "Attempting to restore from backup..." # Restore package.json if sudo test -f "$backup_dir/package.json" 2>/dev/null; then sudo -u "$KIOSK_USER" cp "$backup_dir/package.json" "$KIOSK_DIR/" log_success "Restored package.json" fi # Reinstall original version log_info "Reinstalling original Electron version..." if sudo -u "$KIOSK_USER" bash -c "cd '$KIOSK_DIR' && npm install"; then log_success "Restored original Electron installation" # Restart kiosk display log_info "Restarting kiosk display..." sudo systemctl start lightdm log_success "Kiosk display restarted" else log_error "Failed to restore original installation" log_error "Manual intervention required" fi fi echo "" pause } system_diagnostics() { clear echo " ═══ SYSTEM DIAGNOSTICS ═══" echo echo "=== Kiosk Status ===" systemctl status lightdm --no-pager -l | head -20 echo echo "=== Audio Status ===" sudo -u kiosk 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 } view_logs() { clear echo " ═══ VIEW LOGS ═══" echo echo " 1. Electron log (last 50 lines)" echo " 2. LightDM log (last 50 lines)" echo " 3. System journal (last 100 lines)" echo " 0. Return" echo read -r -p "Choose [0-4]: " choice case "$choice" in 1) if sudo test -f /home/kiosk/electron.log; then sudo tail -50 /home/kiosk/electron.log else echo "No electron log found yet" fi pause ;; 2) sudo tail -50 /var/log/lightdm/lightdm.log; pause ;; 3) sudo journalctl -n 100; pause ;; 0) return ;; esac } factory_reset() { echo echo " ═══ FACTORY RESET ═══" echo echo "This will reset to default config but keep the system." echo read -r -p "Continue? (yes/no): " confirm [[ "$confirm" != "yes" ]] && return sudo -u "$KIOSK_USER" rm -f "$CONFIG_PATH" log_success "Config reset. Reconfigure via Core Settings." pause } export_settings() { echo echo "══════════════════════════════════════════════════════════" echo " EXPORT ALL SETTINGS " echo "══════════════════════════════════════════════════════════" echo echo "This will export:" echo " • Core configuration (sites, touch controls, passwords)" echo " • Schedules (power, display, quiet hours)" echo " • Addon settings (Squeezelite, VNC, Easy Asterisk)" echo " • VPN configs (WireGuard, Netbird, OpenVPN)" echo local export_dir="/tmp/kiosk-backup-$(date +%Y%m%d-%H%M%S)" mkdir -p "$export_dir" chmod 777 "$export_dir" echo "[1/7] Exporting core configuration..." if [[ -f "$CONFIG_PATH" ]]; then sudo cp "$CONFIG_PATH" "$export_dir/config.json" log_success "Core config exported" else log_warning "No core config found" fi echo "[2/7] Exporting schedule timers..." local timer_count=0 mkdir -p "$export_dir/timers" for timer in kiosk-shutdown kiosk-display-off kiosk-display-on kiosk-quiet-start kiosk-quiet-end kiosk-electron-reload; do if [[ -f "/etc/systemd/system/${timer}.timer" ]]; then sudo cp "/etc/systemd/system/${timer}.timer" "$export_dir/timers/" sudo cp "/etc/systemd/system/${timer}.service" "$export_dir/timers/" 2>/dev/null timer_count=$((timer_count + 1)) fi done [[ $timer_count -gt 0 ]] && log_success "Exported $timer_count schedule timers" || log_info "No schedules configured" echo "[3/7] Exporting Squeezelite config..." if [[ -f /usr/local/bin/squeezelite-start.sh ]]; then sudo cp /usr/local/bin/squeezelite-start.sh "$export_dir/" log_success "Squeezelite config exported" else log_info "Squeezelite not installed" fi echo "[4/7] Exporting VNC config..." if [[ -f /etc/systemd/system/x11vnc.service ]]; then sudo cp /etc/systemd/system/x11vnc.service "$export_dir/" # Also copy password file if it exists if [[ -f "$KIOSK_HOME/.vnc/passwd" ]]; then mkdir -p "$export_dir/vnc" sudo cp "$KIOSK_HOME/.vnc/passwd" "$export_dir/vnc/" fi log_success "VNC config exported" else log_info "VNC not installed" fi echo "[5/7] Exporting Easy Asterisk client config..." if [[ -d "$KIOSK_HOME/.baresip" ]]; then mkdir -p "$export_dir/baresip" sudo cp -r "$KIOSK_HOME/.baresip/"* "$export_dir/baresip/" 2>/dev/null log_success "Easy Asterisk client config exported" else log_info "Easy Asterisk client not installed" fi echo "[6/7] Exporting VPN configurations..." local vpn_count=0 mkdir -p "$export_dir/vpn" # WireGuard configs if [[ -d /etc/wireguard ]] && ls /etc/wireguard/*.conf &>/dev/null; then mkdir -p "$export_dir/vpn/wireguard" sudo cp /etc/wireguard/*.conf "$export_dir/vpn/wireguard/" 2>/dev/null log_success "WireGuard config exported" vpn_count=$((vpn_count + 1)) fi # Netbird config and state if command -v netbird &>/dev/null; then mkdir -p "$export_dir/vpn/netbird" # Config file [[ -f /etc/netbird/config.json ]] && sudo cp /etc/netbird/config.json "$export_dir/vpn/netbird/" 2>/dev/null # State directory (contains machine keys, etc.) if [[ -d /var/lib/netbird ]]; then sudo cp -r /var/lib/netbird "$export_dir/vpn/netbird/state" 2>/dev/null fi # Also check for user config [[ -d "$KIOSK_HOME/.netbird" ]] && sudo cp -r "$KIOSK_HOME/.netbird" "$export_dir/vpn/netbird/user-config" 2>/dev/null log_success "Netbird config exported" vpn_count=$((vpn_count + 1)) fi # OpenVPN configs if [[ -d /etc/openvpn ]] && [[ -n "$(ls -A /etc/openvpn 2>/dev/null)" ]]; then mkdir -p "$export_dir/vpn/openvpn" sudo cp -r /etc/openvpn/* "$export_dir/vpn/openvpn/" 2>/dev/null log_success "OpenVPN config exported" vpn_count=$((vpn_count + 1)) fi # Tailscale - just note if installed (requires re-auth) if command -v tailscale &>/dev/null; then local ts_name=$(tailscale status --json 2>/dev/null | jq -r '.Self.HostName' 2>/dev/null || echo "") echo "tailscale_installed=true" > "$export_dir/vpn/tailscale-info.txt" [[ -n "$ts_name" ]] && echo "hostname=$ts_name" >> "$export_dir/vpn/tailscale-info.txt" log_info "Tailscale installed (requires re-authentication after restore)" vpn_count=$((vpn_count + 1)) fi [[ $vpn_count -eq 0 ]] && log_info "No VPN configurations found" echo "[7/7] Exporting quiet hours config..." if [[ -f /usr/local/bin/kiosk-quiet-start.sh ]]; then sudo cp /usr/local/bin/kiosk-quiet-start.sh "$export_dir/" sudo cp /usr/local/bin/kiosk-quiet-end.sh "$export_dir/" 2>/dev/null # Extract quiet mode from script local qmode=$(grep "^QUIET_MODE=" /usr/local/bin/kiosk-quiet-start.sh 2>/dev/null | cut -d'=' -f2) [[ -n "$qmode" ]] && echo "$qmode" > "$export_dir/quiet-mode.txt" log_success "Quiet hours config exported" fi # Create archive local archive_name="kiosk-backup-$(date +%Y%m%d-%H%M%S).tar.gz" # Determine home directory - check SUDO_USER first, then USER, then fallback to /tmp local home_dir="" if [[ -n "${SUDO_USER:-}" ]]; then home_dir=$(eval echo "~$SUDO_USER") elif [[ -n "${USER:-}" ]]; then home_dir=$(eval echo "~$USER") else home_dir="/tmp" fi # Validate home_dir exists and is writable if [[ ! -d "$home_dir" ]] || [[ ! -w "$home_dir" ]]; then home_dir="/tmp" fi # Fix permissions for tar (make readable by all) sudo chmod -R a+r "$export_dir" 2>/dev/null sudo find "$export_dir" -type d -exec chmod a+rx {} \; 2>/dev/null sudo tar -czf "$home_dir/$archive_name" -C /tmp "$(basename "$export_dir")" sudo chown "${SUDO_USER:-$USER}:${SUDO_USER:-$USER}" "$home_dir/$archive_name" 2>/dev/null sudo rm -rf "$export_dir" echo log_success "Settings exported to: $home_dir/$archive_name" echo echo "To transfer this file, use:" echo " scp $(whoami)@$(hostname -I | awk '{print $1}'):$home_dir/$archive_name ." echo pause } import_settings() { echo echo "══════════════════════════════════════════════════════════" echo " IMPORT SETTINGS " echo "══════════════════════════════════════════════════════════" echo echo "This will restore settings from a previous export." echo "Current settings will be OVERWRITTEN." echo # List available backups - determine home directory local home_dir="" if [[ -n "${SUDO_USER:-}" ]]; then home_dir=$(eval echo "~$SUDO_USER") elif [[ -n "${USER:-}" ]]; then home_dir=$(eval echo "~$USER") else home_dir="/tmp" fi # Build array of backup files local -a backup_array=() while IFS= read -r file; do [[ -n "$file" ]] && backup_array+=("$file") done < <(ls -1t "$home_dir"/kiosk-backup-*.tar.gz 2>/dev/null | head -10) local import_file="" if [[ ${#backup_array[@]} -gt 0 ]]; then echo "Found backup files in $home_dir:" echo local i=1 for file in "${backup_array[@]}"; do local fname=$(basename "$file") local fsize=$(du -h "$file" 2>/dev/null | cut -f1) echo " $i) $fname ($fsize)" i=$((i + 1)) done echo read -r -p "Enter number (1-${#backup_array[@]}) or full path: " selection # Check if it's a number if [[ "$selection" =~ ^[0-9]+$ ]]; then if [[ "$selection" -ge 1 && "$selection" -le ${#backup_array[@]} ]]; then import_file="${backup_array[$((selection - 1))]}" else log_error "Invalid selection: $selection" pause return fi else # User entered a path import_file="$selection" fi else echo "No backup files found in $home_dir" echo read -r -p "Enter full path to backup file (.tar.gz): " import_file fi if [[ ! -f "$import_file" ]]; then log_error "File not found: $import_file" pause return fi echo echo "Selected: $(basename "$import_file")" echo read -r -p "This will overwrite current settings. Continue? (yes/no): " confirm [[ "$confirm" != "yes" ]] && return local import_dir="/tmp/kiosk-import-$$" mkdir -p "$import_dir" chmod 777 "$import_dir" echo echo "Extracting backup..." sudo tar -xzf "$import_file" -C "$import_dir" sudo chmod -R a+r "$import_dir" # Find the extracted directory local backup_dir=$(find "$import_dir" -maxdepth 1 -type d -name "kiosk-backup-*" | head -1) [[ -z "$backup_dir" ]] && backup_dir="$import_dir" echo "[1/7] Importing core configuration..." if [[ -f "$backup_dir/config.json" ]]; then sudo cp "$backup_dir/config.json" "$CONFIG_PATH" sudo chown "$KIOSK_USER:$KIOSK_USER" "$CONFIG_PATH" log_success "Core config restored" fi echo "[2/7] Importing schedule timers..." if [[ -d "$backup_dir/timers" ]]; then local timer_count=0 for timer_file in "$backup_dir/timers"/*.timer; do [[ -f "$timer_file" ]] || continue local timer_name=$(basename "$timer_file" .timer) sudo cp "$timer_file" /etc/systemd/system/ local service_file="${timer_file%.timer}.service" [[ -f "$service_file" ]] && sudo cp "$service_file" /etc/systemd/system/ sudo systemctl daemon-reload sudo systemctl enable "${timer_name}.timer" 2>/dev/null sudo systemctl start "${timer_name}.timer" 2>/dev/null timer_count=$((timer_count + 1)) done [[ $timer_count -gt 0 ]] && log_success "Restored $timer_count schedule timers" fi echo "[3/7] Importing Squeezelite config..." if [[ -f "$backup_dir/squeezelite-start.sh" ]]; then sudo cp "$backup_dir/squeezelite-start.sh" /usr/local/bin/ sudo chmod +x /usr/local/bin/squeezelite-start.sh log_success "Squeezelite config restored" fi echo "[4/7] Importing VNC config..." if [[ -f "$backup_dir/x11vnc.service" ]]; then sudo cp "$backup_dir/x11vnc.service" /etc/systemd/system/ if [[ -f "$backup_dir/vnc/passwd" ]]; then sudo mkdir -p "$KIOSK_HOME/.vnc" sudo cp "$backup_dir/vnc/passwd" "$KIOSK_HOME/.vnc/" sudo chown -R "$KIOSK_USER:$KIOSK_USER" "$KIOSK_HOME/.vnc" fi sudo systemctl daemon-reload sudo systemctl enable x11vnc 2>/dev/null log_success "VNC config restored" fi echo "[5/7] Importing Easy Asterisk client config..." if [[ -d "$backup_dir/baresip" ]]; then sudo mkdir -p "$KIOSK_HOME/.baresip" sudo cp -r "$backup_dir/baresip/"* "$KIOSK_HOME/.baresip/" sudo chown -R "$KIOSK_USER:$KIOSK_USER" "$KIOSK_HOME/.baresip" log_success "Easy Asterisk client config restored" fi echo "[6/7] Importing VPN configurations..." local vpn_restored=false # WireGuard if [[ -d "$backup_dir/vpn/wireguard" ]]; then sudo mkdir -p /etc/wireguard for wg_conf in "$backup_dir/vpn/wireguard"/*.conf; do [[ -f "$wg_conf" ]] || continue local wg_name=$(basename "$wg_conf" .conf) sudo cp "$wg_conf" /etc/wireguard/ sudo chmod 600 "/etc/wireguard/${wg_name}.conf" sudo systemctl enable "wg-quick@${wg_name}" 2>/dev/null sudo systemctl start "wg-quick@${wg_name}" 2>/dev/null done log_success "WireGuard config restored" vpn_restored=true fi # Netbird if [[ -d "$backup_dir/vpn/netbird" ]]; then # Restore config file if [[ -f "$backup_dir/vpn/netbird/config.json" ]]; then sudo mkdir -p /etc/netbird sudo cp "$backup_dir/vpn/netbird/config.json" /etc/netbird/ fi # Restore state directory (contains machine keys) if [[ -d "$backup_dir/vpn/netbird/state" ]]; then sudo cp -r "$backup_dir/vpn/netbird/state" /var/lib/netbird sudo chown -R root:root /var/lib/netbird fi # Restore user config if [[ -d "$backup_dir/vpn/netbird/user-config" ]]; then sudo cp -r "$backup_dir/vpn/netbird/user-config" "$KIOSK_HOME/.netbird" sudo chown -R "$KIOSK_USER:$KIOSK_USER" "$KIOSK_HOME/.netbird" fi if command -v netbird &>/dev/null; then sudo systemctl enable netbird 2>/dev/null sudo systemctl start netbird 2>/dev/null log_success "Netbird config restored" else log_warning "Netbird config restored but netbird not installed" echo " Install with: curl -fsSL https://pkgs.netbird.io/install.sh | sudo bash" fi vpn_restored=true fi # OpenVPN if [[ -d "$backup_dir/vpn/openvpn" ]]; then sudo mkdir -p /etc/openvpn sudo cp -r "$backup_dir/vpn/openvpn/"* /etc/openvpn/ log_success "OpenVPN config restored" vpn_restored=true fi # Tailscale info if [[ -f "$backup_dir/vpn/tailscale-info.txt" ]]; then if command -v tailscale &>/dev/null; then log_info "Tailscale installed - run 'sudo tailscale up' to reconnect" else log_warning "Tailscale was configured but is not installed" echo " Install with: curl -fsSL https://tailscale.com/install.sh | sh" fi fi [[ "$vpn_restored" == "false" ]] && log_info "No VPN configs in backup" echo "[7/7] Importing quiet hours config..." if [[ -f "$backup_dir/kiosk-quiet-start.sh" ]]; then sudo cp "$backup_dir/kiosk-quiet-start.sh" /usr/local/bin/ sudo chmod +x /usr/local/bin/kiosk-quiet-start.sh [[ -f "$backup_dir/kiosk-quiet-end.sh" ]] && { sudo cp "$backup_dir/kiosk-quiet-end.sh" /usr/local/bin/ sudo chmod +x /usr/local/bin/kiosk-quiet-end.sh } log_success "Quiet hours config restored" fi # Cleanup sudo rm -rf "$import_dir" echo log_success "Settings import complete!" echo echo "Restart kiosk to apply changes:" echo " sudo systemctl restart kiosk" echo pause } # Shared helper: write LightDM autologin config and add kiosk user to required groups. # Called from both fresh install (step 19/27) and upgrade (step 5/6). 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 </dev/null) 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 --show-progress -O "$tmp_zip" "$electron_url" 2>&1; then command -v unzip >/dev/null 2>&1 || sudo apt install -y unzip chmod 644 "$tmp_zip" # mktemp creates root:root 600; kiosk user needs read access # Fix ownership first so the kiosk user can write into the directory # (npm postinstall with --unsafe-perm can leave dist/ owned by root) 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 re-run the installer." return 1 fi log_success "Electron binary verified" # chrome-sandbox MUST be owned by root and setuid — without this 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 } # Repair option: re-verify/download electron binary and fix sandbox perms without full reinstall repair_electron() { clear echo "════════════════════════════════════════════════════════════" echo " Fix Blank Screen / Electron Repair" echo "════════════════════════════════════════════════════════════" echo echo "This will:" echo " 1. Check if 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 read -r -p "Continue? [y/N]: " confirm [[ "$confirm" != "y" && "$confirm" != "Y" ]] && return echo sudo systemctl stop lightdm 2>/dev/null || true sleep 1 if ! install_electron_binary; then echo log_error "Could not install Electron. Check internet and retry." read -r -p "Press Enter..." _; return fi echo log_info "Restarting kiosk display..." sudo systemctl restart lightdm sleep 3 if systemctl is-active --quiet lightdm && pgrep -f "electron.*main.js" >/dev/null 2>&1; 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 echo read -r -p "Press Enter to return..." _ } upgrade_kiosk() { clear echo "══════════════════════════════════════════════════════════" echo " SILENT UPGRADE KIOSK APP (v$SCRIPT_VERSION) " echo "══════════════════════════════════════════════════════════" echo echo "This will upgrade the kiosk application while preserving:" echo " • All your site configurations" echo " • Schedules and timers" echo " • Password settings" echo " • Addon configurations (Squeezelite, VNC, VPN, etc.)" echo echo "NO user input is required - upgrade runs automatically." echo # Upgrade requires the script to be a real file on disk (not a pipe). # The extract_file() helper greps the script for heredoc markers. if [[ -z "$SCRIPT_FILE" ]]; then echo log_error "Upgrade cannot run when the script was piped (curl|bash / wget|bash)." echo echo " Download the script to a file first, then run it:" echo echo " wget -O kiosk.sh " echo " sudo bash kiosk.sh" echo pause return fi read -r -p "Continue with upgrade? (yes/no): " confirm [[ "$confirm" != "yes" ]] && return # Use the pre-resolved path (set at startup) local script_path="$SCRIPT_FILE" echo echo "[1/6] Stopping kiosk (LightDM)..." # Kill any running electron process first pkill -f "electron.*main.js" 2>/dev/null || true sleep 1 log_success "Kiosk stopped" echo "[2/6] Backing up config.json..." local config_backup="/tmp/kiosk-config-backup-$$.json" if [[ -f "$CONFIG_PATH" ]]; then sudo cp "$CONFIG_PATH" "$config_backup" log_success "Config backed up" fi echo "[3/6] Removing old app files..." sudo rm -f "$KIOSK_DIR/main.js" sudo rm -f "$KIOSK_DIR/preload.js" sudo rm -f "$KIOSK_DIR/keyboard.html" sudo rm -f "$KIOSK_DIR/keyboard-button.html" sudo rm -f "$KIOSK_DIR/pause-dialog.html" sudo rm -f "$KIOSK_DIR/pin-entry.html" sudo rm -f "$KIOSK_DIR/inactivity-prompt-extended.html" # node_modules is NOT removed — preserves the Electron binary (~120MB download) # npm install below is a no-op if dependencies haven't changed log_success "Old files removed" echo "[4/6] Extracting new app files from script..." # Ensure kiosk directory exists with correct ownership if [[ ! -d "$KIOSK_DIR" ]]; then echo " Creating $KIOSK_DIR..." sudo mkdir -p "$KIOSK_DIR" fi sudo chown "$KIOSK_USER:$KIOSK_USER" "$KIOSK_DIR" # Helper function to extract heredoc content using line numbers extract_file() { local start_pattern="$1" local end_marker="$2" local output_file="$3" local fname fname=$(basename "$output_file") # Find start line number local start_line start_line=$(grep -n "$start_pattern" "$script_path" | head -1 | cut -d: -f1) if [[ -z "$start_line" ]]; then echo " ✗ $fname (pattern not found)" return 1 fi # Find the end marker after the start line local end_offset end_offset=$(tail -n +"$start_line" "$script_path" | grep -n "^${end_marker}$" | head -1 | cut -d: -f1) if [[ -z "$end_offset" ]]; then echo " ✗ $fname (end marker not found)" return 1 fi # Calculate line range (skip heredoc start line, exclude end marker) local content_start=$((start_line + 1)) local content_end=$((start_line + end_offset - 2)) # Extract content and write to file (use sudo tee for reliability) sed -n "${content_start},${content_end}p" "$script_path" | sudo tee "$output_file" > /dev/null if sudo test -s "$output_file"; then echo " ✓ $fname (lines $content_start-$content_end)" return 0 else echo " ✗ $fname (extracted 0 lines from $content_start-$content_end)" return 1 fi } # Extract all files using their unique heredoc markers extract_file 'tee.*main\.js.*MAINJS' 'MAINJS' "$KIOSK_DIR/main.js" extract_file 'tee.*preload\.js.*PRELOAD' 'PRELOAD' "$KIOSK_DIR/preload.js" extract_file 'tee.*keyboard\.html.*KBHTML' 'KBHTML' "$KIOSK_DIR/keyboard.html" extract_file 'tee.*pause-dialog\.html.*PAUSEHTML' 'PAUSEHTML' "$KIOSK_DIR/pause-dialog.html" extract_file 'tee.*pin-entry\.html.*PINHTML' 'PINHTML' "$KIOSK_DIR/pin-entry.html" extract_file 'tee.*inactivity-prompt-extended\.html.*INACTHTML' 'INACTHTML' "$KIOSK_DIR/inactivity-prompt-extended.html" extract_file 'tee.*keyboard-button\.html.*BTNHTML' 'BTNHTML' "$KIOSK_DIR/keyboard-button.html" extract_file 'tee.*package\.json.*PKGJSON' 'PKGJSON' "$KIOSK_DIR/package.json" extract_file 'tee.*start\.sh.*LAUNCHER' 'LAUNCHER' "$KIOSK_DIR/start.sh" # Set ownership and permissions sudo chown -R "$KIOSK_USER:$KIOSK_USER" "$KIOSK_DIR" sudo chmod +x "$KIOSK_DIR/start.sh" echo "[5/6] Installing npm dependencies..." 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" 2>&1 | tail -5; then log_success "npm install complete" else log_warning "npm install may have had issues" fi # Verify and fix Electron binary + chrome-sandbox (same logic as fresh install) # Use || return 1 to disable set -e inside the function (matches fresh install pattern) install_electron_binary || { log_error "Electron setup failed — upgrade aborted."; return 1; } # Restore config if [[ -f "$config_backup" ]]; then sudo cp "$config_backup" "$CONFIG_PATH" sudo chown "$KIOSK_USER:$KIOSK_USER" "$CONFIG_PATH" sudo rm -f "$config_backup" fi # Ensure LightDM autologin config exists and groups are correct. # The upgrade preserves node_modules but does not re-run the full install, # so this may be the first time the config is written on new hardware. configure_lightdm_autologin # Force any touch screen to use libinput (proper multitouch for Chromium). # Remove the old wacom-touch override from earlier versions — it sorts after # this file and would otherwise win and re-bind the device to the wacom driver. sudo mkdir -p /etc/X11/xorg.conf.d sudo rm -f /etc/X11/xorg.conf.d/99-wacom-touch.conf sudo tee /etc/X11/xorg.conf.d/99-finger-libinput.conf > /dev/null <<'TOUCHCFG' Section "InputClass" Identifier "Touch screen use libinput" MatchIsTouchscreen "on" Driver "libinput" EndSection TOUCHCFG # Install simplified power button handler echo " Updating power button handler..." sudo tee /usr/local/bin/kiosk-power-button.sh > /dev/null <<'PWREOF' #!/bin/bash # Power button handler - sends SIGUSR1 to Electron to show power menu logger "KIOSK POWER: Button pressed" PIDS=$(pgrep -u kiosk -f "electron" 2>/dev/null) if [ -z "$PIDS" ]; then logger "KIOSK POWER: No Electron process found" exit 1 fi 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" PWREOF sudo chmod +x /usr/local/bin/kiosk-power-button.sh # Update ACPI event handler sudo tee /etc/acpi/events/kiosk-power-button > /dev/null <<'EOF' event=button/power.* action=/usr/local/bin/kiosk-power-button.sh EOF sudo systemctl restart acpid 2>/dev/null || true echo "[6/6] Starting kiosk..." sudo systemctl restart lightdm sleep 3 echo echo "══════════════════════════════════════════════════════════" echo " UPGRADE SUMMARY " echo "══════════════════════════════════════════════════════════" echo echo "Files updated:" ls -la "$KIOSK_DIR"/*.js "$KIOSK_DIR"/*.html 2>/dev/null | awk '{print " " $NF}' echo # Show preserved settings echo "Preserved settings:" if [[ -f "$CONFIG_PATH" ]]; then local site_count site_count=$(sudo -u "$KIOSK_USER" jq -r '.sites | length // 0' "$CONFIG_PATH" 2>/dev/null || echo "0") echo " • Sites configured: $site_count" local pin_enabled pin_enabled=$(sudo -u "$KIOSK_USER" jq -r '.pinEnabled // false' "$CONFIG_PATH" 2>/dev/null) [[ "$pin_enabled" == "true" ]] && echo " • PIN protection: enabled" local rotation rotation=$(sudo -u "$KIOSK_USER" jq -r '.rotationInterval // 0' "$CONFIG_PATH" 2>/dev/null) [[ "$rotation" -gt 0 ]] && echo " • Rotation interval: ${rotation}s" fi # Check timers local timer_count=0 for timer in kiosk-shutdown kiosk-display-off kiosk-display-on kiosk-quiet-start kiosk-quiet-end; do systemctl is-enabled "${timer}.timer" &>/dev/null && timer_count=$((timer_count + 1)) done [[ $timer_count -gt 0 ]] && echo " • Active schedule timers: $timer_count" # Check addons [[ -f /usr/local/bin/squeezelite-start.sh ]] && echo " • Squeezelite: configured" systemctl is-enabled x11vnc &>/dev/null && echo " • VNC: enabled" command -v netbird &>/dev/null && echo " • Netbird VPN: installed" command -v tailscale &>/dev/null && echo " • Tailscale VPN: installed" [[ -d /etc/wireguard ]] && ls /etc/wireguard/*.conf &>/dev/null && echo " • WireGuard VPN: configured" echo if systemctl is-active --quiet lightdm && pgrep -f "electron.*main.js" >/dev/null 2>&1; then log_success "Upgrade complete! Kiosk is running." elif systemctl is-active --quiet lightdm; then log_warning "LightDM running but Electron may not have started yet." echo " Check: journalctl -u lightdm -n 20" else log_warning "Kiosk may not have started. Check: sudo systemctl status lightdm" fi echo pause } network_test() { echo echo " ═══ NETWORK TEST ═══" echo echo "Ping test..." ping -c 4 8.8.8.8 echo echo "DNS test..." nslookup google.com pause } audio_test() { echo echo " ═══ AUDIO TEST ═══" echo echo "Testing speaker..." speaker-test -t sine -f 1000 -l 1 2>/dev/null || echo "speaker-test not available" echo echo "PipeWire status:" pactl info pause } ################################################################################ ### SECTION 16: MENU SYSTEM ################################################################################ restart_kiosk_display() { clear echo " ═══ RESTART KIOSK DISPLAY ═══" echo echo "This will restart the display (LightDM)." echo "All services continue running." echo "Takes about 10 seconds." echo read -r -p "Restart now? (y/n): " confirm if [[ "$confirm" =~ ^[Yy]$ ]]; then echo "Restarting..." sudo systemctl restart lightdm fi } show_main_menu() { while true; do clear echo "════════════════════════════════════════════════════════════" echo " Ubuntu Based Kiosk (UBK) v${SCRIPT_VERSION} " echo "════════════════════════════════════════════════════════════" echo show_system_status show_addon_status show_schedule_status echo "Main Menu:" echo " 1. Core Settings" echo " 2. Addons" echo " 3. Advanced" echo " 4. Restart Kiosk Display" echo " 0. Exit" echo read -r -p "Choose [0-4]: " choice case "$choice" in 1) core_menu ;; 2) addons_menu ;; 3) advanced_menu ;; 4) restart_kiosk_display ;; 0) exit 0 ;; esac done } core_menu() { while true; do clear echo "══════════════════════════════════════════════════════════" echo " CORE SETTINGS " echo "══════════════════════════════════════════════════════════" echo show_current_config echo echo "Options:" echo " 1. Timezone" echo " 2. Touch controls" echo " 3. Navigation security" echo " 4. Sites" echo " 5. WiFi" echo " 6. Power/Display/Quiet Hours" echo " 7. Optional Features (Pause/Keyboard)" echo " 8. Password Protection & Lockout" echo " 9. Hidden Site PIN" echo " 10. Upgrade (preserves settings)" echo " 11. Full reinstall" echo " 12. Complete uninstall" echo " 0. Return" echo read -r -p "Choose [0-12]: " choice case "$choice" in 1) configure_timezone; pause ;; 2) load_existing_config; configure_touch_controls; save_config ;; 3) load_existing_config; configure_navigation_security; save_config ;; 4) configure_sites ;; 5) configure_wifi ;; 6) configure_power_display_quiet ;; 7) load_existing_config; if configure_optional_features; then save_config; fi ;; 8) load_existing_config; if configure_password_protection; then save_config; fi ;; 9) configure_hidden_site_pin ;; 10) upgrade_kiosk ;; 11) full_reinstall ;; 12) complete_uninstall; return ;; 0) return ;; esac done } addons_menu() { while true; do clear echo "════════════════════════════════════════════════════════════" echo " ADDONS MANAGEMENT " echo "════════════════════════════════════════════════════════════" echo show_addon_status echo "Available Addons:" echo " 1. LMS Server / Squeezelite Player" echo " 2. CUPS Printing" echo " 3. Remote Access (VNC/VPN)" echo " 4. Easy Asterisk Intercom" echo " 5. Authelia Auto-Login" echo " 0. Return" echo read -r -p "Choose [0-5]: " choice case "$choice" in 1) addon_lms_squeezelite ;; 2) addon_cups ;; 3) remote_access_menu ;; 4) addon_easy_asterisk_intercom ;; 5) configure_authelia ;; 0) return ;; esac done } remote_access_menu() { while true; do clear echo "════════════════════════════════════════════════════════════" echo " REMOTE ACCESS " echo "════════════════════════════════════════════════════════════" echo echo "Options:" echo " 1. VNC Remote Desktop" echo " 2. WireGuard VPN" echo " 3. Tailscale VPN" echo " 4. Netbird VPN" echo " 0. Return" echo read -r -p "Choose [0-4]: " choice case "$choice" in 1) addon_vnc ;; 2) addon_wireguard ;; 3) addon_tailscale ;; 4) addon_netbird ;; 0) return ;; esac done } 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 sudo -u "$KIOSK_USER" XDG_RUNTIME_DIR="/run/user/$(id -u $KIOSK_USER)" pactl info &>/dev/null; then log_success "PipeWire accessible" pipewire_running=true sudo -u "$KIOSK_USER" XDG_RUNTIME_DIR="/run/user/$(id -u $KIOSK_USER)" 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 systemctl --user start pipewire pipewire-pulse" fi echo if $pipewire_running; then echo "[4/8] Checking audio sinks..." local sinks=$(sudo -u "$KIOSK_USER" XDG_RUNTIME_DIR="/run/user/$(id -u $KIOSK_USER)" pactl list sinks short 2>/dev/null) if [[ -n "$sinks" ]]; then echo "$sinks" local default_sink=$(sudo -u "$KIOSK_USER" XDG_RUNTIME_DIR="/run/user/$(id -u $KIOSK_USER)" 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=$(sudo -u "$KIOSK_USER" XDG_RUNTIME_DIR="/run/user/$(id -u $KIOSK_USER)" pactl list sink-inputs short 2>/dev/null) 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=$(pgrep -f squeezelite | head -1) if [[ -n "$sq_pid" ]]; then if sudo -u "$KIOSK_USER" XDG_RUNTIME_DIR="/run/user/$(id -u $KIOSK_USER)" 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=$(sudo -u "$KIOSK_USER" XDG_RUNTIME_DIR="/run/user/$(id -u $KIOSK_USER)" pactl get-sink-volume @DEFAULT_SINK@ 2>/dev/null | grep -oE '[0-9]+%' | head -1 || echo "unknown") local muted=$(sudo -u "$KIOSK_USER" XDG_RUNTIME_DIR="/run/user/$(id -u $KIOSK_USER)" 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..." read -r -p "Play test sound? (y/n): " play_test if [[ "$play_test" =~ ^[Yy]$ ]] && $pipewire_running; then echo "Playing beep..." 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" fi echo echo "═══════════════════════════════" if $issue_found; then echo "⚠️ ISSUES DETECTED - See above" else echo "✓ All checks passed" fi echo "═══════════════════════════════" pause } fix_squeezelite_audio() { clear echo "═══ FIX SQUEEZELITE AUDIO ═══" echo echo "This will attempt to fix Squeezelite audio issues by:" echo " 1. Restarting PipeWire" echo " 2. Resetting audio configuration" echo " 3. Restarting Squeezelite" echo read -r -p "Continue? (y/n): " confirm [[ ! "$confirm" =~ ^[Yy]$ ]] && return echo echo "[1/6] Stopping Squeezelite..." sudo systemctl stop squeezelite sleep 2 echo "[2/6] Restarting PipeWire..." local kiosk_uid=$(id -u "$KIOSK_USER") sudo -u "$KIOSK_USER" XDG_RUNTIME_DIR="/run/user/$kiosk_uid" systemctl --user restart pipewire pipewire-pulse wireplumber sleep 5 echo "[3/6] Waiting for audio system..." for i in {1..10}; do if sudo -u "$KIOSK_USER" XDG_RUNTIME_DIR="/run/user/$kiosk_uid" pactl info &>/dev/null; then break fi sleep 1 done echo "[4/6] Setting audio levels..." sudo -u "$KIOSK_USER" XDG_RUNTIME_DIR="/run/user/$kiosk_uid" pactl set-sink-volume @DEFAULT_SINK@ 100% sudo -u "$KIOSK_USER" XDG_RUNTIME_DIR="/run/user/$kiosk_uid" pactl set-source-volume @DEFAULT_SOURCE@ 100% sudo -u "$KIOSK_USER" XDG_RUNTIME_DIR="/run/user/$kiosk_uid" pactl set-source-mute @DEFAULT_SOURCE@ 0 echo "[5/6] Starting Squeezelite..." sudo systemctl start squeezelite sleep 3 echo "[6/6] Checking status..." if systemctl is-active --quiet squeezelite; then log_success "Squeezelite restarted" local sq_pid=$(pgrep -f squeezelite | head -1) if [[ -n "$sq_pid" ]]; then sleep 2 if sudo -u "$KIOSK_USER" XDG_RUNTIME_DIR="/run/user/$kiosk_uid" 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 started but not connected to audio sink" echo " Check LMS server connection" fi fi else log_error "Squeezelite failed to start" echo " Check logs: sudo journalctl -u squeezelite -n 50" fi pause } advanced_menu() { while true; do clear echo "══════════════════════════════════════════════════════════" echo " ADVANCED OPTIONS " echo "══════════════════════════════════════════════════════════" echo echo "Options:" echo " 1. Manual Electron Update" echo " 2. System Diagnostics" echo " 3. View Logs" echo " 4. Audio Diagnostics" echo " 5. Fix Squeezelite Audio" echo " 6. Factory Reset Config" echo " 7. Virtual Consoles (Ctrl+Alt+F1-F8)" echo " 8. Export All Settings" echo " 9. Import Settings" echo " 10. Emergency Hotspot" echo " 11. Network Test" echo " 12. Fix Blank Screen (Electron repair)" echo " 0. Return" echo read -r -p "Choose [0-12]: " choice case "$choice" in 1) manual_electron_update ;; 2) system_diagnostics ;; 3) view_logs ;; 4) audio_diagnostics ;; 5) fix_squeezelite_audio ;; 6) factory_reset ;; 7) configure_virtual_consoles ;; 8) export_settings ;; 9) import_settings ;; 10) configure_emergency_hotspot ;; 11) network_test ;; 12) repair_electron ;; 0) return ;; esac done } ################################################################################ ### SECTION 17: MAIN ENTRY POINT ################################################################################ # Verify not running as kiosk user if [[ "$(whoami)" == "kiosk" ]]; then echo "ERROR: Cannot run as user 'kiosk'" exit 1 fi # Verify not running as root if [[ $EUID -eq 0 ]]; then echo "ERROR: Run as regular user with sudo privileges" exit 1 fi # Main execution if is_kiosk_installed; then show_main_menu else first_time_install fi exit 0