Commit Graph
425 Commits
Author SHA1 Message Date
Claude c196a5f7e7 Turn off WiFi power save and USB autosuspend for Realtek dongles
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A97KUggSE3rBL8veDM5Mr4
2026-10-16 15:58:43 +00:00
Claude f91dbe4d5f WiFi: adapter chooser, optional wifis, wrong-password diagnosis
- Menu asks which adapter when several exist (shows USB/PCI); the web UI
  helper prefers the adapter already configured.
- Stale-entry cleanup also marks remaining installer wifis optional so a
  dead WiFi entry can't hold up boot.
- After a failed connect, read wpa_supplicant's log to say wrong password /
  network not found / rejected.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A97KUggSE3rBL8veDM5Mr4
2026-10-16 15:56:46 +00:00
Claude 903a1e45e4 WiFi: drop stale netplan wifis entries before apply
netplan apply waits ~90s for every wifis: interface in every file and then
fails if one doesn't exist (e.g. installer config naming wlan0 while the
adapter is wlp1s0). Remove such entries (backup kept as *.kiosk-bak) before
applying, in both the menu and kiosk-wifi-helper.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A97KUggSE3rBL8veDM5Mr4
2026-10-16 15:35:06 +00:00
Outis 1987b0ee71 Merge pull request #121 from outis1one/claude/happy-knuth-hwnvbn
Claude/happy knuth hwnvbn
2026-10-10 11:45:37 -04:00
Claude ff962a41c4 WiFi screen: Ctrl+Alt+Super+W, gated by the unlock PIN/password
The WiFi screen now opens with Ctrl+Alt+Super+W, which is easier to
press than the five-key chord. The Shift+W+S+F+H chord and
Ctrl+Alt+Shift+Super+W are removed. Fn isn't part of the combo: most
keyboards never send it to the computer.

With password protection on, the screen asks for the unlock code: the
PIN pad for a PIN, a password field for a password. It shares the
lock screen's wrong-code slowdown. With protection off, it falls back
to the hidden-sites PIN as before. The lock screen's check now goes
through the same checkCode() helper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cjz8a58eB5P9sUweqd3NHm
2026-10-10 15:42:46 +00:00
Claude d6769d5207 Hash the hidden-sites PIN and slow down repeated wrong PINs
.jitsi-pin now holds "sha256:<hex>" (or NOPIN) instead of the digits.
The menu writes the hashed form. A plain-digits file still works and
is converted by the kiosk app at startup.

The PIN is now checked in the main process instead of the PIN pad's
renderer, using the lock screen's slowdown, now factored out
(createCodeThrottle): 5 wrong in a row refuse entry for 30s, doubling
up to 5 minutes. The F10 PIN pad and the WiFi screen share one count.
The WiFi screen's close-after-5-tries is replaced by this. The PIN pad
no longer shows the default PIN on screen, and the menu no longer
echoes the PIN as it's typed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cjz8a58eB5P9sUweqd3NHm
2026-10-10 15:33:05 +00:00
Claude f215c64085 Lock screen: password or 4-8 digit PIN, optional separate boot code
The unlock code and the boot lock code can each be a password or a
4-8 digit PIN. A PIN gets an on-screen number pad, so the lock works on
a touch-only kiosk. The boot lock can use the unlock code or its own.
These are the kiosk's own codes, set in Core Settings -> Password
Protection & Lockout or on the Web UI's Lockout page, never the admin
(sudo) password.

The lock screen moves from an inline data: URL to lockscreen.html. The
code is now hashed and compared in the main process, which also slows
down guessing: after every 5 wrong codes in a row, input is refused for
30s, doubling up to 5 minutes. Existing configs behave as before:
missing fields mean a password, and the same code at boot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cjz8a58eB5P9sUweqd3NHm
2026-10-10 15:26:59 +00:00
Outis 36f09b2253 Merge pull request #120 from outis1one/claude/happy-knuth-hwnvbn
Claude/happy knuth hwnvbn
2026-10-09 15:53:06 -04:00
Claude 600f5174d8 Add a WiFi screen to the kiosk app (Shift+W+S+F+H)
With the virtual consoles turned off there was no way to join a WiFi
network at the kiosk itself. Holding Shift+W+S+F+H together (or
Ctrl+Alt+Shift+Super+W) now opens a PIN-protected WiFi screen: scan,
pick or type a network, enter the password, connect.

