From 037a62e008ec622cf35c4ac7a3a35fe5085b50c9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 05:24:32 +0000 Subject: [PATCH] Add real first-time provisioning to install.sh (v2.14.0) Until now ./install.sh only managed an already-installed kiosk; ubuntu-based-kiosk.sh was still the only path from a bare Ubuntu Server box to a running one. install.sh now provisions from scratch too: packages, kiosk user, Node.js/Electron, LightDM+Openbox autologin, audio/video/HDMI/power-button hardware setup, and the firewall, then hands off to the already-migrated Core Settings/Advanced menus for initial configuration instead of reimplementing that logic again. - lib/provision.sh: the new provisioning flow, built mostly by calling existing menus (core_settings_menu, emergency hotspot, virtual consoles) - cuts it to ~300 lines against the legacy script's ~4,000-line first_time_install(). - lib/electron.sh: electron_install_binary() extracted out of menus/advanced_electron.sh so provisioning and the existing "Fix blank screen" action share one implementation. - kiosk-app/ and provision/files/: the Electron app source and every system template file, extracted byte-for-byte out of ubuntu-based-kiosk.sh's heredocs into real files. - Found and fixed a bash set -e gotcha along the way: testing a multi-statement function as an if-condition (`if ! some_func; then`) silently exempts everything inside that function from set -e for the duration of the call. Fixed in the new provisioning code and in menus/advanced_electron.sh's pre-existing repair action, which had the same shape. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01VfsFSoRqfbRG7XAg5RoE7e --- Readme.md | 71 +- install.sh | 79 +- kiosk-app/inactivity-prompt-extended.html | 137 ++ kiosk-app/keyboard.html | 261 +++ kiosk-app/main.js | 1634 +++++++++++++++++ kiosk-app/package.json | 8 + kiosk-app/pause-dialog.html | 119 ++ kiosk-app/pin-entry.html | 139 ++ kiosk-app/preload.js | 931 ++++++++++ kiosk-app/start.sh | 30 + lib/electron.sh | 58 + lib/provision.sh | 323 ++++ menus/advanced_electron.sh | 69 +- .../etc/X11/xorg.conf.d/10-serverflags.conf | 10 + .../files/etc/X11/xorg.conf.d/20-intel.conf | 7 + .../X11/xorg.conf.d/99-finger-libinput.conf | 5 + .../files/etc/acpi/events/kiosk-power-button | 2 + .../files/etc/acpi/events/kiosk-power-pbtn | 2 + .../files/etc/acpi/events/kiosk-power-pwr | 2 + .../50-local.d/kiosk-power.pkla | 6 + .../systemd/logind.conf.d/power-button.conf | 6 + .../etc/systemd/system/kiosk-hotplug.service | 8 + .../etc/udev/rules.d/99-kiosk-hotplug.rules | 1 + provision/files/openbox/autostart | 143 ++ .../files/pipewire/99-noise-cancellation.conf | 33 + .../files/usr/local/bin/kiosk-audio-route.sh | 36 + .../files/usr/local/bin/kiosk-hotplug.sh | 7 + .../usr/local/bin/kiosk-mirror-display.sh | 48 + .../files/usr/local/bin/kiosk-power-button.sh | 21 + .../files/usr/local/bin/kiosk-volume-down | 5 + provision/files/usr/local/bin/kiosk-volume-up | 5 + .../files/usr/local/bin/test-power-button | 51 + ubuntu-based-kiosk.sh | 51 +- 33 files changed, 4209 insertions(+), 99 deletions(-) create mode 100644 kiosk-app/inactivity-prompt-extended.html create mode 100644 kiosk-app/keyboard.html create mode 100644 kiosk-app/main.js create mode 100644 kiosk-app/package.json create mode 100644 kiosk-app/pause-dialog.html create mode 100644 kiosk-app/pin-entry.html create mode 100644 kiosk-app/preload.js create mode 100755 kiosk-app/start.sh create mode 100644 lib/electron.sh create mode 100644 lib/provision.sh create mode 100644 provision/files/etc/X11/xorg.conf.d/10-serverflags.conf create mode 100644 provision/files/etc/X11/xorg.conf.d/20-intel.conf create mode 100644 provision/files/etc/X11/xorg.conf.d/99-finger-libinput.conf create mode 100644 provision/files/etc/acpi/events/kiosk-power-button create mode 100644 provision/files/etc/acpi/events/kiosk-power-pbtn create mode 100644 provision/files/etc/acpi/events/kiosk-power-pwr create mode 100644 provision/files/etc/polkit-1/localauthority/50-local.d/kiosk-power.pkla create mode 100644 provision/files/etc/systemd/logind.conf.d/power-button.conf create mode 100644 provision/files/etc/systemd/system/kiosk-hotplug.service create mode 100644 provision/files/etc/udev/rules.d/99-kiosk-hotplug.rules create mode 100755 provision/files/openbox/autostart create mode 100644 provision/files/pipewire/99-noise-cancellation.conf create mode 100755 provision/files/usr/local/bin/kiosk-audio-route.sh create mode 100755 provision/files/usr/local/bin/kiosk-hotplug.sh create mode 100755 provision/files/usr/local/bin/kiosk-mirror-display.sh create mode 100755 provision/files/usr/local/bin/kiosk-power-button.sh create mode 100755 provision/files/usr/local/bin/kiosk-volume-down create mode 100755 provision/files/usr/local/bin/kiosk-volume-up create mode 100755 provision/files/usr/local/bin/test-power-button diff --git a/Readme.md b/Readme.md index dbe9e81..2c4fec7 100644 --- a/Readme.md +++ b/Readme.md @@ -54,6 +54,12 @@ chmod +x ubuntu-based-kiosk.sh && ./ubuntu-based-kiosk.sh The installer will guide you through configuration during setup. +> The modular `./install.sh` (see "Modular Management" below) can also +> provision a kiosk from scratch now, as an alternative to the +> single-file installer above. `ubuntu-based-kiosk.sh` remains the more +> battle-tested path and the only one that supports Upgrade/Full +> Reinstall of an existing install. + --- ## Offline / Air-Gapped Download @@ -1260,33 +1266,55 @@ terminal menu and the web UI, so they can't drift apart). share the same settings. New, not a legacy port — deliberately never copies machine-bound credentials (Authelia, WireGuard, Asterisk Intercom); see "Recent Updates (v2.13.0)" below. +- `lib/electron.sh` — `electron_install_binary()`: verify/download the + Electron binary and fix `chrome-sandbox` permissions. Shared between + fresh provisioning and `menus/advanced_electron.sh`'s "Fix blank + screen" action — the same repair sequence applies whether the binary + never downloaded during the initial `npm install` or went missing + later. +- `lib/provision.sh` — first-time provisioning: packages, kiosk user, + Node.js/Electron, LightDM+Openbox autologin, audio/video/HDMI/ + power-button hardware setup, firewall, then hands off to + `core_settings_menu` and other already-migrated Advanced actions for + initial configuration, rather than reimplementing that logic a third + time. See "Recent Updates (v2.14.0)" below. +- `kiosk-app/` — the Electron app source (`main.js`, `preload.js`, the + dialog HTML files, `package.json`, `start.sh`), copied to the kiosk + directory during provisioning. Also the basis for a future clean + `git pull`-based Upgrade. +- `provision/files/` — every other system template file provisioning + installs (X11 configs, udev rules, systemd units, the power-button + and HDMI-mirroring scripts, polkit rules), laid out mirroring their + real destination path, e.g. `provision/files/etc/X11/xorg.conf.d/ + foo.conf` installs to `/etc/X11/xorg.conf.d/foo.conf`. - `install.sh` — entry point for the modular tool, now grouped **Core - Settings / Addons / Advanced** like the legacy menu. Run it against an - *already-installed* kiosk: + Settings / Addons / Advanced** like the legacy menu. On a machine + with no kiosk installed yet, it provisions one first (see + `lib/provision.sh` above); on an already-installed kiosk, it goes + straight to the same menus: ```bash git clone https://github.com/outis1one/ubuntu-based-kiosk/ cd ubuntu-based-kiosk ./install.sh ``` -**Honest status:** this does not yet replace first-time installation, or -most of the old installer. `ubuntu-based-kiosk.sh` is still ~12,000 +**Honest status:** first-time installation is now covered — `install.sh` +provisions a kiosk from a bare Ubuntu Server box, not just an +already-installed one — but `ubuntu-based-kiosk.sh` is still ~12,000 lines and still contains its own unremoved, unmodified copies of every menu above, including the legacy three-option (Client/Server/Full) Easy Asterisk Intercom — the modular version only replaces the Client -option, by design (plus Upgrade, Full Reinstall, and Fix Squeezelite -Audio — none of that has moved yet; Complete Uninstall *is* now -migrated, but Upgrade and Full Reinstall are staying put — both are -coupled to this file's own heredoc self-extraction of main.js/ -preload.js/etc, which has no modular equivalent). The legacy Export/ -Import Settings is also staying as-is; Clone Settings is a new, -narrower feature alongside it, not a replacement for it — see "Recent -Updates (v2.13.0)" below for why they're not the same thing. +option, by design. Two pieces remain legacy-only: Upgrade and Full +Reinstall, both coupled to `ubuntu-based-kiosk.sh`'s own heredoc +self-extraction of main.js/preload.js/etc — a different mechanism from +the new provisioning, which copies real files from `kiosk-app/` and +`provision/files/` instead. The legacy Export/Import Settings is also +staying as-is; Clone Settings is a new, narrower feature alongside it, +not a replacement for it — see "Recent Updates (v2.13.0)" below for why +they're not the same thing. Both copies coexist deliberately: the old ones stay until enough of Core Settings/Addons/Advanced is migrated to retire them in one pass, -rather than leaving the legacy menu half-wired. Migration continues one -`menus/*.sh` file at a time; first-time installation itself is the last -and largest piece to move, if it moves at all. +rather than leaving the legacy menu half-wired. **Resolved (v2.9.0):** `is_service_enabled()` — shared by both scripts — had a pre-check (`systemctl list-unit-files | grep -q "^${service}\s"`) @@ -1313,9 +1341,18 @@ full migration pass. ## Project Status & Future Plans -**Current Version:** 2.13.0 +**Current Version:** 2.14.0 -**Recent Updates (v2.13.0):** +**Recent Updates (v2.14.0):** +- **`./install.sh` now provisions a kiosk from scratch, not just manages an existing one.** Until now it only worked against an already-installed kiosk — `ubuntu-based-kiosk.sh` was still the only path from a bare Ubuntu Server box to a running one. On a machine with no kiosk-app directory yet, it now installs packages, creates the kiosk user, installs Node.js/Electron, sets up LightDM+Openbox autologin, audio/video/HDMI/power-button hardware handling, and the firewall, then hands off to the same Core Settings menus for initial configuration — matching the legacy script's own install-then-configure flow, on the modular codebase. +- **New: `lib/provision.sh`**, the provisioning steps — built almost entirely by calling menus already migrated below (`core_settings_menu`, emergency hotspot, virtual consoles) instead of reimplementing that configuration logic a third time. Reuse cut it down to roughly 300 lines against the legacy script's ~4,000-line `first_time_install()`. +- **New: `lib/electron.sh`** — `electron_install_binary()`, extracted out of `menus/advanced_electron.sh` so fresh provisioning and the existing "Fix blank screen" action share one implementation instead of two copies of the same repair sequence. +- **New: `kiosk-app/`** (the Electron app source — `main.js`, `preload.js`, the dialog HTML files, `package.json`, `start.sh`) and **`provision/files/`** (every other system template file — X11 configs, udev rules, systemd units, the power-button and HDMI-mirroring scripts, polkit rules), extracted byte-for-byte out of `ubuntu-based-kiosk.sh`'s heredocs into real files, laid out mirroring their real destination paths. +- **Bug found and fixed while writing this:** a bash `set -e` gotcha where testing a multi-statement function as an if-condition (`if ! some_func; then`) silently exempts everything inside that function from `set -e` for the duration of the call — found via direct testing, then swept for elsewhere in the codebase and also fixed in `menus/advanced_electron.sh`'s pre-existing "Fix blank screen" action, which had the same shape. +- **Known, deliberate limitation carried over unchanged:** a few of the extracted system scripts (`start.sh`, `kiosk-hotplug.sh`, the power-button handler) hardcode the username `kiosk` rather than substituting `$KIOSK_USER`, exactly as the legacy script's quoted heredocs always did. Only matters if `$KIOSK_USER` is overridden from its default, which in practice is rare. +- Upgrade and Full Reinstall are still not ported — both are coupled to `ubuntu-based-kiosk.sh`'s own heredoc self-extraction, a different mechanism than the new provisioning (which copies real files, not heredocs). `ubuntu-based-kiosk.sh` remains the way to upgrade/reinstall an existing install for now. + +**Previous (v2.13.0):** - **New: Clone Settings** (Advanced → Clone Settings) — not a port of the legacy Export/Import Settings, a narrower MVP for the "set up one kiosk, then stamp out a dozen more like it" use case. Exports the portable parts of `config.json` (sites, display/touch/navigation, lockout, password protection) to a JSON file; applies that file to any other already-installed kiosk. - **Deliberately does not copy machine-bound credentials**, because copying them would be actively wrong: Authelia's encrypted password is keyed off `/etc/machine-id` and decrypts to garbage on another machine; a WireGuard private key is a device identity, and reusing one across machines is a peer conflict, not a saving; most Asterisk PBXes reject two simultaneous registrations to the same extension. Applying a profile prints these as an explicit "needs a human" checklist instead of silently skipping or cloning them. - Records which addons were present at export time and reports which are/aren't present on the target — doesn't install anything itself. Non-interactive addon installation (so applying a profile needs zero prompts — scriptable over SSH to a whole fleet) is a deliberate follow-up, not bundled into this MVP. diff --git a/install.sh b/install.sh index 279673b..a94ec96 100755 --- a/install.sh +++ b/install.sh @@ -1,25 +1,28 @@ #!/bin/bash ################################################################################ -# install.sh - Modular management entry point for Ubuntu Based Kiosk. +# install.sh - Ubuntu Based Kiosk: install and manage, one entry point. # -# This is NOT yet the full system installer - that is still the big -# single-file script (ubuntu-based-kiosk.sh) documented in Readme.md, and -# first-time provisioning of a new kiosk still goes through it. That file -# still also contains its own (unmigrated, unmodified) copies of every -# menu below - both copies coexist deliberately until enough of Core -# Settings/Addons/Advanced has moved over to retire the old ones in one -# pass. This entry point is the modular replacement, one menus/*.sh file -# at a time, so a change to (say) the Sites menu can't accidentally break -# WiFi setup or the uninstaller three thousand lines away. +# On a bare Ubuntu Server box with no kiosk installed, this provisions +# one (lib/provision.sh) - packages, kiosk user, LightDM/Openbox, the +# Electron app, audio/video/power hardware setup - then hands off to the +# same Core Settings/Addons/Advanced menus below for initial +# configuration. On a machine that already has a kiosk, it skips +# straight to those menus. Same entry point either way. +# +# ubuntu-based-kiosk.sh, the original single-file installer, still +# exists and still works, but is no longer the only way to provision a +# new kiosk. Two things remain there that this tool deliberately doesn't +# reimplement: Upgrade and Full Reinstall, both coupled to that script's +# own heredoc self-extraction of main.js/preload.js/etc - a different +# mechanism than provisioning (which now copies real files from +# kiosk-app/ and provision/files/, not heredocs) and not yet ported. # # Migrated so far, grouped the same way the legacy menu groups them: # Core Settings: Sites & Page Timing, Display & Interaction, Timezone, # Hidden Site PIN, Password Protection & Lockout, WiFi, # Power/Display/Quiet Hours, Complete Uninstall # (menus/complete_uninstall.sh - composed from every addon's own -# uninstall helper rather than re-implementing removal a second -# time; Upgrade and Full Reinstall stay in the legacy script, both -# coupled to its heredoc self-extraction of main.js/preload.js/etc). +# uninstall helper rather than re-implementing removal a second time). # Addons: CUPS Printing (menus/addon_cups.sh), Authelia Auto-Login # (menus/addon_authelia.sh), Remote Access - VNC/WireGuard/ # Tailscale/Netbird (menus/addon_remote_access.sh), LMS Server / @@ -35,7 +38,7 @@ # several kiosks; deliberately excludes machine-bound credentials # like Authelia/WireGuard/Asterisk Intercom - see the file header). # -# Usage (once the kiosk has already been installed): +# Usage (works whether or not a kiosk is already installed): # git clone # cd ubuntu-based-kiosk # ./install.sh @@ -49,6 +52,8 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/lib/menu.sh" # shellcheck source=lib/config.sh source "$SCRIPT_DIR/lib/config.sh" +# shellcheck source=lib/electron.sh +source "$SCRIPT_DIR/lib/electron.sh" # shellcheck source=menus/sites.sh source "$SCRIPT_DIR/menus/sites.sh" # shellcheck source=menus/display.sh @@ -84,12 +89,18 @@ source "$SCRIPT_DIR/menus/advanced_virtual_consoles.sh" # shellcheck source=menus/advanced_emergency_hotspot.sh source "$SCRIPT_DIR/menus/advanced_emergency_hotspot.sh" # shellcheck source=menus/complete_uninstall.sh -# Sourced last: composes the *_do_uninstall/*_do_remove_all/*_do_disable -# helpers defined in every file above it. +# Sourced last among menus/*.sh: composes the *_do_uninstall/ +# *_do_remove_all/*_do_disable helpers defined in every file above it. source "$SCRIPT_DIR/menus/complete_uninstall.sh" # shellcheck source=menus/clone_settings.sh # Also composes detection helpers (*_is_installed) from every addon above. source "$SCRIPT_DIR/menus/clone_settings.sh" +# shellcheck source=lib/provision.sh +# Sourced last of all: calls into core_settings_menu and the Advanced +# actions below during first-time setup, so everything they depend on +# must already be defined by the time it actually runs (not merely +# sourced - bash resolves function calls at run time either way). +source "$SCRIPT_DIR/lib/provision.sh" ################################################################################ # Preflight @@ -110,17 +121,6 @@ if ! command -v jq &>/dev/null; then exit 1 fi -if ! is_kiosk_installed; then - echo - log_error "No installed kiosk found at ${KIOSK_DIR}." - echo - echo "This tool manages an already-installed kiosk. To provision a new" - echo "one for the first time, use the full installer instead - see" - echo "Readme.md ('Quick Install') for the current download command." - echo - exit 1 -fi - ################################################################################ # Top-level menu - grouped the same way the legacy menu groups them # (Core Settings / Addons / Advanced), so the structure stays familiar @@ -181,4 +181,29 @@ main_menu_status() { echo "Managing kiosk at: ${KIOSK_DIR}" } +################################################################################ +# Provision if there's nothing here yet, otherwise go straight to management. +################################################################################ + +if ! is_kiosk_installed; then + echo + echo "No installed kiosk found at ${KIOSK_DIR}." + echo "This will provision a new one on this machine." + echo + # Bare call, not `if run_first_time_install; then ...`: this is a + # large multi-step function, and testing it as an if-condition would + # exempt every step inside it from set -e for the duration - see the + # comment at its own call to provision_install_app for why that + # matters. Called bare, a real failure anywhere inside it halts the + # whole script immediately (set -e's normal behavior); reaching the + # lines below is itself proof every step succeeded. A declined + # install prints "Cancelled" from inside the function and returns + # non-zero, which the same bare-statement rule turns into a normal + # exit here - nothing further to print either way. + run_first_time_install + echo + echo "Run ./install.sh again to manage this kiosk." + exit 0 +fi + run_menu "UBUNTU BASED KIOSK - MANAGEMENT" main_menu_builder main_menu_status "Exit" diff --git a/kiosk-app/inactivity-prompt-extended.html b/kiosk-app/inactivity-prompt-extended.html new file mode 100644 index 0000000..759eddc --- /dev/null +++ b/kiosk-app/inactivity-prompt-extended.html @@ -0,0 +1,137 @@ + + + + + + +
+

