Two more menus migrated onto lib/menu.sh + lib/config.sh, chosen
specifically because neither touches config.json - a third and fourth
shape for the framework (a system command via timedatectl, and a flat
PIN file), on top of Sites' list CRUD and Display's JSON toggles.
- menus/timezone.sh: also replaces the legacy script's hand-numbered
18-entry case statement with a plain data list (TIMEZONE_COMMON_ZONES)
plus one handler that reads the number run_menu hands it - adding or
removing a zone never touches numbering anywhere else. Required a
small run_menu addition: handlers now receive the chosen 1-based
number as $1, so one handler can serve a whole data-driven list
instead of needing a wrapper function per entry.
- menus/hidden_pin.sh: set/disable/reset the PIN gating hidden pages.
Testing menus/timezone.sh surfaced a real bug before it ever shipped:
this whole tool runs under `set -e`, and set_timezone() rejecting an
invalid zone via a bare `return 1` as its last statement took down the
*entire* install.sh session, not just that one action - a single typo
would silently drop the user back to their shell. Fixed at the
framework level in lib/menu.sh (run_menu now absorbs a failed handler's
exit code) rather than patching set_timezone alone, since any future
menu could hit the same trap. Verified against the real install.sh as a
genuine non-root user: an invalid timezone now logs an error and
redraws the Timezone menu instead of killing the session (confirmed
exit code 0 at the end of the run). Note this specific hazard was
introduced by this session's own return-1 idiom, not inherited from the
legacy script, which never uses a bare return 1 in these functions.
Also per the user: left the old configure_sites/configure_touch_controls/
configure_navigation_security/configure_optional_features functions in
ubuntu-based-kiosk.sh untouched for now (still carrying the v2.0.0
settings-clobber and reorder bugs) rather than removing them - they'll
be retired in one pass once enough of Core Settings/Addons/Advanced is
migrated. Bumped SCRIPT_VERSION to 2.1.0 with matching changelog entries
in the script header and Readme, and updated the Readme's "Modular
Management" section to state plainly what is and isn't migrated yet.
Verified:
- Full regression: re-ran the Sites and Display scratch-config test
suites against the updated run_menu signature - both still clean.
- New scratch-config tests for hidden_pin.sh (set/mismatch/reject/
disable/reset, correct file permissions) and timezone.sh (builder
entry count, common-zone pick by index, manual entry with legacy
US/* alias normalization, region search + cancel, invalid-zone
rejection) - all correct, with timedatectl/sudo stubbed only where
needed to avoid mutating this sandbox's real system clock/timezone.
- End-to-end: ran the real install.sh as a genuine non-root, non-"kiosk"
user, navigating Timezone -> manual entry -> invalid zone -> confirmed
no crash and a normal return to the menu, then Hidden Site PIN -> set
a PIN -> confirmed the file on disk (mode 600, correct content) ->
clean exit (code 0).
- menus/display.sh: second menu migrated onto lib/menu.sh + lib/config.sh,
covering touch gesture mode, link navigation security, and the
pause/keyboard/navigation button toggles (previously three separate
Core Settings entries). Deliberately a different shape from Sites
(toggle list vs. list CRUD) to exercise the framework more broadly.
Wired into install.sh's top-level menu alongside Sites.
- Renamed ubuntu-based-kiosk-v1.0.3.sh -> ubuntu-based-kiosk.sh so the
installer can be updated in place instead of growing a new
version-numbered filename every release; released versions are now
tracked via git history and the in-script changelog. Updated all
Readme download/re-run commands accordingly. Older versioned files
(ubuntu-based-kiosk-v*.sh, install_kiosk_*.sh) are left in place as
archived releases.
- Bumped SCRIPT_VERSION to 2.0.0 (new script-level changelog entry) and
the Readme version/changelog to match, given the new modular
management path, the rename, and the two real bugs fixed along the
way (settings clobbered on save, off-by-one in reorder).
Verified before moving on to the web admin work:
- Regression: re-ran the full Sites scratch-config test suite (add,
edit, delete, reorder, home) - still clean, no invalid-input paths hit.
- New: scratch-config test for every display.sh action (touch mode,
navigation security, all three toggles), confirming values persist
through save/reload and that a previously-added site survives
untouched across Display-menu saves.
- End-to-end: ran the real install.sh (not just sourced functions) as a
genuine non-root, non-"kiosk" user with real sudo, driving actual menu
input through Sites -> add a page -> Display -> toggle a setting ->
exit. Confirmed final config.json on disk matches every action taken,
and both guard clauses (run as root; no installed kiosk found) fire
correctly.
Start of pulling the menu system out of the 12k-line single-file
installer so individual menus can change without risking the rest of
the script (network, VNC, addons, etc). This is groundwork for the
planned web-based management UI, which will share the same lib/config.sh
read/write layer instead of duplicating it.
- lib/menu.sh: generic numbered-menu framework (auto-numbered entries,
"0" always exits/returns) plus the validated input helpers menus need.
- lib/config.sh: single load/save for config.json. Fixes a latent bug
where the old Sites menu wrote config.json without first loading
swipe/navigation/lockout settings, silently resetting them to
defaults on save.
- menus/sites.sh: Sites & Page Timing fully migrated - add/edit/delete/
reorder pages, set duration (auto-rotate/manual/hidden) and home page.
Also fixes an off-by-one in the ported reorder logic (moving an item
landed one slot short of the requested position) caught by testing.
- install.sh: new entry point for managing an already-installed kiosk
via `git clone` + `./install.sh`, wired to the Sites menu. Does not
yet replace first-time provisioning, which still uses the existing
single-file installer.
All new site CRUD/reorder/home-page paths were exercised against a
scratch config.json (add with/without basic auth, edit duration, set
home + timeout, 2- and 3-item reorders in both directions, delete) to
confirm the resulting config.json matches expectations.
Previously nothing switched PipeWire's default sink, so audio always stayed on the laptop/built-in speakers regardless of HDMI mirroring. Adds /usr/local/bin/kiosk-audio-route.sh, which finds a sink whose name contains 'hdmi' and makes it the default (moving any already-playing streams onto it) whenever an external display is connected, falling back to the non-HDMI sink when it isn't. Called from autostart once PipeWire is confirmed ready, and from kiosk-hotplug.sh alongside the existing display mirroring on every plug/unplug event. Documents the behavior and manual fallback in the README.
Previously mirroring used 'xrandr --auto', which picks the external output's own native resolution (e.g. a TV's 1920x1080) rather than matching the kiosk/laptop panel's resolution. Extracts the shared mirroring logic (previously duplicated between autostart and the hotplug handler) into /usr/local/bin/kiosk-mirror-display.sh, which now checks whether the external output natively lists the primary's resolution and uses it directly, or generates a matching mode with cvt and forces it via --newmode/--addmode when it doesn't. Documents the behavior and a manual fallback for displays that reject non-native CVT timings in the README.
Adds a udev rule (DRM 'change' events) that triggers a new kiosk-hotplug.service, which re-runs the same xrandr mirroring logic as the Openbox autostart script. External displays plugged in after boot now get mirrored without requiring a login or lightdm restart. Named kiosk-hotplug (not kiosk-display-*) to avoid collision with the existing kiosk-display-* wildcard cleanup in remove_all_schedules(). Also removes the udev rule during Complete Uninstall and documents the behavior in the README.
Openbox autostart now detects any connected output beyond the primary and mirrors the kiosk content onto it via xrandr, instead of leaving detected-but-unused external displays dark. Also documents the behavior, its limitation (applies at session start, not live hotplug), and adds an HDMI troubleshooting section to the README.
apt install already includes curl and git; adds net-tools and ncdu to the same idempotent apt install so they're present on fresh installs without duplicating already-installed packages.
Clarifies that the installer script can be downloaded on another machine and transferred via USB, while noting the kiosk machine still needs internet access during install for apt/npm packages.
Two bugs caused the login screen to appear on new Ubuntu 24.04 server hardware:
1. Ubuntu 24.04's PAM config checks 'user ingroup nopasswdlogin', not 'autologin'.
Add kiosk user to nopasswdlogin group (and autologin for older versions).
2. The upgrade path never wrote /etc/lightdm/lightdm.conf.d/10-kiosk.conf,
so on new hardware running through upgrade the file simply didn't exist.
Refactor: extract configure_lightdm_autologin() shared helper called from
both fresh install (step 19/27) and upgrade, so both paths are consistent.
Also use [Seat:*] instead of [SeatDefaults] for forward compatibility.
https://claude.ai/code/session_01VQ13Fwq4MXxwThLfCXBeGr
[SeatDefaults] is silently ignored by LightDM on newer Ubuntu versions.
Replace with [Seat:*] which is the correct section name for Ubuntu 22.04+.
Also add the kiosk user to the autologin group, which newer Ubuntu
requires for passwordless autologin to work.
Without these fixes, LightDM shows the login screen instead of
auto-logging in and launching the kiosk app.
https://claude.ai/code/session_01VQ13Fwq4MXxwThLfCXBeGr
/home/kiosk is mode 700 so root cannot traverse it. Every [[ -f ]] or
[[ ! -f ]] check on paths inside /home/kiosk was silently returning
'not found' even after the kiosk user had successfully written the file.
Replace all three [[ ! -f "$electron_bin" ]] checks and the
[[ -f "$sandbox" ]] check in install_electron_binary with
sudo -u "$KIOSK_USER" test -f so they run in the kiosk user's
security context and can actually see the files.
https://claude.ai/code/session_01VQ13Fwq4MXxwThLfCXBeGr
Accidentally dropped this line when rewriting the extraction block.
mktemp creates the file as root:root 600, so sudo -u kiosk unzip
gets 'Permission denied' trying to open the zipfile.
https://claude.ai/code/session_01VQ13Fwq4MXxwThLfCXBeGr
The previous fix incorrectly ran unzip as root, which fails because
/home/kiosk is not accessible to root. The kiosk user is the right
actor for the extraction, but two things blocked it:
1. node_modules/electron/dist/ can be owned by root when npm's electron
postinstall runs with --unsafe-perm, so the kiosk user gets
'Permission denied' trying to write there. Fix: sudo chown -R the
electron directory to the kiosk user before extracting.
2. With set -euo pipefail active (upgrade call had no || guard), a
failed unzip or chmod would abort the script silently before the
diagnostic error messages could print. Fix: add || true to both
commands so the function always reaches the explicit -f check which
prints the real error and returns 1. The upgrade call already has
|| { log_error ...; return 1; } from the previous commit.
https://claude.ai/code/session_01VQ13Fwq4MXxwThLfCXBeGr
Two bugs combined to cause the 'Electron binary download failed' error
even though the zip downloaded and unzip reported inflating all files:
1. The upgrade path called install_electron_binary bare (no ||), so
set -euo pipefail was active inside the function. Any failing command
(e.g. chmod on a file that wasn't written) killed the script before
the error messages printed. Fresh install used || exit 1, which
disables set -e inside the function body. Upgrade now uses
|| { log_error ...; return 1; } to match.
2. The unzip ran as the kiosk user, but node_modules/electron/dist/ can
be owned by root when npm's electron postinstall script runs with
--unsafe-perm. The kiosk user can't write there, so unzip's write
errors go to stderr (not visible in the log) while inflating: lines
still appear on stdout. The binary is never actually written.
Fix: run mkdir/unzip/chmod as root, then chown -R to kiosk.
https://claude.ai/code/session_01VQ13Fwq4MXxwThLfCXBeGr
mktemp creates the tmp zip owned by root with mode 600.
sudo -u kiosk unzip then fails with "Permission denied".
Add chmod 644 immediately after download so the kiosk user can read it.
https://claude.ai/code/session_01EyjEQLWbTXcZgbMDarf7NU
When the script is run via curl|bash or wget|bash, BASH_SOURCE[0] is a
pipe descriptor, not a real file. The upgrade function grep-extracts
heredocs from the script file, so it fails with a confusing path error.
Fixes:
- Set SCRIPT_FILE global at startup (empty string when piped)
- upgrade_kiosk() checks SCRIPT_FILE before asking "Continue?" and shows
a clear message explaining how to download the script to a file first
- Removes the silent failure path (no more cryptic "Cannot find script at
/proc/.../pipe:[...]" error)
https://claude.ai/code/session_01EyjEQLWbTXcZgbMDarf7NU
Fresh Ubuntu 24.04 minimal installs don't include unzip. The wget fallback
in install_electron_binary() downloaded the 120MB Electron zip successfully
but then failed on the unzip call. Two fixes:
1. Add unzip to the main apt install step so it's always present.
2. Auto-install unzip inside install_electron_binary() as a safety net for
upgrades on existing systems that may not have it.
https://claude.ai/code/session_01EyjEQLWbTXcZgbMDarf7NU
Drop the MatchProduct "Finger" restriction from the xorg libinput rule,
leaving only MatchIsTouchscreen "on". MatchIsTouchscreen is set by udev
from hardware capabilities, so it matches finger touch screens of any
brand (ELAN, Goodix, eGalax, Wacom, etc.) while never matching keyboards,
mice, or pen/stylus digitizers (which are tagged as tablets, not
touchscreens). This makes the script work on any touch hardware without
hardcoding device names. Behavior on existing Wacom machines is unchanged
since their finger device matched either way.
https://claude.ai/code/session_01EyjEQLWbTXcZgbMDarf7NU
Two genuinely separate root causes were behind the dead touchscreen:
1. GNOME keyring grab — under LightDM autologin the keyring stays locked.
When Chromium accessed it, the gcr-prompter unlock dialog grabbed all
keyboard and touch input at the X level. The app rendered (timers ran)
but ignored every tap and keypress. Fix: --password-store=basic stops
Electron from using the keyring, so the dialog never appears.
2. Wacom driver single-touch emulation — the wacom X driver only does
single-touch pointer emulation and never passes real multitouch to
Chromium, so 1-finger and 2-finger swipe gestures could not fire.
Fix: force the finger touch device to the libinput driver via
/etc/X11/xorg.conf.d/99-finger-libinput.conf. libinput delivers proper
XI2 multitouch which Chromium turns into real JS touch events. The
pen/stylus stays on the wacom driver.
Removed the earlier dead-end attempts (xsetwacom MapToOutput / CTM reset,
Wacom Enable Touch Gesture, 99-wacom-touch.conf) which were all chasing the
wrong cause while the keyring grab masked any real testing. The upgrade path
removes the stale 99-wacom-touch.conf so it can't override libinput.
https://claude.ai/code/session_01EyjEQLWbTXcZgbMDarf7NU
The Wacom driver owns the Coordinate Transformation Matrix and silently
overrides any xinput set-prop changes. xsetwacom MapToOutput tells the
driver to recalculate the CTM for the primary connected output, which is
the correct API and persists across driver resets.
Dynamically detects the primary output (eDP1, HDMI1, DP1, etc.) so the
fix works on any machine without hardcoding a display name.
https://claude.ai/code/session_01EyjEQLWbTXcZgbMDarf7NU
The Wacom driver can initialise the CTM to all-zeros, which maps every
touch event to screen coordinate (0,0). The touchscreen appears completely
dead even though the hardware and kernel are working correctly.
Reset the CTM to the identity matrix for every touch/finger device at
startup, before launching Electron, so coordinates are always correct.
https://claude.ai/code/session_01EyjEQLWbTXcZgbMDarf7NU
xorg uses fnmatch (shell glob) for MatchProduct, where . is a literal
dot. Wacom.*Finger never matched "Wacom HID 48E3 Finger touch" because
there is no literal dot in that string. Wacom*Finger* matches correctly.
https://claude.ai/code/session_01EyjEQLWbTXcZgbMDarf7NU
Without /etc/X11/xorg.conf.d/99-wacom-touch.conf the Wacom driver initialises
the finger touch device in pointer emulation mode (generating RawButtonPress/
RawButtonRelease/RawMotion). Electron never sees TouchBegin/TouchEnd events so
touchstart/pointerdown(touch) never fire in the renderer.
Setting Option "Gesture" "on" and Option "Touch" "on" at the driver level means
the device initialises in XI2 touch mode on every X server start, regardless of
any post-init xinput set-prop calls.
Added to both fresh install (step 18) and upgrade function.
https://claude.ai/code/session_01EyjEQLWbTXcZgbMDarf7NU
Without XAUTHORITY set, xinput can fail with "Authorization required"
if the display manager doesn't propagate it through the session environment.
Hardcode the kiosk user's .Xauthority path to guarantee xinput works.
https://claude.ai/code/session_01EyjEQLWbTXcZgbMDarf7NU
Update all version strings (header, SCRIPT_VERSION, JS VERSION constant)
and rename ubuntu-based-kiosk-v1.0.2.sh → ubuntu-based-kiosk-v1.0.3.sh.
Update README with v1.0.3 change log and archive v1.0.2 as previous.
https://claude.ai/code/session_01EyjEQLWbTXcZgbMDarf7NU
The xinput loop now scans all input devices at startup, matches anything
with "touch" or "finger" in the name (excluding touchpads/trackpads), and
attempts to enable Wacom touch gestures on each match. Non-Wacom devices
silently ignore the set-prop call, so the loop is safe on any hardware.
https://claude.ai/code/session_01EyjEQLWbTXcZgbMDarf7NU
Add xinput call to start.sh so Wacom HID 48E3 touch gesture support is
initialized every time the kiosk starts, not just after lightdm restarts.
Also add start.sh to the upgrade extraction list so it is updated in place
instead of keeping the stale version from the original install.
https://claude.ai/code/session_01EyjEQLWbTXcZgbMDarf7NU
touchstart/touchend never fire on this device (confirmed by zero [TOUCH]
log entries). The activity tracker already uses pointerdown/pointerup and
works fine, proving PointerEvents reach the preload. Added pointer event
handlers that mirror the touch handlers for all gestures (2-finger swipe,
3-finger toggle, 1-finger arrow keys). A 500ms debounce on the IPC send
prevents double-firing on devices where both event types fire.
https://claude.ai/code/session_01EyjEQLWbTXcZgbMDarf7NU