The root side is a small helper (kiosk-wifi-helper: status, scan,
connect only) that the kiosk user may run through a validated
sudoers.d rule. It writes the same netplan file as Core Settings ->
WiFi and puts the previous one back if the new network gives no
address. Installed by provisioning and Advanced -> Upgrade, removed by
Complete Uninstall.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cjz8a58eB5P9sUweqd3NHm
2026-10-09 19:48:09 +00:00
Claude d4f9233655 Add iso/e2b-contig.sh: make an ISO contiguous on a USB drive from Linux
Easy2Boot's UEFI menu returned straight to the menu for the new kiosk ISO
(a 3.5 GB copy on a well-used drive). Some multiboot modes need the ISO in
one unbroken piece, and Linux has no defragmenter for NTFS/FAT32/exFAT -
Easy2Boot's own Linux helpers are a 32-bit udefrag needing i386 libraries
and a FAT32-only perl script.

e2b-contig.sh rewrites just the one file, using only ordinary file
operations - never filesystem structures - so the worst case is "still
not contiguous", never a damaged drive:

1. fill the drive's free space with 4 MB filler files (plugs every gap;
   a plain copy, or fallocate where it exists at all, just fills the
   gaps again - measured on ntfs-3g: 7-8 pieces either way);
2. map the fillers (filefrag -e, which also works on ntfs-3g via FIBMAP)
   and find the longest physically unbroken run of single-piece ones;
3. free just enough of that run and write the copy - the only free space
   left - then delete the fillers;