👋 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) +
+
+ + + diff --git a/kiosk-app/keyboard.html b/kiosk-app/keyboard.html new file mode 100644 index 0000000..ee8a9f4 --- /dev/null +++ b/kiosk-app/keyboard.html @@ -0,0 +1,261 @@ + + + + + + + +
×
+ +
+
⌨️ 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
+
+
+ + + + diff --git a/kiosk-app/main.js b/kiosk-app/main.js new file mode 100644 index 0000000..93bf85a --- /dev/null +++ b/kiosk-app/main.js @@ -0,0 +1,1634 @@ +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(); +}); diff --git a/kiosk-app/package.json b/kiosk-app/package.json new file mode 100644 index 0000000..0b31721 --- /dev/null +++ b/kiosk-app/package.json @@ -0,0 +1,8 @@ +{ + "name": "kiosk-app", + "version": "1.0.0", + "main": "main.js", + "dependencies": { + "electron": "^42.0.0" + } +} diff --git a/kiosk-app/pause-dialog.html b/kiosk-app/pause-dialog.html new file mode 100644 index 0000000..074d9e1 --- /dev/null +++ b/kiosk-app/pause-dialog.html @@ -0,0 +1,119 @@ + + + + + + +
+

⏸️ 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...
+
+ + + + diff --git a/kiosk-app/pin-entry.html b/kiosk-app/pin-entry.html new file mode 100644 index 0000000..f638219 --- /dev/null +++ b/kiosk-app/pin-entry.html @@ -0,0 +1,139 @@ + + + + + + +
+