4. verify the copy's SHA-256 against the original and rename it into
   place (same directory, so the data doesn't move).

If there's no run big enough while the original is on the drive,
--move-out parks it on this computer (checksummed), retries, and puts it
back verified if that still fails. --check only reports. Interrupts and
failures remove fillers and temp copies.

Tested on NTFS images through ntfs-3g: a 60 MB file in 7 pieces became 1
piece with identical content, no leftovers, free space unchanged (27 s for
~900 MB free); drives with no big enough free run (with and without
--move-out) report so and leave the original byte-identical, parked copy
cleaned up. FAT32/exFAT through their FUSE drivers can't report layout,
which the tool detects and stops on, naming the kernel driver to remount
with (vfat/exfat/ntfs3); the kernel drivers aren't available in the test
environment, so those paths are untested.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cjz8a58eB5P9sUweqd3NHm
2026-10-09 18:42:28 +00:00
Outis a66b1e6079 Merge pull request #119 from outis1one/claude/happy-knuth-hwnvbn
Claude/happy knuth hwnvbn
2026-10-09 13:26:37 -04:00
Claude 5cd2117a94 WiFi menu: show its errors, detect adapters by type, keep wired config
Reported: Core Settings -> WiFi "just returns to the core settings menu".
run_menu clears the screen the moment a handler returns, and every early
exit in wifi_menu returned without a pause - so whatever went wrong
(no adapter found, interface wouldn't come up, ...) was wiped before it
could be read.

- wifi_menu now runs the wizard (renamed wifi_configure) and always
  pauses afterwards, success or failure.
- Adapter detection by type (/sys/class/net/*/wireless or phy80211),
  falling back to wl* - not every adapter is named wl*. When none is
  found, lists the interfaces that do exist.
- Clears a software rfkill block via sysfs before `ip link set up`
  (rfkill isn't installed on a server); warns on a hard block; shows
  ip's actual error if bringing the link up still fails.
- Writes its own $NETPLAN_DIR/60-kiosk-wifi.yaml instead of overwriting
  the first *.yaml found - on a fresh install that's the installer's
  50-cloud-init.yaml with the wired config, which it silently dropped.
- SSID/password written as YAML single-quoted scalars: a password with
  " or \ produced invalid YAML before.
- Success = the WiFi interface itself has an IPv4 address (waits up to
  30s), not "the machine has some IP". The SSH revert watchdog uses the
  same test instead of pinging 8.8.8.8, which on a network without
  internet reverted every working connection; with no previous WiFi file
  it reverts by removing ours.

Tested with ip/netplan/sudo stubbed and netplan files in a scratch dir:
installer's wired file untouched, own file written, SSID
`Bob's "Café" #2` and password `p@ss"wo'rd\\:#x` round-trip through a
YAML parser exactly, no-address case reports instead of claiming
success, early errors stay on screen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cjz8a58eB5P9sUweqd3NHm
2026-10-09 17:14:01 +00:00
Claude f7901a3684 Don't glob or test inside the kiosk user's private home as the admin
Reported on a 26.04 offline install, first-boot step 4:
  chown: cannot dereference '/home/kiosk/kiosk-app/*.js': No such file or directory
(same for *.mjs, *.html). `sudo chown ... "$KIOSK_DIR"/*.js` has the glob
expanded by the calling (admin) shell before sudo runs; the kiosk user's
home is created mode 750, so the admin can't list it, the pattern stays
literal, and chown fails - stopping the install under set -e.

- provision_install_app: chown the copied top-level files with
  `sudo find "$KIOSK_DIR" -maxdepth 1 -type f -exec chown ...` (root does
  the listing). Top level only, so chrome-sandbox inside node_modules
  keeps its root ownership.
- Swept lib/ and menus/ for the same class of bug: Asterisk Intercom's
  baresip_is_installed and status read $KIOSK_HOME/.baresip/accounts
  without sudo, so they always reported "not configured". Now sudo test
  / sudo head. Everything else that touches the kiosk home (config.json,
  kiosk-app checks) already goes through sudo -u kiosk.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cjz8a58eB5P9sUweqd3NHm
2026-10-09 16:59:28 +00:00
Claude b0c0008d44 Create missing groups before adding the kiosk user (no netdev on 26.04)
Reported on a 26.04 offline install, first-boot step 2:
  useradd: group 'netdev' does not exist
useradd refuses to create the user at all when any -G group is missing,
which stopped provisioning. Ubuntu 26.04 Server's base system has audio,
video, input and plugdev but no netdev (checked against the 26.04.1
ISO's /etc/group).

provision_create_kiosk_user now `groupadd -f -r`s each supplementary
group first (a no-op when it exists), then passes the same list to
useradd.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cjz8a58eB5P9sUweqd3NHm
2026-10-09 16:55:11 +00:00
Outis 50cf0bef1d Merge pull request #118 from outis1one/claude/happy-knuth-hwnvbn
Fix first-boot service never starting on Ubuntu 26.04
2026-10-09 11:57:37 -04:00
Claude 1ba9e0a8aa Install WiFi tools (wpasupplicant, iw) in the base kiosk install
A default Ubuntu Server 26.04 install only gets wpasupplicant/iw when
WiFi is configured in the installer's network screen (they're not in the
server manifest otherwise). Without them netplan can't join a WiFi
network and Core Settings -> WiFi stops at "No WiFi tools found" - which
is exactly the offline-first case: install with no network, then use the
kiosk's WiFi menu to connect.

- Add wpasupplicant and iw to PROVISION_APT_PACKAGES, so first boot
  installs them in step 1 (from the offline bundle when there's no
  internet) - before Core Settings -> WiFi is offered. +1.6 MB in the
  bundle.
- wifi_menu installs them itself (via run_with_offline_fallback) if
  they're missing, instead of erroring with an apt command to run.

Bundle rebuilt: contains wpasupplicant and iw.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cjz8a58eB5P9sUweqd3NHm
2026-10-09 15:54:59 +00:00
Outis c15ff2f098 Merge pull request #117 from outis1one/claude/happy-knuth-hwnvbn
Fix first-boot service never starting on Ubuntu 26.04
2026-10-09 11:42:08 -04:00
Claude b479b68219 Fix first-boot service never starting on Ubuntu 26.04
Reported: installed from the ISO, files present, but the kiosk setup
never appeared on first boot.

Cause: kiosk-firstboot.service had After=cloud-final.service, and on 26.04
cloud-final.service is After=multi-user.target - while multi-user.target,
which wants kiosk-firstboot, is implicitly After= it. An ordering cycle;
systemd breaks it at boot by deleting a job, and kiosk-firstboot's was
the one to go. systemd-analyze verify against the 26.04.1 ISO's own units
reproduces it:

  Found ordering cycle: kiosk-firstboot.service/start after
  cloud-final.service/start after multi-user.target/start - after
  kiosk-firstboot.service

- Drop After=cloud-final.service. The script's `cloud-init status --wait`
  goes too: cloud-final waits for multi-user.target, which waits for this
  service - it would deadlock. DPkg::Lock::Timeout already covers what it
  was guarding against (dpkg lock contention).
- Drop Conflicts=getty@tty1.service: getty.target pulls getty@tty1 into
  the same boot transaction, and a conflict between two wanted jobs is
  also resolved by dropping one. Before=getty@tty1 alone keeps tty1's
  login prompt waiting until this oneshot finishes.

Verified: systemd-analyze verify of kiosk-firstboot + multi-user.target +
cloud-final + cloud-init.target + getty@tty1 + graphical.target in the
26.04.1 server filesystem - old unit reports the cycle, fixed unit is
clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cjz8a58eB5P9sUweqd3NHm
2026-10-09 15:39:24 +00:00
Outis b660ba89db Merge pull request #116 from outis1one/claude/happy-knuth-hwnvbn
Claude/happy knuth hwnvbn
2026-10-03 12:51:35 -04:00
Claude fcbb890dbf Offline addons, LAN-aware emergency hotspot, LAN time server
Offline bundle now carries every addon that works on a network without
internet: LMS (Lyrion's own .deb, from the addon's LMS_DEB_URL) +
Squeezelite, Asterisk Intercom (baresip), VNC, WireGuard and the
Emergency Hotspot packages - each list in a variable next to the code
that installs it (SQUEEZELITE_APT_PACKAGES, ASTERISK_INTERCOM_APT_PACKAGES,
VNC_APT_PACKAGES, WIREGUARD_APT_PACKAGES, EMERGENCY_HOTSPOT_APT_PACKAGES),
read by build-bundle.sh. Each install goes through
run_with_offline_fallback like CUPS, so they install offline from the
normal menus / Web UI. LMS installs as a bundle package when offline
(lms_offline_install_wanted); its online repo/direct-download paths are
unchanged. Tailscale/Netbird deliberately not bundled (vendor scripts,
vendor servers). Bundle: 524 MB (+87 MB).

Emergency hotspot: the boot check pinged only 8.8.8.8, so on a LAN with
no internet the hotspot started on every boot. Now chosen at setup:
"network" (default - router or internet must answer; a router that
ignores ping still counts if it answered ARP), "internet" (old check),
or "host" (a specific address). Three tries 10s apart. Existing scripts
keep the old check until Reconfigure.

New Core Settings -> Time Server (NTP) (menus/time_sync.sh): point
systemd-timesyncd at LAN time server(s) via a timesyncd.conf.d drop-in,
back to Ubuntu's defaults, or set the clock by hand (and the RTC) for a
network with no time server. Complete Uninstall resets it.

Also: lms_service_name used `systemctl list-unit-files | grep -q`, the
same pipefail/SIGPIPE pattern as the earlier cups_is_installed bug; now
`systemctl cat`.

Tested in a network-less chroot of the 26.04.1 server filesystem: kiosk
base, then action_wireguard_install and action_install_lms (real
functions) and the Squeezelite/Intercom/VNC/hotspot package sets all
installed from the bundle, apt left on normal sources after each.
Hotspot script generated for all three modes (plus an invalid address,
which falls back to "network") and its check run against simulated
networks: LAN-only with router answering ping or only ARP -> no hotspot
in "network" mode; nothing reachable -> hotspot. Time Server actions
driven with timedatectl stubbed: drop-in contents, input validation,
manual set-time sequence, reset removes the drop-in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cjz8a58eB5P9sUweqd3NHm
2026-10-03 16:08:46 +00:00
Claude fb4ee6fa62 Slideshow folders can include PDFs
PDFs in a slideshow folder now play alongside images and Word documents:
a one-page PDF is shown whole, a longer one fits the width and scrolls
through its pages during docInterval (first 30 pages - rendering a huge
PDF for a few seconds on screen isn't worth the time and memory).

- kiosk-app/pdf-render.mjs renders pages to canvases with pdf.js
  (pdfjs-dist ^6.3.289, new kiosk-app dependency): no viewer toolbar, and
  the slideshow's existing auto-scroll works on it. pdf.js >= 4.2.67 is
  required (CVE-2024-4367: older versions could run script embedded in a
  crafted PDF); isEvalSupported:false too. It's a module because pdf.js
  only ships as one - confirmed to load (with its worker) from file:// in
  Electron 44.
- Hands pdf.js a copy of the bytes: it transfers the buffer it's given,
  which would break the cached copy the next time round.
- slideshow.js lists .pdf as a document type; the title-request handler
  serves any listed non-image item (still only from that folder).
- provision_install_app now copies kiosk-app/*.mjs as well - without it
  pdf-render.mjs would never reach an installed kiosk.

Tested with the real main.js on Electron 44.5.1 under Xvfb + Openbox,
app files copied with provision_install_app's own glob: a photo, a
one-page PDF, a three-page PDF and a .docx played in order, twice round;
the one-page PDF shown whole, the three-page one scrolled through all
pages. Offline bundle rebuilt with pdfjs-dist (437 MB total, +22 MB).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cjz8a58eB5P9sUweqd3NHm
2026-10-03 15:43:05 +00:00
Claude 4b85ae59fc Slideshow folders can mix Word documents in with images
A slideshow folder now plays .docx files alongside images: a one-page
document is shown whole (letterboxed like a photo); a longer one fills
the width and scrolls smoothly top to bottom during its docInterval
(new slideshow.json key, default 2x interval). Word's ~$ lock files are
skipped; changed documents (by mtime) are re-read on the minute refresh.

- kiosk-app/docx-render.js: the docx-preview rendering now shared by
  docview.html and slideshow.html. Each render gets its own class prefix
  and keeps its styles in its own container, so two documents crossfading
  can't restyle each other. "One page" is judged by content fitting the
  paper height, not section count - docx-preview only starts a new
  section at explicit page breaks, so a long unbroken document is one
  tall section.
- Document bytes are fetched on demand: the page sets its title to a
  request, slideshow.js answers via executeJavaScript - only for a .docx
  in that slideshow's own current listing (no file-read API exposed to
  pages; the preload is shared by every site).

Tested with the real main.js on Electron 44.5.1 under Xvfb + Openbox on a
folder of 2 images + a 1-page and a long .docx (+ a ~$ lock file): order,
crossfades, whole-page fit, scroll to the end, lock file skipped; the
single-.docx site still renders. The request handler answered a listed
.docx and refused /etc/passwd, a listed image, and a .docx outside the
folder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cjz8a58eB5P9sUweqd3NHm
2026-10-03 15:07:24 +00:00
Claude c54ae352a1 Word (.docx) documents as sites, via docx-preview
A site whose path is a local .docx renders as pages in the kiosk instead
of Chromium offering a download. kiosk-app/docview.js maps the file URL
to docview.html (alongside slideshow.js, through main.js's new
resolveLocalSite) and pushes the file's bytes into the page from the main
process; the page renders them with docx-preview + jszip, both pure-JS
npm dependencies of kiosk-app (so online installs, Advanced -> Upgrade
and the offline bundle all pick them up - no LibreOffice).

- Scaled to the screen width (capped at 160%), scrolls normally.
- Re-checked every minute; a changed file re-renders from the top.
- Word's Symbol/Wingdings private-use bullet glyphs (U+F0B7 etc.), which
  render blank without Microsoft fonts, are mapped to Unicode (•, ▪, ...).
- Missing/unreadable/oversized (>50 MB) files and a missing library show
  a message instead of a blank page. .doc/.odt aren't supported.

Tested with the real main.js on Electron 44.5.1 under Xvfb + Openbox:
heading, coloured bold run, image, bordered table, bullets and a page
break rendered; wheel scrolling works; replacing the file while running
re-rendered the new document within the refresh. Offline bundle rebuilt
and contains docx-preview and jszip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cjz8a58eB5P9sUweqd3NHm
2026-10-03 14:57:10 +00:00
Outis ab76325c9b Merge pull request #115 from outis1one/claude/happy-knuth-hwnvbn
Claude/happy knuth hwnvbn
2026-10-03 10:42:34 -04:00
Claude d4b75ca1b9 Folder image slideshow sites; Electron 42 -> 44
Slideshow: a site whose path is a local folder (/home/kiosk/photos/, or
any existing directory) plays its images full-screen instead of showing
Chromium's directory listing. kiosk-app/slideshow.js maps the folder URL
to slideshow.html (resolveSiteUrl - used where main.js loads a view and
where attachView re-checks the configured URL) and pushes the image list
into the page from the main process (the page has no fs access), re-read
every minute so added/removed images show up without a restart. Natural
file-name order, crossfade, letterboxed by default; optional
slideshow.json in the folder: interval, shuffle, fit, transition,
recursive. provision_install_app already copies kiosk-app/*.js/*.html,
so new installs and Advanced -> Upgrade pick it up.

Electron: ^42.0.0 -> ^44.0.0. Electron supports the latest three majors
(44/43/42), so 42 drops out when 45 ships. Checked the 43/44 breaking
changes against kiosk-app: nothing it uses changed (no clipboard,
safeStorage, net.request, utilityProcess; BrowserView is deprecated but
still present through 46). The offline bundle builder now retries
Electron's install.js download, which crashes outright if a large
download is cut off mid-stream.

Tested with the real main.js on Electron 44.5.1 under Xvfb + Openbox:
slideshow fills the screen, cycles in order (01, 02, 10) skipping a .txt
file, honours slideshow.json, and an image added mid-run joined the
rotation after the refresh. Offline bundle rebuilt with Electron 44.5.1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cjz8a58eB5P9sUweqd3NHm
2026-10-03 14:08:50 +00:00
Claude 5792e201b6 Offline CUPS from the normal menu, local-file sites, build-dep install
- CUPS (Addons -> CUPS Printing, from ./install.sh or the Web UI) now
  installs from the offline bundle by itself when the kiosk can't reach
  the Ubuntu archive: lib/config.sh's run_with_offline_fallback switches
  apt to the bundle (kiosk-offline-apt on) around the install and back
  afterwards. No-op on manual installs or when online.
- Fix cups_is_installed reporting "not installed" for an installed CUPS:
  `dpkg -l | grep -q` under install.sh's pipefail fails when grep exits
  early and dpkg takes SIGPIPE. Uses dpkg-query on the one package now.
- Sites: an absolute path (/home/kiosk/docs/menu.pdf) is accepted and
  turned into a percent-encoded file:// URL, identically in the Sites
  menu (sites_parse_url) and the Web UI (parseUrl, with tests). Electron
  42 shows local HTML, PDFs (built-in viewer) and images as-is.
- iso/build-iso.sh checks its build tools (plus the --offline ones) and
  offers to `sudo apt install` what's missing; --install-deps does it
  without asking. --offline now refuses non-amd64 hosts up front.

Tested: offline chroot of the 26.04.1 server filesystem - action_install_cups
installed CUPS + printer-driver-all/hplip/gutenprint from the bundle with
no network and left apt back on normal sources; webui test suite passes;
Electron 42 rendered a file:// PDF and PNG with spaces/# in the path;
build-iso.sh stopped with the install command when apt-utils was missing
and no TTY, and installed it and built the ISO with --install-deps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cjz8a58eB5P9sUweqd3NHm
2026-10-03 13:29:04 +00:00
Claude 0b5d958fcc Readme: drop the outdated 'no offline bundle' remark
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cjz8a58eB5P9sUweqd3NHm
2026-10-03 04:34:46 +00:00
Claude 96adf6a5e2 Readme: put the offline-ISO pointer after the online-only caveat
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cjz8a58eB5P9sUweqd3NHm
2026-10-03 04:34:40 +00:00
Claude a170dc6228 ISO: add --offline bundle so the kiosk installs with no internet
`iso/build-iso.sh --offline` (and the workflow's new `offline` box, on by
default) adds ~420 MB to the ISO and copies it to /opt/kiosk-offline:

- apt/: local repository with every package install.sh's provisioning
  and the CUPS addon install ask for, NodeSource's nodejs, and all
  dependencies a fresh server install doesn't already have. Resolved
  against the source ISO's own package manifest via a synthesized dpkg
  status (iso/offline/fake-dpkg-status.py), so apt downloads only what's
  missing and makes the same virtual/alternative choices as the target.
- npm/: prebuilt kiosk-app (Electron 42, binary included - it no longer
  downloads at npm install time) and Web UI node_modules.

On first boot, kiosk-firstboot installs online as before when there's
internet; with none it waits KIOSK_OFFLINE_WAIT (90s), then runs
`kiosk-offline-apt on` (apt.conf.d drop-in: bundle is the ONLY source)
and turns it off again afterwards. install.sh notices offline mode via
offline_mode_active (lib/config.sh) and installs nodejs from the bundle
and unpacks prebuilt node_modules instead of npm install.

Package lists moved into PROVISION_APT_PACKAGES /
PROVISION_INTEL_APT_PACKAGES / CUPS_APT_PACKAGES so the bundler reads
the same lists install.sh installs from.

Tested: built against the 26.04.1 ISO, then in a network-less chroot of
the ISO's own server filesystem installed every package set, ran
provision_create_kiosk_user/install_nodejs/install_app and
webui_install_app_files in offline mode (Electron runs, no missing
libraries, chrome-sandbox setuid), and ran kiosk-firstboot through the
offline switch with a stub install.sh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cjz8a58eB5P9sUweqd3NHm
2026-10-03 04:34:32 +00:00
Outis 125622cd74 Merge pull request #114 from outis1one/claude/happy-knuth-hwnvbn
Claude/happy knuth hwnvbn
2026-10-02 23:20:05 -04:00
Claude 7f171cf52b Add "Build kiosk ISO" GitHub Actions workflow
Manually triggered (workflow_dispatch): pick interactive /
unattended-kiosk / fully-automatic, optionally pre-set the admin user
(password from the KIOSK_ADMIN_PASSWORD repo secret, never a logged
input), hostname and SSH key. Runs iso/build-iso.sh and uploads the ISO
plus its sha256 as a run artifact (uncompressed, 3-day default retention).

build-iso.sh changes to run in CI:
- Password can come from $KIOSK_ADMIN_PASSWORD; with no TTY and no
  password it fails clearly instead of hanging on a prompt.
- Stage the repo by fetching the commit rather than `git clone --branch`,
  which fails on actions/checkout's detached HEAD; the branch name falls
  back to $GITHUB_REF_NAME, then main, and is set to track origin so
  Advanced -> Upgrade works on the installed kiosk.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cjz8a58eB5P9sUweqd3NHm
2026-10-03 03:00:11 +00:00
Claude 0dfc2dd3ef build-iso: reject usernames the Ubuntu installer reserves
Found by an emulated install: Subiquity refuses --username admin (a
system group) at boot with 'Username is reserved by the system', after
the user has already written the stick. Check against subiquity's
reserved-usernames list at build time instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cjz8a58eB5P9sUweqd3NHm
2026-10-03 01:29:32 +00:00
Claude aee6b779eb Add all-in-one Ubuntu Server 26.04 + kiosk install ISO builder
iso/build-iso.sh remasters the official 26.04.x live-server ISO (downloaded
and checksum-verified, or --iso PATH) with xorriso, replaying its BIOS/UEFI
boot records unchanged. It adds:

- /autoinstall.yaml: Subiquity autoinstall that answers locale/keyboard/SSH
  and by default leaves only network, storage and identity interactive
  (--username/--auto-storage/--auto-network/--no-confirm/--fully-automatic
  make those automatic too). late-commands copy the repo (a clone of the
  current HEAD) to /opt/ubuntu-based-kiosk and enable a first-boot service.
- iso/firstboot/kiosk-firstboot(.service): on first boot, takes tty1, waits
  for cloud-init and internet, copies the repo to the admin user's home and
  runs ./install.sh as that user with temporary passwordless sudo (removed
  on every exit path). Failed/interrupted installs re-run next boot.

install.sh hooks for this:
- KIOSK_UNATTENDED=1 makes ask_yes_no take each prompt's default and skips
  the Core Settings menu (--unattended).
- KIOSK_PROVISION_DONE_MARKER is touched by provision_finish before the
  reboot prompt, so the caller can tell a finished install from an
  interrupted one.

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

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

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

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

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

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

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

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

webui/lib/config.js re-implements lib/config.sh's exact schema and
merge-on-save contract in JS (kiosk-app/main.js already reads the same
config.json directly in JS, so this isn't a new pattern), so it can
never silently clobber fields it doesn't track - the same bug
previously fixed in lib/config.sh's own history.

No login of its own by design: Authelia runs elsewhere, and the
expectation is a reverse proxy (e.g. Caddy) with Authelia forward-auth
in front of it, the same way other self-hosted apps get protected -
Authelia integration is explicitly out of scope for this repo.

Deliberately narrow scope for this first pass: WiFi, Timezone,
Power/Display/Quiet Hours, Complete Uninstall, every other addon, and
everything in Advanced remain terminal-only, since a network-facing
process shouldn't be handed sudo-level system mutation without a lot
more thought than this pass gives it. Wired into Complete Uninstall
(webui_do_uninstall) and Clone Settings (addon-presence detection) the
same way every other addon is.

This is the single-kiosk piece of the web-based GUI this repo's
"Modular Management" notes have mentioned for a while - a central
multi-kiosk fleet dashboard is an intentional follow-up.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VfsFSoRqfbRG7XAg5RoE7e
2026-08-19 16:01:20 +00:00
Outis 1fad3393ac Merge pull request #111 from outis1one/claude/kiosk-web-management-5rl5zr
Claude/kiosk web management 5rl5zr
2026-08-19 11:37:43 -04:00
Claude d0b76dc6cf Drop Full Reinstall from the migration plan (docs only)
It never worked reliably in the legacy script, and the modular tool
already covers the same outcome more reliably as two already-tested
pieces run back to back: Complete Uninstall (Core Settings), then
./install.sh again to provision fresh. No code changes - nothing was
ever built for it in the modular tool, this just stops carrying it in
docs/comments as a pending gap.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VfsFSoRqfbRG7XAg5RoE7e
2026-08-19 14:38:17 +00:00
Claude d411b05cae Add Upgrade to install.sh (v2.15.0)
The legacy Upgrade re-extracted main.js/preload.js/etc from its own
heredocs on every run - a mechanism the modular tool has no equivalent
of now that kiosk-app/ and provision/files/ are real files in the git
checkout. The new Advanced -> Upgrade is `git pull` (only after a
clean-tree check, and only as a fast-forward - never an automatic
merge) followed by re-running the same packages/kiosk-app/display/
firewall/power-management steps lib/provision.sh already has for a
fresh install, reused rather than reimplemented. Also offers an
on-demand Electron version check via the existing action_update_electron,
since Electron isn't versioned by this repo.

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

- lib/provision.sh: the new provisioning flow, built mostly by calling
  existing menus (core_settings_menu, emergency hotspot, virtual
  consoles) - cuts it to ~300 lines against the legacy script's
  ~4,000-line first_time_install().
- lib/electron.sh: electron_install_binary() extracted out of
  menus/advanced_electron.sh so provisioning and the existing "Fix
  blank screen" action share one implementation.
- kiosk-app/ and provision/files/: the Electron app source and every
  system template file, extracted byte-for-byte out of
  ubuntu-based-kiosk.sh's heredocs into real files.
- Found and fixed a bash set -e gotcha along the way: testing a
  multi-statement function as an if-condition (`if ! some_func; then`)
  silently exempts everything inside that function from set -e for the
  duration of the call. Fixed in the new provisioning code and in
  menus/advanced_electron.sh's pre-existing repair action, which had
  the same shape.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Verified before touching the shipping copy: extracted the exact
save_config() function (now lines 3682-3805) into an isolated test
harness with stubbed dependencies (kiosk_user_exists, is_service_active,
log_success/warning), seeded a stub config.json with Authelia-style
fields via the same `. + {...}` merge configure_authelia() uses, called
save_config() a second time simulating a visit to an unrelated menu,
and confirmed the Authelia fields survive while an actual settings
change (duration 60 -> 90) still correctly takes effect. Also verified
the corrupt-JSON and missing-file edge cases don't crash the function.
Full syntax check on the whole 12,000+ line script, and the entire
modular test suite (11 scratch/stub suites), both still clean.
2026-08-19 01:21:43 +00:00