🔒 Enter PIN

+
••••
+
❌ Incorrect PIN
+
+ + + + + + + + + + + + +
+
+ + +
+
Default PIN: 1234 (4-8 digits)
+
+ + + diff --git a/kiosk-app/preload.js b/kiosk-app/preload.js new file mode 100644 index 0000000..afbd010 --- /dev/null +++ b/kiosk-app/preload.js @@ -0,0 +1,931 @@ +const {contextBridge,ipcRenderer}=require('electron'); + +console.log('════════════════════════════════════════════════════════════'); +console.log(' Gestures:'); +console.log(' 3-finger DOWN: Toggle hidden tabs (PIN required)'); +console.log(' 2-finger HORIZONTAL: Switch between sites'); +console.log(' 1-finger HORIZONTAL: Navigate within page'); +console.log(' Navigation: Top-left key icon for site menu'); +console.log('════════════════════════════════════════════════════════════'); + +contextBridge.exposeInMainWorld('electronAPI', { + notifyActivity: () => ipcRenderer.send('user-activity'), + showKeyboard: () => ipcRenderer.send('show-keyboard'), + closeKeyboard: () => ipcRenderer.send('close-keyboard'), + keyboardActivity: () => ipcRenderer.send('keyboard-activity'), + showPauseDialog: () => ipcRenderer.send('show-pause-dialog') +}); + +// Pause button state (MUST be outside DOMContentLoaded to persist across page loads) +let pauseButton=null; +let pauseButtonShouldShow=false; +let pauseButtonShown=false; +let pauseButtonHideTimer=null; +const PAUSE_BUTTON_HIDE_DELAY=5000; // Hide after 5 seconds of inactivity + +// Pause button functions (must be outside DOMContentLoaded for IPC listener) +function createPauseButton(){ + if(pauseButton)return; + + pauseButton=document.createElement('div'); + pauseButton.id='electron-pause-button'; + pauseButton.innerHTML='
'; + 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}); + }); +}); diff --git a/kiosk-app/start.sh b/kiosk-app/start.sh new file mode 100755 index 0000000..22844a5 --- /dev/null +++ b/kiosk-app/start.sh @@ -0,0 +1,30 @@ +#!/bin/bash +cd /home/kiosk/kiosk-app + +# Wait for network +for i in {1..30}; do + ping -c 1 -W 2 8.8.8.8 >/dev/null 2>&1 && break + sleep 2 +done + +export DISPLAY=:0 +export XAUTHORITY=/home/kiosk/.Xauthority +export ELECTRON_ENABLE_LOGGING=1 + +# Ensure PipeWire is running +systemctl --user is-active --quiet pipewire || systemctl --user start pipewire +systemctl --user is-active --quiet pipewire-pulse || systemctl --user start pipewire-pulse +systemctl --user is-active --quiet wireplumber || systemctl --user start wireplumber + +# Wait for PipeWire +for i in {1..10}; do + pactl info >/dev/null 2>&1 && break + sleep 1 +done + +exec node_modules/electron/dist/electron . \ + --no-sandbox --disable-gpu-sandbox --disable-dev-shm-usage \ + --enable-features=UseOzonePlatform --ozone-platform=x11 \ + --enable-audio-service-sandbox=false --autoplay-policy=no-user-gesture-required \ + --password-store=basic \ + 2>&1 | tee -a /home/kiosk/electron.log diff --git a/lib/electron.sh b/lib/electron.sh new file mode 100644 index 0000000..c125b2c --- /dev/null +++ b/lib/electron.sh @@ -0,0 +1,58 @@ +#!/bin/bash +################################################################################ +# lib/electron.sh - Electron binary install/repair, shared between fresh +# provisioning (lib/provision.sh) and ongoing maintenance +# (menus/advanced_electron.sh's "Fix blank screen" action) - the exact +# same repair sequence applies whether the binary never downloaded during +# `npm install` or went missing later. +# +# Depends on: lib/config.sh being sourced first (for $KIOSK_DIR/$KIOSK_USER). +################################################################################ + +# Re-verify/download the Electron binary and fix chrome-sandbox +# permissions, without touching package.json or reinstalling anything else. +electron_install_binary() { + local electron_bin="$KIOSK_DIR/node_modules/electron/dist/electron" + + if ! sudo -u "$KIOSK_USER" test -f "$electron_bin"; then + log_warning "Electron binary missing - retrying via install.js..." + sudo -u "$KIOSK_USER" bash -lc "cd '$KIOSK_DIR' && ELECTRON_FORCE_DOWNLOAD=true node node_modules/electron/install.js" || true + fi + + if ! sudo -u "$KIOSK_USER" test -f "$electron_bin"; then + log_warning "Attempting direct download of Electron binary (~120MB)..." + local electron_ver + electron_ver=$(sudo -u "$KIOSK_USER" node -e \ + "try{console.log(require('$KIOSK_DIR/node_modules/electron/package.json').version)}catch(e){}" 2>/dev/null || true) + if [[ -n "$electron_ver" ]]; then + local electron_url="https://github.com/electron/electron/releases/download/v${electron_ver}/electron-v${electron_ver}-linux-x64.zip" + log_info "Downloading Electron v${electron_ver} directly..." + local tmp_zip + tmp_zip=$(mktemp --suffix=.zip) + if wget --timeout=300 --tries=3 -O "$tmp_zip" "$electron_url"; then + command -v unzip &>/dev/null || sudo apt install -y unzip + chmod 644 "$tmp_zip" + sudo chown -R "$KIOSK_USER:$KIOSK_USER" "$KIOSK_DIR/node_modules/electron/" 2>/dev/null || true + sudo -u "$KIOSK_USER" mkdir -p "$KIOSK_DIR/node_modules/electron/dist" + sudo -u "$KIOSK_USER" unzip -o "$tmp_zip" -d "$KIOSK_DIR/node_modules/electron/dist/" || true + sudo -u "$KIOSK_USER" chmod +x "$electron_bin" || true + fi + rm -f "$tmp_zip" + fi + fi + + if ! sudo -u "$KIOSK_USER" test -f "$electron_bin"; then + log_error "Electron binary download failed after all attempts." + log_error "Check your internet connection and try again." + return 1 + fi + log_success "Electron binary verified" + + # chrome-sandbox MUST be owned by root and setuid, or Electron shows a blank screen. + local sandbox="$KIOSK_DIR/node_modules/electron/dist/chrome-sandbox" + if sudo -u "$KIOSK_USER" test -f "$sandbox"; then + sudo chown root:root "$sandbox" + sudo chmod 4755 "$sandbox" + log_success "Chrome sandbox permissions set (required for display)" + fi +} diff --git a/lib/provision.sh b/lib/provision.sh new file mode 100644 index 0000000..24fc64d --- /dev/null +++ b/lib/provision.sh @@ -0,0 +1,323 @@ +#!/bin/bash +################################################################################ +# lib/provision.sh - First-time kiosk provisioning: turns a bare Ubuntu +# Server box into a working kiosk. This is the piece the modular tool +# never had - everything else in menus/*.sh only manages a kiosk that +# already exists. +# +# The legacy ubuntu-based-kiosk.sh did this in one ~4,000-line function +# (first_time_install) that mixed three different things together: +# 1. ~2,900 lines of embedded app source (main.js, preload.js, 4 HTML +# dialogs, package.json, start.sh), written via `sudo tee ... <<'EOF'`. +# 2. A few hundred more lines of embedded system scripts/units/configs +# (HDMI mirroring, audio routing, hotplug udev rules, power button +# handling, etc), written the same way. +# 3. The actual provisioning logic - roughly 1,000 lines once (1) and +# (2) are out of the way. +# +# Every one of those embedded files used a quoted heredoc delimiter +# (<<'EOF', not < /etc/X11/xorg.conf.d/foo.conf). +# This function copies them into place instead of re-embedding them, and +# calls straight into the Core Settings / Addons / Advanced menus this +# tool already has for configuration - sites, timezone, touch/nav +# settings, password protection, WiFi, schedules, emergency hotspot, and +# virtual consoles are NOT reimplemented a third time here. +# +# One known, deliberate limitation carried over unchanged: several of +# the extracted system scripts (start.sh, kiosk-hotplug.sh, the power +# button handler) hardcode the username "kiosk" rather than using +# $KIOSK_USER, exactly as the legacy heredocs did (quoted heredocs can't +# substitute at write time either way). Fine for the common case since +# $KIOSK_USER is virtually never overridden outside this project's own +# tests, but a real gap if someone ever does. Not fixed here - fixing it +# means moving those scripts off static templates onto generated ones, +# which is more risk than this pass should take on. +# +# Depends on: lib/menu.sh, lib/config.sh, lib/electron.sh being sourced +# first, and every menus/*.sh this calls into for configuration. +################################################################################ + +PROVISION_FILES="$SCRIPT_DIR/provision/files" +KIOSK_APP_SRC="$SCRIPT_DIR/kiosk-app" + +# provision_install_file SRC_REL DEST [MODE] +# Copies a template from provision/files/SRC_REL to the real system path +# DEST (creating parent directories as needed) as root, mode 644 unless +# overridden - pass 755 for scripts, 750 for the openbox autostart. +provision_install_file() { + local src_rel="$1" dest="$2" mode="${3:-644}" + sudo install -D -m "$mode" "$PROVISION_FILES/$src_rel" "$dest" +} + +provision_install_packages() { + echo "[1/9] Installing packages..." + sudo apt update + sudo apt install -y \ + xorg openbox lightdm unclutter screen curl git build-essential \ + ca-certificates gnupg lsb-release jq ufw x11-xserver-utils xinput \ + vainfo mesa-utils libgl1-mesa-dri libglx-mesa0 mesa-vulkan-drivers \ + libva2 libva-drm2 libva-x11-2 mesa-va-drivers \ + libegl-mesa0 libegl1-mesa-dev libgles2-mesa-dev \ + pipewire pipewire-pulse pipewire-alsa wireplumber pipewire-audio-client-libraries alsa-utils libnotify-bin \ + gstreamer1.0-pipewire libspa-0.2-bluetooth \ + systemd-timesyncd acpid xbindkeys xdotool python3-evdev unzip \ + net-tools ncdu evtest + + if lspci | grep -i "VGA.*Intel" >/dev/null 2>&1; then + sudo apt install -y intel-gpu-tools xserver-xorg-video-intel \ + i965-va-driver intel-media-va-driver + provision_install_file "etc/X11/xorg.conf.d/20-intel.conf" /etc/X11/xorg.conf.d/20-intel.conf + fi + + sudo systemctl enable systemd-timesyncd + sudo systemctl start systemd-timesyncd + log_success "Packages installed, NTP time sync enabled" +} + +provision_create_kiosk_user() { + echo "[2/9] Creating kiosk user..." + if ! id "$KIOSK_USER" &>/dev/null; then + sudo useradd -m -s /bin/bash -G audio,video,input,plugdev,netdev "$KIOSK_USER" + echo "$KIOSK_USER:kiosk" | sudo chpasswd + log_success "Kiosk user created (default password: kiosk - change it)" + else + log_success "Kiosk user already exists" + fi + + sudo mkdir -p "$KIOSK_DIR" + sudo chown -R "$KIOSK_USER:$KIOSK_USER" "$KIOSK_HOME" + + sudo -u "$KIOSK_USER" mkdir -p "$KIOSK_HOME/.config/pipewire/pipewire.conf.d" + provision_install_file "pipewire/99-noise-cancellation.conf" \ + "$KIOSK_HOME/.config/pipewire/pipewire.conf.d/99-noise-cancellation.conf" + sudo chown -R "$KIOSK_USER:$KIOSK_USER" "$KIOSK_HOME/.config" +} + +provision_install_nodejs() { + echo "[3/9] Installing Node.js..." + if ! command -v node &>/dev/null; then + curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - + sudo apt install -y nodejs + fi + echo "Node.js: $(node -v)" +} + +provision_install_app() { + echo "[4/9] Installing kiosk app..." + sudo cp "$KIOSK_APP_SRC"/*.js "$KIOSK_APP_SRC"/*.html "$KIOSK_APP_SRC/package.json" "$KIOSK_APP_SRC/start.sh" "$KIOSK_DIR/" + sudo chown "$KIOSK_USER:$KIOSK_USER" "$KIOSK_DIR"/*.js "$KIOSK_DIR"/*.html "$KIOSK_DIR/package.json" "$KIOSK_DIR/start.sh" + sudo chmod +x "$KIOSK_DIR/start.sh" + + echo "Installing npm dependencies (Electron ~120MB - may take several minutes)..." + sudo -u "$KIOSK_USER" bash -lc " + npm config set fetch-timeout 600000 + npm config set fetch-retries 5 + npm config set fetch-retry-mintimeout 30000 + npm config set fetch-retry-maxtimeout 300000 + " + if ! sudo -u "$KIOSK_USER" bash -lc "cd '$KIOSK_DIR' && npm install --unsafe-perm"; then + log_error "npm install failed" + return 1 + fi + + electron_install_binary +} + +# Not moved to lib/electron.sh or anywhere else - this is the one piece +# of first-time setup with no modular equivalent to call into, and +# nothing else needs it. +provision_configure_lightdm_autologin() { + sudo mkdir -p /etc/lightdm/lightdm.conf.d + # nopasswdlogin is checked by PAM on Ubuntu 24.04; autologin group for older versions + sudo groupadd -f nopasswdlogin + sudo groupadd -f autologin + sudo usermod -aG nopasswdlogin,autologin "$KIOSK_USER" + # [Seat:*] works on all LightDM versions; [SeatDefaults] is ignored on newer Ubuntu + sudo tee /etc/lightdm/lightdm.conf.d/10-kiosk.conf > /dev/null </dev/null || true + provision_install_file "etc/acpi/events/kiosk-power-button" /etc/acpi/events/kiosk-power-button + provision_install_file "etc/acpi/events/kiosk-power-pbtn" /etc/acpi/events/kiosk-power-pbtn + provision_install_file "etc/acpi/events/kiosk-power-pwr" /etc/acpi/events/kiosk-power-pwr + + sudo mkdir -p /etc/systemd/logind.conf.d + provision_install_file "etc/systemd/logind.conf.d/power-button.conf" /etc/systemd/logind.conf.d/power-button.conf + + sudo systemctl daemon-reload + sudo systemctl restart systemd-logind + sudo systemctl enable acpid + sudo systemctl restart acpid + + sleep 2 + if systemctl is-active --quiet acpid; then + log_success "Power button configured" + else + log_warning "acpid may not be running properly - check: sudo systemctl status acpid" + fi +} + +# Configuration from here on is NOT reimplemented - it's the exact same +# Core Settings / Advanced menus this tool already uses to manage a +# kiosk after install, called directly instead of duplicated. +provision_configure_kiosk_settings() { + echo "[8/9] Configuring kiosk settings..." + echo "Core Settings is next - sites, timezone, touch/navigation," + echo "password protection, WiFi, and schedules. Skip and configure" + echo "later via ./install.sh if you'd rather do this after reboot." + echo + if ask_yes_no "Configure Core Settings now?" "y"; then + core_settings_menu + fi + + echo + echo "Emergency Hotspot auto-starts a WiFi hotspot if no internet is" + echo "detected after boot, so you can connect and reconfigure remotely." + if ask_yes_no "Configure emergency hotspot now?" "n"; then + action_configure_emergency_hotspot + else + log_info "Configure later: Advanced -> Emergency Hotspot" + fi + + echo + echo "Virtual consoles (Ctrl+Alt+F1-F8) are ENABLED by default." + if ! ask_yes_no "Keep virtual consoles enabled?" "y"; then + action_disable_virtual_consoles + fi +} + +provision_finish() { + echo "[9/9] Done." + echo + log_success "Core installation complete!" + echo + echo "Run ./install.sh again anytime to configure Core Settings, Addons" + echo "(CUPS, LMS/Squeezelite, Remote Access, Authelia, Asterisk Intercom)," + echo "or Advanced options." + echo + if ask_yes_no "Reboot now to start the kiosk?" "y"; then + echo "Rebooting in 3 seconds..." + sleep 3 + sudo reboot + else + log_warning "Remember to reboot before the kiosk will start: sudo reboot" + fi +} + +run_first_time_install() { + clear + echo "════════════════════════════════════════════════════════════" + echo " Ubuntu Based Kiosk - First-Time Installation" + echo "════════════════════════════════════════════════════════════" + echo + echo "This will install a HEADLESS KIOSK (no desktop environment):" + echo + echo "CORE:" + echo " - Kiosk user with auto-login" + echo " - LightDM + Openbox (minimal window manager)" + echo " - Electron browser" + echo " - Multi-site rotation with touch controls" + echo " - Hardware video acceleration" + echo " - Audio support (PipeWire)" + echo " - Time synchronization (NTP)" + echo + echo "OPTIONAL (configure after install, via Addons):" + echo " - Lyrion Music Server (LMS) / Squeezelite" + echo " - CUPS printing" + echo " - Remote desktop (VNC), VPN (WireGuard/Tailscale/Netbird)" + echo " - Authelia auto-login, Asterisk Intercom" + echo + ask_yes_no "Proceed with installation?" "y" || { echo "Cancelled"; return 1; } + + # Cache sudo credentials upfront so they don't expire mid-install. + sudo -v + + provision_install_packages + provision_create_kiosk_user + provision_install_nodejs + # Bare call, not `if ! provision_install_app; then ...`: testing a + # multi-statement function's result as an if-condition exempts + # everything *inside* that function from set -e for the duration - + # an early step failing (e.g. the `cp` before npm install even + # runs) would silently not stop the later steps. provision_install_app + # already reports its own npm-install failure via a guarded + # single-command `if`, which doesn't have this problem; letting its + # overall exit status propagate here as a bare statement preserves + # real fail-fast for every step in between. + provision_install_app + provision_configure_display + provision_configure_firewall + provision_configure_power_management + provision_configure_kiosk_settings + provision_finish + return 0 +} diff --git a/menus/advanced_electron.sh b/menus/advanced_electron.sh index 68e38f0..a0788f7 100644 --- a/menus/advanced_electron.sh +++ b/menus/advanced_electron.sh @@ -2,15 +2,18 @@ ################################################################################ # menus/advanced_electron.sh - "Electron Maintenance" (Advanced): the # legacy "Manual Electron Update" and "Fix Blank Screen" items, combined -# under one submenu since both maintain the same Electron installation -# and share the binary-repair logic (electron_install_binary). +# under one submenu since both maintain the same Electron installation. +# The binary-repair logic itself (electron_install_binary) now lives in +# lib/electron.sh, shared with fresh provisioning (lib/provision.sh) - +# the same repair sequence applies whether the binary never downloaded +# during the initial `npm install` or went missing later. # # Real system state: $KIOSK_DIR/node_modules, package.json, lightdm. # Every write goes through `sudo`/`sudo -u "$KIOSK_USER"`, stubbed at the # command level in tests - there's no relocatable equivalent for another # project's (npm/Electron's) own directory layout. # -# Depends on: lib/menu.sh, lib/config.sh being sourced first. +# Depends on: lib/menu.sh, lib/config.sh, lib/electron.sh being sourced first. ################################################################################ electron_installed_version() { @@ -35,55 +38,6 @@ electron_is_running() { pgrep -f "electron.*main.js" &>/dev/null || pgrep -f "node.*electron" &>/dev/null } -# Re-verify/download the Electron binary and fix chrome-sandbox -# permissions, without touching package.json or reinstalling anything -# else. Shared by both actions below. -electron_install_binary() { - local electron_bin="$KIOSK_DIR/node_modules/electron/dist/electron" - - if ! sudo -u "$KIOSK_USER" test -f "$electron_bin"; then - log_warning "Electron binary missing - retrying via install.js..." - sudo -u "$KIOSK_USER" bash -lc "cd '$KIOSK_DIR' && ELECTRON_FORCE_DOWNLOAD=true node node_modules/electron/install.js" || true - fi - - if ! sudo -u "$KIOSK_USER" test -f "$electron_bin"; then - log_warning "Attempting direct download of Electron binary (~120MB)..." - local electron_ver - electron_ver=$(sudo -u "$KIOSK_USER" node -e \ - "try{console.log(require('$KIOSK_DIR/node_modules/electron/package.json').version)}catch(e){}" 2>/dev/null || true) - if [[ -n "$electron_ver" ]]; then - local electron_url="https://github.com/electron/electron/releases/download/v${electron_ver}/electron-v${electron_ver}-linux-x64.zip" - log_info "Downloading Electron v${electron_ver} directly..." - local tmp_zip - tmp_zip=$(mktemp --suffix=.zip) - if wget --timeout=300 --tries=3 -O "$tmp_zip" "$electron_url"; then - command -v unzip &>/dev/null || sudo apt install -y unzip - chmod 644 "$tmp_zip" - sudo chown -R "$KIOSK_USER:$KIOSK_USER" "$KIOSK_DIR/node_modules/electron/" 2>/dev/null || true - sudo -u "$KIOSK_USER" mkdir -p "$KIOSK_DIR/node_modules/electron/dist" - sudo -u "$KIOSK_USER" unzip -o "$tmp_zip" -d "$KIOSK_DIR/node_modules/electron/dist/" || true - sudo -u "$KIOSK_USER" chmod +x "$electron_bin" || true - fi - rm -f "$tmp_zip" - fi - fi - - if ! sudo -u "$KIOSK_USER" test -f "$electron_bin"; then - log_error "Electron binary download failed after all attempts." - log_error "Check your internet connection and try again." - return 1 - fi - log_success "Electron binary verified" - - # chrome-sandbox MUST be owned by root and setuid, or Electron shows a blank screen. - local sandbox="$KIOSK_DIR/node_modules/electron/dist/chrome-sandbox" - if sudo -u "$KIOSK_USER" test -f "$sandbox"; then - sudo chown root:root "$sandbox" - sudo chmod 4755 "$sandbox" - log_success "Chrome sandbox permissions set (required for display)" - fi -} - advanced_electron_status() { local ver ver=$(electron_installed_version) @@ -258,7 +212,16 @@ action_repair_electron() { sudo systemctl stop lightdm 2>/dev/null || true sleep 1 - if ! electron_install_binary; then + # Bare call, not `if ! electron_install_binary; then`: testing a + # multi-statement function as an if-condition exempts everything + # inside it from set -e for the duration (e.g. the sandbox chown/ + # chmod below would silently continue past an earlier failure). + # Capturing $? right after a bare call doesn't have that problem - + # the exemption only affects whether a nonzero status halts the + # script, never the actual value $? holds. + electron_install_binary + local electron_rc=$? + if [[ $electron_rc -ne 0 ]]; then log_error "Could not install Electron. Check internet and retry." pause return 1 diff --git a/provision/files/etc/X11/xorg.conf.d/10-serverflags.conf b/provision/files/etc/X11/xorg.conf.d/10-serverflags.conf new file mode 100644 index 0000000..87447bb --- /dev/null +++ b/provision/files/etc/X11/xorg.conf.d/10-serverflags.conf @@ -0,0 +1,10 @@ +Section "ServerFlags" + # Disable Ctrl+Alt+Backspace (X server kill) + Option "DontZap" "true" + + # Disable VT switching (Ctrl+Alt+F1-F12) + Option "DontVTSwitch" "true" + + # Don't allow clients to disconnect on exit + Option "AllowClosedownGrabs" "false" +EndSection diff --git a/provision/files/etc/X11/xorg.conf.d/20-intel.conf b/provision/files/etc/X11/xorg.conf.d/20-intel.conf new file mode 100644 index 0000000..6755ac5 --- /dev/null +++ b/provision/files/etc/X11/xorg.conf.d/20-intel.conf @@ -0,0 +1,7 @@ +Section "Device" + Identifier "Intel Graphics" + Driver "intel" + Option "AccelMethod" "sna" + Option "TearFree" "true" + Option "DRI" "3" +EndSection diff --git a/provision/files/etc/X11/xorg.conf.d/99-finger-libinput.conf b/provision/files/etc/X11/xorg.conf.d/99-finger-libinput.conf new file mode 100644 index 0000000..437ba28 --- /dev/null +++ b/provision/files/etc/X11/xorg.conf.d/99-finger-libinput.conf @@ -0,0 +1,5 @@ +Section "InputClass" + Identifier "Touch screen use libinput" + MatchIsTouchscreen "on" + Driver "libinput" +EndSection diff --git a/provision/files/etc/acpi/events/kiosk-power-button b/provision/files/etc/acpi/events/kiosk-power-button new file mode 100644 index 0000000..9feae1d --- /dev/null +++ b/provision/files/etc/acpi/events/kiosk-power-button @@ -0,0 +1,2 @@ +event=button/power.* +action=/usr/local/bin/kiosk-power-button.sh diff --git a/provision/files/etc/acpi/events/kiosk-power-pbtn b/provision/files/etc/acpi/events/kiosk-power-pbtn new file mode 100644 index 0000000..2dcd9e5 --- /dev/null +++ b/provision/files/etc/acpi/events/kiosk-power-pbtn @@ -0,0 +1,2 @@ +event=button/power PBTN +action=/usr/local/bin/kiosk-power-button.sh diff --git a/provision/files/etc/acpi/events/kiosk-power-pwr b/provision/files/etc/acpi/events/kiosk-power-pwr new file mode 100644 index 0000000..73bb80a --- /dev/null +++ b/provision/files/etc/acpi/events/kiosk-power-pwr @@ -0,0 +1,2 @@ +event=button/power PWRF +action=/usr/local/bin/kiosk-power-button.sh diff --git a/provision/files/etc/polkit-1/localauthority/50-local.d/kiosk-power.pkla b/provision/files/etc/polkit-1/localauthority/50-local.d/kiosk-power.pkla new file mode 100644 index 0000000..2935752 --- /dev/null +++ b/provision/files/etc/polkit-1/localauthority/50-local.d/kiosk-power.pkla @@ -0,0 +1,6 @@ +[Allow kiosk power operations] +Identity=unix-user:kiosk +Action=org.freedesktop.login1.power-off;org.freedesktop.login1.power-off-multiple-sessions;org.freedesktop.login1.reboot;org.freedesktop.login1.reboot-multiple-sessions;org.freedesktop.login1.suspend;org.freedesktop.login1.suspend-multiple-sessions +ResultAny=yes +ResultInactive=yes +ResultActive=yes diff --git a/provision/files/etc/systemd/logind.conf.d/power-button.conf b/provision/files/etc/systemd/logind.conf.d/power-button.conf new file mode 100644 index 0000000..b7dc0e9 --- /dev/null +++ b/provision/files/etc/systemd/logind.conf.d/power-button.conf @@ -0,0 +1,6 @@ +[Login] +HandlePowerKey=ignore +HandlePowerKeyLongPress=poweroff +HandleSuspendKey=ignore +HandleHibernateKey=ignore +HandleLidSwitch=ignore diff --git a/provision/files/etc/systemd/system/kiosk-hotplug.service b/provision/files/etc/systemd/system/kiosk-hotplug.service new file mode 100644 index 0000000..647cbb6 --- /dev/null +++ b/provision/files/etc/systemd/system/kiosk-hotplug.service @@ -0,0 +1,8 @@ +[Unit] +Description=Kiosk Display Hotplug Handler + +[Service] +Type=oneshot +ExecStart=/usr/local/bin/kiosk-hotplug.sh +StandardOutput=journal +StandardError=journal diff --git a/provision/files/etc/udev/rules.d/99-kiosk-hotplug.rules b/provision/files/etc/udev/rules.d/99-kiosk-hotplug.rules new file mode 100644 index 0000000..0b09b03 --- /dev/null +++ b/provision/files/etc/udev/rules.d/99-kiosk-hotplug.rules @@ -0,0 +1 @@ +SUBSYSTEM=="drm", ACTION=="change", TAG+="systemd", ENV{SYSTEMD_WANTS}="kiosk-hotplug.service" diff --git a/provision/files/openbox/autostart b/provision/files/openbox/autostart new file mode 100755 index 0000000..2ae03fc --- /dev/null +++ b/provision/files/openbox/autostart @@ -0,0 +1,143 @@ +#!/bin/bash + +# Mirror any connected external display (e.g. HDMI-out to a monitor/TV) onto +# the primary display, forcing it to the primary's exact resolution. +/usr/local/bin/kiosk-mirror-display.sh + +# AGGRESSIVE DPMS disable - multiple methods +xset s off +xset s noblank +xset -dpms +xset s 0 0 +xset dpms 0 0 0 +xset dpms force on + +# Keep screen on forever - watchdog (schedule-aware) +( + while true; do + sleep 300 # Every 5 minutes + + # Check if display schedule is active + schedule_active=false + if systemctl is-active --quiet kiosk-display-off.timer && systemctl is-active --quiet kiosk-display-on.timer; then + # Get display off and on times from systemd timers + doff=$(systemctl cat kiosk-display-off.timer 2>/dev/null | grep "^OnCalendar=" | sed 's/.*\*-\*-\* //' | sed 's/:00$//') + don=$(systemctl cat kiosk-display-on.timer 2>/dev/null | grep "^OnCalendar=" | sed 's/.*\*-\*-\* //' | sed 's/:00$//') + + if [ -n "$doff" ] && [ -n "$don" ]; then + # Get current time in HH:MM format + current_time=$(date +%H:%M) + + # Convert times to minutes since midnight for comparison + # Using 10# prefix to force decimal interpretation (fixes octal bug for 08:xx and 09:xx times) + current_mins=$(( 10#$(date +%H) * 60 + 10#$(date +%M) )) + off_mins=$(( 10#$(echo "$doff" | cut -d: -f1) * 60 + 10#$(echo "$doff" | cut -d: -f2) )) + on_mins=$(( 10#$(echo "$don" | cut -d: -f1) * 60 + 10#$(echo "$don" | cut -d: -f2) )) + + # Check if we're in the "display off" window + if [ "$off_mins" -lt "$on_mins" ]; then + # Normal case: off time is before on time (e.g., 22:00 to 06:00 next day) + if [ "$current_mins" -ge "$off_mins" ] && [ "$current_mins" -lt "$on_mins" ]; then + schedule_active=true + fi + else + # Overnight case: off time is after on time (e.g., 06:00 to 22:00) + if [ "$current_mins" -ge "$off_mins" ] || [ "$current_mins" -lt "$on_mins" ]; then + schedule_active=true + fi + fi + fi + fi + + # Only force display on if NOT in scheduled off period + if [ "$schedule_active" = "false" ]; then + xset s reset 2>/dev/null + xset dpms force on 2>/dev/null + fi + done +) & + +# Start PipeWire user services +systemctl --user start pipewire pipewire-pulse wireplumber +sleep 3 + +# Wait for PipeWire to be ready +for i in {1..15}; do + pactl info >/dev/null 2>&1 && break + sleep 1 +done + +# Wait for ALSA devices +for i in {1..10}; do + pactl list sinks short | grep -q alsa && break + sleep 1 +done + +# Route audio to HDMI if an external display is connected/mirrored, else built-in +/usr/local/bin/kiosk-audio-route.sh + +# Set audio levels (speakers 100%, mic 100%, mic unmuted) +pactl set-sink-volume @DEFAULT_SINK@ 100% +pactl set-source-volume @DEFAULT_SOURCE@ 100% +pactl set-source-mute @DEFAULT_SOURCE@ 0 + +# Audio watchdog - checks every 30 seconds (quiet hours aware) +( + while true; do + sleep 30 + + # Check if quiet hours is active + quiet_active=false + if systemctl is-active --quiet kiosk-quiet-start.timer && systemctl is-active --quiet kiosk-quiet-end.timer; then + # Get quiet hours start and end times from systemd timers + qstart=$(systemctl cat kiosk-quiet-start.timer 2>/dev/null | grep "^OnCalendar=" | sed 's/.*\*-\*-\* //' | sed 's/:00$//') + qend=$(systemctl cat kiosk-quiet-end.timer 2>/dev/null | grep "^OnCalendar=" | sed 's/.*\*-\*-\* //' | sed 's/:00$//') + + if [ -n "$qstart" ] && [ -n "$qend" ]; then + # Convert times to minutes since midnight for comparison + # Using 10# prefix to force decimal interpretation (fixes octal bug for 08:xx and 09:xx times) + current_mins=$(( 10#$(date +%H) * 60 + 10#$(date +%M) )) + start_mins=$(( 10#$(echo "$qstart" | cut -d: -f1) * 60 + 10#$(echo "$qstart" | cut -d: -f2) )) + end_mins=$(( 10#$(echo "$qend" | cut -d: -f1) * 60 + 10#$(echo "$qend" | cut -d: -f2) )) + + # Check if we're in quiet hours window + if [ "$start_mins" -lt "$end_mins" ]; then + # Normal case: start time is before end time (e.g., 08:00 to 17:00) + if [ "$current_mins" -ge "$start_mins" ] && [ "$current_mins" -lt "$end_mins" ]; then + quiet_active=true + fi + else + # Overnight case: start time is after end time (e.g., 22:00 to 07:00) + if [ "$current_mins" -ge "$start_mins" ] || [ "$current_mins" -lt "$end_mins" ]; then + quiet_active=true + fi + fi + fi + fi + + # Check if PipeWire is running + if ! pactl info >/dev/null 2>&1; then + logger "KIOSK: Audio dead, restarting PipeWire" + systemctl --user restart pipewire pipewire-pulse wireplumber + sleep 5 + # Only restore audio levels if NOT in quiet hours + if [ "$quiet_active" = "false" ]; then + pactl set-sink-volume @DEFAULT_SINK@ 100% + pactl set-source-volume @DEFAULT_SOURCE@ 100% + pactl set-source-mute @DEFAULT_SOURCE@ 0 + fi + fi + + done +) & + +# Other services +unclutter -idle 0.1 -root & +XDG_RUNTIME_DIR=/run/user/$(id -u) xbindkeys & + +# Create boot flag for password requirement on boot +touch /home/kiosk/kiosk-app/.boot-flag + +# Start kiosk app AFTER audio is ready +sleep 2 +/home/kiosk/kiosk-app/start.sh & diff --git a/provision/files/pipewire/99-noise-cancellation.conf b/provision/files/pipewire/99-noise-cancellation.conf new file mode 100644 index 0000000..57011a9 --- /dev/null +++ b/provision/files/pipewire/99-noise-cancellation.conf @@ -0,0 +1,33 @@ +# PipeWire noise cancellation configuration for kiosk microphone +# This creates a virtual source with echo cancellation and noise suppression + +context.modules = [ + { name = libpipewire-module-echo-cancel + args = { + # audio.channels = 1 + # capture.props = { + # node.name = "Echo Cancellation Capture" + # } + # source.props = { + # node.name = "Echo Cancellation Source" + # node.description = "Noise-Cancelled Microphone" + # } + # sink.props = { + # node.name = "Echo Cancellation Sink" + # } + # playback.props = { + # node.name = "Echo Cancellation Playback" + # } + aec.method = webrtc + aec.args = { + # WebRTC audio processing settings + webrtc.gain_control = true + webrtc.extended_filter = true + webrtc.high_pass_filter = true + webrtc.noise_suppression = true + webrtc.noise_suppression_level = 3 + webrtc.voice_detection = true + } + } + } +] diff --git a/provision/files/usr/local/bin/kiosk-audio-route.sh b/provision/files/usr/local/bin/kiosk-audio-route.sh new file mode 100755 index 0000000..c162423 --- /dev/null +++ b/provision/files/usr/local/bin/kiosk-audio-route.sh @@ -0,0 +1,36 @@ +#!/bin/bash +# Assumes DISPLAY/XAUTHORITY are set (for xrandr) and pactl already has a +# working PipeWire/pulse socket for the invoking context. + +QUERY=$(xrandr --query 2>/dev/null) +[ -z "$QUERY" ] && exit 0 + +PRIMARY_OUTPUT=$(echo "$QUERY" | awk '/ primary/{print $1; exit}') +[ -z "$PRIMARY_OUTPUT" ] && exit 0 + +EXTERNAL_CONNECTED=$(echo "$QUERY" | awk -v p="$PRIMARY_OUTPUT" '/ connected/ && $1!=p{f=1} END{print (f==1)?"yes":"no"}') + +HDMI_SINK=$(pactl list sinks short 2>/dev/null | awk 'tolower($2) ~ /hdmi/{print $2; exit}') +NON_HDMI_SINK=$(pactl list sinks short 2>/dev/null | awk 'tolower($2) !~ /hdmi/{print $2; exit}') + +route_to() { + local sink="$1" label="$2" + if [ -z "$sink" ]; then + logger "KIOSK: no $label audio sink found, leaving routing unchanged" + return + fi + pactl set-default-sink "$sink" 2>/dev/null \ + && logger "KIOSK: audio routed to $label sink ($sink)" \ + || logger "KIOSK: failed to route audio to $label sink ($sink)" + pactl list sink-inputs short 2>/dev/null | awk '{print $1}' | while read -r sid; do + pactl move-sink-input "$sid" "$sink" 2>/dev/null + done + pactl set-sink-volume "$sink" 100% 2>/dev/null + pactl set-sink-mute "$sink" 0 2>/dev/null +} + +if [ "$EXTERNAL_CONNECTED" = "yes" ]; then + route_to "$HDMI_SINK" "HDMI" +else + route_to "$NON_HDMI_SINK" "built-in" +fi diff --git a/provision/files/usr/local/bin/kiosk-hotplug.sh b/provision/files/usr/local/bin/kiosk-hotplug.sh new file mode 100755 index 0000000..55f4208 --- /dev/null +++ b/provision/files/usr/local/bin/kiosk-hotplug.sh @@ -0,0 +1,7 @@ +#!/bin/bash +# Give X a moment to finish enumerating the output after the hotplug event +sleep 2 +sudo -u kiosk DISPLAY=:0 XAUTHORITY=/home/kiosk/.Xauthority /usr/local/bin/kiosk-mirror-display.sh + +kiosk_uid=$(id -u kiosk) +sudo -u kiosk DISPLAY=:0 XAUTHORITY=/home/kiosk/.Xauthority XDG_RUNTIME_DIR="/run/user/${kiosk_uid}" /usr/local/bin/kiosk-audio-route.sh diff --git a/provision/files/usr/local/bin/kiosk-mirror-display.sh b/provision/files/usr/local/bin/kiosk-mirror-display.sh new file mode 100755 index 0000000..456038f --- /dev/null +++ b/provision/files/usr/local/bin/kiosk-mirror-display.sh @@ -0,0 +1,48 @@ +#!/bin/bash +# Assumes it's run with DISPLAY/XAUTHORITY already set for the kiosk user's +# X session (either inherited, as from Openbox autostart, or exported by the +# caller). Mirrors every connected non-primary output at the primary's exact +# current resolution so kiosk content isn't cropped/letterboxed/blank on a +# TV/monitor with a different native resolution than the kiosk panel. + +QUERY=$(xrandr --query 2>/dev/null) +[ -z "$QUERY" ] && exit 0 + +PRIMARY_OUTPUT=$(echo "$QUERY" | awk '/ primary/{print $1; exit}') +[ -z "$PRIMARY_OUTPUT" ] && exit 0 + +PRIMARY_RES=$(echo "$QUERY" | awk -v p="$PRIMARY_OUTPUT" '$1==p{for(i=1;i<=NF;i++) if ($i ~ /^[0-9]+x[0-9]+\+/){split($i,a,"+"); print a[1]; exit}}') +[ -z "$PRIMARY_RES" ] && exit 0 + +for OUT in $(echo "$QUERY" | awk '/ connected/{print $1}'); do + [ "$OUT" = "$PRIMARY_OUTPUT" ] && continue + + HAS_NATIVE=$(echo "$QUERY" | awk -v out="$OUT" -v res="$PRIMARY_RES" ' + $0 ~ "^"out" " {infound=1; next} + /^[^ \t]/ {infound=0} + infound && $1==res {print "yes"; exit} + ') + + if [ "$HAS_NATIVE" = "yes" ]; then + xrandr --output "$OUT" --mode "$PRIMARY_RES" --same-as "$PRIMARY_OUTPUT" 2>/dev/null \ + && logger "KIOSK: mirrored $OUT at native $PRIMARY_RES" \ + || logger "KIOSK: mirror of $OUT at $PRIMARY_RES failed" + continue + fi + + # $OUT doesn't natively list the primary's resolution - force a matching mode + CVT_LINE=$(cvt "${PRIMARY_RES%x*}" "${PRIMARY_RES#*x}" 2>/dev/null | grep Modeline) + MODENAME=$(echo "$CVT_LINE" | sed -n 's/^Modeline "\([^"]*\)".*/\1/p') + TIMINGS=$(echo "$CVT_LINE" | sed -n 's/^Modeline "[^"]*" *//p') + + if [ -z "$MODENAME" ] || [ -z "$TIMINGS" ]; then + logger "KIOSK: could not generate a $PRIMARY_RES mode for $OUT (cvt failed or missing)" + continue + fi + + xrandr --newmode "$MODENAME" $TIMINGS 2>/dev/null + xrandr --addmode "$OUT" "$MODENAME" 2>/dev/null + xrandr --output "$OUT" --mode "$MODENAME" --same-as "$PRIMARY_OUTPUT" 2>/dev/null \ + && logger "KIOSK: mirrored $OUT at forced $PRIMARY_RES ($MODENAME)" \ + || logger "KIOSK: mirror of $OUT at forced $PRIMARY_RES failed" +done diff --git a/provision/files/usr/local/bin/kiosk-power-button.sh b/provision/files/usr/local/bin/kiosk-power-button.sh new file mode 100755 index 0000000..f94f9a9 --- /dev/null +++ b/provision/files/usr/local/bin/kiosk-power-button.sh @@ -0,0 +1,21 @@ +#!/bin/bash +# Power button handler - sends SIGUSR1 to Electron to show power menu +# This runs as ROOT from acpid, so it can signal any process + +logger "KIOSK POWER: Button pressed" + +# Find the Electron main process (runs as kiosk user) +PIDS=$(pgrep -u kiosk -f "electron" 2>/dev/null) + +if [ -z "$PIDS" ]; then + logger "KIOSK POWER: No Electron process found" + exit 1 +fi + +# Send SIGUSR1 to all Electron processes (the main one will handle it) +for PID in $PIDS; do + logger "KIOSK POWER: Sending SIGUSR1 to PID $PID" + kill -USR1 $PID 2>/dev/null +done + +logger "KIOSK POWER: Signal sent" diff --git a/provision/files/usr/local/bin/kiosk-volume-down b/provision/files/usr/local/bin/kiosk-volume-down new file mode 100755 index 0000000..3ec4e18 --- /dev/null +++ b/provision/files/usr/local/bin/kiosk-volume-down @@ -0,0 +1,5 @@ +#!/bin/bash +CURRENT=$(pactl get-sink-volume @DEFAULT_SINK@ | grep -oE '[0-9]+%' | head -1 | tr -d '%') +NEW=$((CURRENT - 5)) +[[ $NEW -lt 0 ]] && NEW=0 +pactl set-sink-volume @DEFAULT_SINK@ ${NEW}% diff --git a/provision/files/usr/local/bin/kiosk-volume-up b/provision/files/usr/local/bin/kiosk-volume-up new file mode 100755 index 0000000..d987b60 --- /dev/null +++ b/provision/files/usr/local/bin/kiosk-volume-up @@ -0,0 +1,5 @@ +#!/bin/bash +CURRENT=$(pactl get-sink-volume @DEFAULT_SINK@ | grep -oE '[0-9]+%' | head -1 | tr -d '%') +NEW=$((CURRENT + 5)) +[[ $NEW -gt 100 ]] && NEW=100 +pactl set-sink-volume @DEFAULT_SINK@ ${NEW}% diff --git a/provision/files/usr/local/bin/test-power-button b/provision/files/usr/local/bin/test-power-button new file mode 100755 index 0000000..850cf7f --- /dev/null +++ b/provision/files/usr/local/bin/test-power-button @@ -0,0 +1,51 @@ +#!/bin/bash +echo "Testing power button configuration..." +echo + +echo "1. Checking acpid service..." +if systemctl is-active --quiet acpid; then + echo " ✓ acpid is running" +else + echo " ✗ acpid is NOT running" + echo " Fix: sudo systemctl enable --now acpid" +fi +echo + +echo "2. Checking ACPI event handler..." +if [ -f /etc/acpi/events/kiosk-power-button ]; then + echo " ✓ Event handler exists" + cat /etc/acpi/events/kiosk-power-button +else + echo " ✗ Event handler not found" +fi +echo + +echo "3. Checking power button script..." +if [ -x /usr/local/bin/kiosk-power-button.sh ]; then + echo " ✓ Script exists and is executable" +else + echo " ✗ Script not found or not executable" +fi +echo + +echo "4. Checking Electron process..." +PIDS=$(pgrep -u kiosk -f "electron" 2>/dev/null) +if [ -n "$PIDS" ]; then + echo " ✓ Found Electron PIDs: $PIDS" +else + echo " ✗ No Electron process found" +fi +echo + +echo "5. Testing power button trigger NOW..." +if [ -x /usr/local/bin/kiosk-power-button.sh ]; then + echo " Running: /usr/local/bin/kiosk-power-button.sh" + /usr/local/bin/kiosk-power-button.sh + echo " Check if power menu appeared!" +else + echo " Script not found" +fi +echo + +echo "6. To watch ACPI events: sudo acpi_listen" +echo " Then press power button and look for 'button/power' events" diff --git a/ubuntu-based-kiosk.sh b/ubuntu-based-kiosk.sh index 9ebae3a..709a231 100644 --- a/ubuntu-based-kiosk.sh +++ b/ubuntu-based-kiosk.sh @@ -1,8 +1,55 @@ #!/bin/bash ################################################################################ -### Ubuntu Based Kiosk v2.13.0 ### +### Ubuntu Based Kiosk v2.14.0 ### ################################################################################ # +# 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 @@ -474,7 +521,7 @@ set -euo pipefail ### SECTION 1: CONSTANTS & GLOBALS ################################################################################ -SCRIPT_VERSION="2.13.0" +SCRIPT_VERSION="2.14.0" # Resolve the real path to this script file. # When piped (curl|bash or wget|bash), BASH_SOURCE[0] is a pipe descriptor,