From 21a0768c8ca0299ca3d6f1f64056047e6098feb0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 15:26:16 +0000 Subject: [PATCH 01/19] Add modular menu framework, migrate Sites & Page Timing onto it Start of pulling the menu system out of the 12k-line single-file installer so individual menus can change without risking the rest of the script (network, VNC, addons, etc). This is groundwork for the planned web-based management UI, which will share the same lib/config.sh read/write layer instead of duplicating it. - lib/menu.sh: generic numbered-menu framework (auto-numbered entries, "0" always exits/returns) plus the validated input helpers menus need. - lib/config.sh: single load/save for config.json. Fixes a latent bug where the old Sites menu wrote config.json without first loading swipe/navigation/lockout settings, silently resetting them to defaults on save. - menus/sites.sh: Sites & Page Timing fully migrated - add/edit/delete/ reorder pages, set duration (auto-rotate/manual/hidden) and home page. Also fixes an off-by-one in the ported reorder logic (moving an item landed one slot short of the requested position) caught by testing. - install.sh: new entry point for managing an already-installed kiosk via `git clone` + `./install.sh`, wired to the Sites menu. Does not yet replace first-time provisioning, which still uses the existing single-file installer. All new site CRUD/reorder/home-page paths were exercised against a scratch config.json (add with/without basic auth, edit duration, set home + timeout, 2- and 3-item reorders in both directions, delete) to confirm the resulting config.json matches expectations. --- Readme.md | 34 +++++ install.sh | 77 +++++++++++ lib/config.sh | 202 +++++++++++++++++++++++++++ lib/menu.sh | 240 ++++++++++++++++++++++++++++++++ menus/sites.sh | 363 +++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 916 insertions(+) create mode 100755 install.sh create mode 100644 lib/config.sh create mode 100644 lib/menu.sh create mode 100644 menus/sites.sh diff --git a/Readme.md b/Readme.md index 234b8df..c13cf53 100644 --- a/Readme.md +++ b/Readme.md @@ -1168,6 +1168,40 @@ See the LICENSE file in the repository for full terms. --- +## Modular Management (new, in progress) + +The 12,000+ line single-file installer works, but every menu lives in the +same file as everything else, which makes small changes risky. We're +pulling the *menu system* out into small, independently editable files as +groundwork for the planned web-based GUI (same modules will back both the +terminal menu and the web UI, so they can't drift apart). + +**What's here so far:** +- `lib/menu.sh` — a generic numbered-menu framework (auto-numbers entries, + always offers `0` to exit/return, validated input helpers). Menu files + just declare their labels and handler functions; they don't hand-roll + `echo`/`case` loops. +- `lib/config.sh` — the single place that reads/writes `config.json`. +- `menus/sites.sh` — **Sites & Page Timing**, fully migrated: add, edit, + delete, and reorder pages, and set the duration/timing mode + (auto-rotate / manual / hidden) and home page — as a working proof of + concept for this approach. +- `install.sh` — entry point for the modular tool. Run it against an + *already-installed* kiosk: + ```bash + git clone https://github.com/outis1one/ubuntu-based-kiosk/ + cd ubuntu-based-kiosk + ./install.sh + ``` + +This does **not** yet replace first-time installation — that's still the +single-file script above (`Quick Install`). The rest of Core +Settings/Addons/Advanced will move into `menus/*.sh` the same way, one +menu at a time, and `install.sh` will eventually take over the whole +`show_main_menu` from the legacy script. + +--- + ## Project Status & Future Plans **Current Version:** 1.0.3 diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..5e84efe --- /dev/null +++ b/install.sh @@ -0,0 +1,77 @@ +#!/bin/bash +################################################################################ +# install.sh - Modular management entry point for Ubuntu Based Kiosk. +# +# This is NOT yet the full system installer - that is still the big +# single-file script (ubuntu-based-kiosk-v1.0.3.sh etc) documented in +# Readme.md, and first-time provisioning of a new kiosk still goes through +# it. This entry point is the start of pulling the *menu system* out of +# that 12k-line file into small, independently editable modules under +# lib/ and menus/, so a change to (say) the Sites menu can't accidentally +# break WiFi setup or the uninstaller three thousand lines away. +# +# Today this only wires up the Sites & Page Timing menu (menus/sites.sh) +# as a working proof of concept. The rest of Core Settings/Addons/Advanced +# will move over the same way, one menus/*.sh file at a time. +# +# Usage (once the kiosk has already been installed): +# git clone +# cd ubuntu-based-kiosk +# ./install.sh +################################################################################ + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# shellcheck source=lib/menu.sh +source "$SCRIPT_DIR/lib/menu.sh" +# shellcheck source=lib/config.sh +source "$SCRIPT_DIR/lib/config.sh" +# shellcheck source=menus/sites.sh +source "$SCRIPT_DIR/menus/sites.sh" + +################################################################################ +# Preflight +################################################################################ + +if [[ "$(whoami)" == "kiosk" ]]; then + log_error "Cannot run as user 'kiosk'" + exit 1 +fi + +if [[ $EUID -eq 0 ]]; then + log_error "Run as a regular user with sudo privileges, not as root" + exit 1 +fi + +if ! command -v jq &>/dev/null; then + log_error "jq is required but not installed. Run: sudo apt-get install -y jq" + exit 1 +fi + +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 +################################################################################ + +main_menu_builder() { + MENU_LABELS=("Sites & Page Timing") + MENU_HANDLERS=(sites_menu) +} + +main_menu_status() { + echo "Managing kiosk at: ${KIOSK_DIR}" +} + +run_menu "UBUNTU BASED KIOSK - MANAGEMENT" main_menu_builder main_menu_status "Exit" diff --git a/lib/config.sh b/lib/config.sh new file mode 100644 index 0000000..7d10b80 --- /dev/null +++ b/lib/config.sh @@ -0,0 +1,202 @@ +#!/bin/bash +################################################################################ +# lib/config.sh - config.json read/write for the kiosk Electron app. +# +# Every menu that touches sites/timing/settings works against the same +# in-memory bash arrays (URLS, DURS, USERS, PASSES, NAMES, ...) and the same +# two functions below. Load once when a menu opens, save after each change. +# +# IMPORTANT: save_config() rewrites config.json from these globals in full. +# Any menu that calls save_config() MUST call load_existing_config() first +# (even if it only touches sites), otherwise settings it doesn't know about +# (swipe mode, navigation security, lockout, etc) get silently reset to +# script defaults. This bit the old single-file Sites menu, which read +# tabs directly via jq but never loaded the rest of the settings - fixed +# here by making load_existing_config() the one canonical loader. +################################################################################ + +: "${KIOSK_USER:=kiosk}" +: "${KIOSK_HOME:=/home/${KIOSK_USER}}" +: "${KIOSK_DIR:=${KIOSK_HOME}/kiosk-app}" +: "${CONFIG_PATH:=${KIOSK_DIR}/config.json}" + +# Site/tab arrays +declare -a URLS=() +declare -a DURS=() +declare -a USERS=() +declare -a PASSES=() +declare -a NAMES=() + +# Other top-level config.json settings we must round-trip even though the +# Sites menu doesn't edit most of them. +AUTOSWITCH="true" +SWIPE_MODE="dual" +ALLOW_NAVIGATION="same-origin" +HOME_TAB_INDEX=-1 +INACTIVITY_TIMEOUT=120 +ENABLE_PAUSE_BUTTON="true" +ENABLE_KEYBOARD_BUTTON="true" +ENABLE_NAV_BUTTON="true" +ENABLE_PASSWORD_PROTECTION="false" +LOCKOUT_PASSWORD="" +LOCKOUT_TIMEOUT=0 +LOCKOUT_AT_TIME="" +LOCKOUT_ACTIVE_START="" +LOCKOUT_ACTIVE_END="" +REQUIRE_PASSWORD_ON_BOOT="false" + +kiosk_user_exists() { + id "$KIOSK_USER" &>/dev/null +} + +is_kiosk_installed() { + kiosk_user_exists && sudo -u "$KIOSK_USER" test -f "$KIOSK_DIR/main.js" 2>/dev/null +} + +is_service_active() { + local service="$1" + systemctl is-active --quiet "$service" 2>/dev/null +} + +# Load every setting config.json has into the bash globals above. +# Safe to call with no existing config file - leaves script defaults in place. +load_existing_config() { + if ! sudo -u "$KIOSK_USER" test -f "$CONFIG_PATH" 2>/dev/null; then + return 0 + fi + + URLS=() + DURS=() + USERS=() + PASSES=() + NAMES=() + + local tab_count + tab_count=$(sudo -u "$KIOSK_USER" jq -r '.tabs | length' "$CONFIG_PATH" 2>/dev/null || echo "0") + if [[ "$tab_count" -gt 0 ]]; then + for ((i = 0; i < tab_count; i++)); do + URLS+=("$(sudo -u "$KIOSK_USER" jq -r ".tabs[$i].url" "$CONFIG_PATH")") + DURS+=("$(sudo -u "$KIOSK_USER" jq -r ".tabs[$i].duration" "$CONFIG_PATH")") + USERS+=("$(sudo -u "$KIOSK_USER" jq -r ".tabs[$i].username // empty" "$CONFIG_PATH")") + PASSES+=("$(sudo -u "$KIOSK_USER" jq -r ".tabs[$i].password // empty" "$CONFIG_PATH")") + NAMES+=("$(sudo -u "$KIOSK_USER" jq -r ".tabs[$i].name // empty" "$CONFIG_PATH")") + done + fi + + HOME_TAB_INDEX=$(sudo -u "$KIOSK_USER" jq -r '.homeTabIndex // -1' "$CONFIG_PATH" 2>/dev/null || echo "-1") + INACTIVITY_TIMEOUT=$(sudo -u "$KIOSK_USER" jq -r '.inactivityTimeout // 120' "$CONFIG_PATH" 2>/dev/null || echo "120") + ALLOW_NAVIGATION=$(sudo -u "$KIOSK_USER" jq -r '.allowNavigation // "same-origin"' "$CONFIG_PATH" 2>/dev/null || echo "same-origin") + SWIPE_MODE=$(sudo -u "$KIOSK_USER" jq -r '.swipeMode // "dual"' "$CONFIG_PATH" 2>/dev/null || echo "dual") + + local pause_btn keyboard_btn nav_btn password_enabled boot_password + pause_btn=$(sudo -u "$KIOSK_USER" jq -r '.enablePauseButton // true' "$CONFIG_PATH" 2>/dev/null) + [[ "$pause_btn" == "true" ]] && ENABLE_PAUSE_BUTTON="true" || ENABLE_PAUSE_BUTTON="false" + + keyboard_btn=$(sudo -u "$KIOSK_USER" jq -r '.enableKeyboardButton // true' "$CONFIG_PATH" 2>/dev/null) + [[ "$keyboard_btn" == "true" ]] && ENABLE_KEYBOARD_BUTTON="true" || ENABLE_KEYBOARD_BUTTON="false" + + nav_btn=$(sudo -u "$KIOSK_USER" jq -r '.enableNavButton // true' "$CONFIG_PATH" 2>/dev/null) + [[ "$nav_btn" == "true" ]] && ENABLE_NAV_BUTTON="true" || ENABLE_NAV_BUTTON="false" + + password_enabled=$(sudo -u "$KIOSK_USER" jq -r '.enablePasswordProtection // false' "$CONFIG_PATH" 2>/dev/null) + [[ "$password_enabled" == "true" ]] && ENABLE_PASSWORD_PROTECTION="true" || ENABLE_PASSWORD_PROTECTION="false" + + LOCKOUT_PASSWORD=$(sudo -u "$KIOSK_USER" jq -r '.lockoutPassword // ""' "$CONFIG_PATH" 2>/dev/null || echo "") + LOCKOUT_TIMEOUT=$(sudo -u "$KIOSK_USER" jq -r '.lockoutTimeout // 0' "$CONFIG_PATH" 2>/dev/null || echo "0") + LOCKOUT_AT_TIME=$(sudo -u "$KIOSK_USER" jq -r '.lockoutAtTime // ""' "$CONFIG_PATH" 2>/dev/null || echo "") + LOCKOUT_ACTIVE_START=$(sudo -u "$KIOSK_USER" jq -r '.lockoutActiveStart // ""' "$CONFIG_PATH" 2>/dev/null || echo "") + LOCKOUT_ACTIVE_END=$(sudo -u "$KIOSK_USER" jq -r '.lockoutActiveEnd // ""' "$CONFIG_PATH" 2>/dev/null || echo "") + + boot_password=$(sudo -u "$KIOSK_USER" jq -r '.requirePasswordOnBoot // false' "$CONFIG_PATH" 2>/dev/null) + [[ "$boot_password" == "true" ]] && REQUIRE_PASSWORD_ON_BOOT="true" || REQUIRE_PASSWORD_ON_BOOT="false" +} + +# Write every bash global back out to config.json, then offer to reload the +# kiosk display so the change takes effect immediately. +save_config() { + if ! kiosk_user_exists; then + log_error "Kiosk user doesn't exist - run the full installer first" + return 1 + fi + + sudo mkdir -p "$KIOSK_DIR" + sudo chown -R "$KIOSK_USER:$KIOSK_USER" "$KIOSK_DIR" + + local tmp + tmp=$(mktemp) + + local dual_json="false" + [[ "$SWIPE_MODE" == "dual" ]] && dual_json="true" + + local pause_btn_json="true" + [[ "$ENABLE_PAUSE_BUTTON" == "false" ]] && pause_btn_json="false" + + local keyboard_btn_json="true" + [[ "$ENABLE_KEYBOARD_BUTTON" == "false" ]] && keyboard_btn_json="false" + + local nav_btn_json="true" + [[ "$ENABLE_NAV_BUTTON" == "false" ]] && nav_btn_json="false" + + local password_json="false" + [[ "$ENABLE_PASSWORD_PROTECTION" == "true" ]] && password_json="true" + + local boot_password_json="false" + [[ "$REQUIRE_PASSWORD_ON_BOOT" == "true" ]] && boot_password_json="true" + + jq -n \ + --argjson autoswitch true \ + --argjson enableTouch true \ + --argjson dualSwipe "$dual_json" \ + --arg swipeMode "$SWIPE_MODE" \ + --arg allowNavigation "$ALLOW_NAVIGATION" \ + --argjson homeTabIndex "${HOME_TAB_INDEX:--1}" \ + --argjson inactivityTimeout "${INACTIVITY_TIMEOUT:-120}" \ + --argjson enablePauseButton "$pause_btn_json" \ + --argjson enableKeyboardButton "$keyboard_btn_json" \ + --argjson enableNavButton "$nav_btn_json" \ + --argjson enablePasswordProtection "$password_json" \ + --arg lockoutPassword "${LOCKOUT_PASSWORD:-}" \ + --argjson lockoutTimeout "${LOCKOUT_TIMEOUT:-0}" \ + --arg lockoutAtTime "${LOCKOUT_AT_TIME:-}" \ + --arg lockoutActiveStart "${LOCKOUT_ACTIVE_START:-}" \ + --arg lockoutActiveEnd "${LOCKOUT_ACTIVE_END:-}" \ + --argjson requirePasswordOnBoot "$boot_password_json" \ + '{autoswitch:$autoswitch,enableTouch:$enableTouch,dualSwipe:$dualSwipe,swipeMode:$swipeMode,allowNavigation:$allowNavigation,homeTabIndex:$homeTabIndex,inactivityTimeout:$inactivityTimeout,enablePauseButton:$enablePauseButton,enableKeyboardButton:$enableKeyboardButton,enableNavButton:$enableNavButton,enablePasswordProtection:$enablePasswordProtection,lockoutPassword:$lockoutPassword,lockoutTimeout:$lockoutTimeout,lockoutAtTime:$lockoutAtTime,lockoutActiveStart:$lockoutActiveStart,lockoutActiveEnd:$lockoutActiveEnd,requirePasswordOnBoot:$requirePasswordOnBoot,tabs:[]}' > "$tmp" + + if [[ ${#URLS[@]} -gt 0 ]]; then + for idx in "${!URLS[@]}"; do + local url="${URLS[$idx]:-}" + local dur="${DURS[$idx]:-0}" + local user="${USERS[$idx]:-}" + local pass="${PASSES[$idx]:-}" + local name="${NAMES[$idx]:-}" + + jq --arg u "$url" \ + --argjson d "$dur" \ + --arg user "$user" \ + --arg pass "$pass" \ + --arg name "$name" \ + '.tabs += [{"url":$u,"duration":$d,"username":$user,"password":$pass,"name":$name}]' \ + "$tmp" > "${tmp}.new" + mv -f "${tmp}.new" "$tmp" + done + fi + + sudo -u "$KIOSK_USER" bash -c "cat > '$CONFIG_PATH'" < "$tmp" + sudo -u "$KIOSK_USER" chmod 644 "$CONFIG_PATH" + rm -f "$tmp" + + log_success "Configuration saved" + + if is_service_active lightdm; then + echo + if ask_yes_no "Reload kiosk now to apply changes?" "y"; then + echo "Reloading kiosk..." + sudo systemctl restart lightdm + sleep 2 + log_success "Kiosk reloaded" + else + log_warning "Remember to reload: sudo systemctl restart lightdm" + fi + fi +} diff --git a/lib/menu.sh b/lib/menu.sh new file mode 100644 index 0000000..1a25ae0 --- /dev/null +++ b/lib/menu.sh @@ -0,0 +1,240 @@ +#!/bin/bash +################################################################################ +# lib/menu.sh - Reusable numbered-menu framework + validated input helpers. +# +# Goal: menu *behavior* (numbering, "0 to exit/return", input validation) +# lives here once. Individual menus/*.sh files only supply their content +# (labels + handler functions) and never re-implement the loop/echo/case +# boilerplate that made the old single-file installer hard to change safely. +# +# Usage: +# my_menu_builder() { +# MENU_LABELS=("Do thing A" "Do thing B") +# MENU_HANDLERS=(action_a action_b) +# } +# run_menu "MY MENU TITLE" my_menu_builder [my_status_func] +# +# The builder runs fresh on every redraw, so labels/handlers can change +# based on current state (e.g. "no sites yet" vs "5 sites configured"). +################################################################################ + +################################################################################ +# Logging +################################################################################ + +log_info() { + echo "[INFO] $*" +} + +log_error() { + echo "[ERROR] $*" >&2 +} + +log_success() { + echo "✓ $*" +} + +log_warning() { + echo "⚠ $*" +} + +pause() { + read -r -p "Press Enter to continue..." +} + +################################################################################ +# Validated input helpers +################################################################################ + +validate_yes_no() { + local answer="$1" + case "${answer,,}" in + y|yes|yeah|yep|yup|sure|ok|okay) return 0 ;; + n|no|nope|nah) return 1 ;; + *) return 2 ;; # invalid + esac +} + +ask_yes_no() { + local prompt="$1" + local default="${2:-n}" + local answer + + while true; do + read -r -p "$prompt (y/n) [$default]: " answer + answer="${answer:-$default}" + + validate_yes_no "$answer" + local result=$? + + if [[ $result -eq 0 ]]; then + return 0 + elif [[ $result -eq 1 ]]; then + return 1 + else + echo "❌ Invalid input. Please enter 'y' for yes or 'n' for no" + echo + fi + done +} + +validate_integer() { + local value="$1" + local min="${2:--2147483648}" + local max="${3:-2147483647}" + + if [[ $value =~ ^-?[0-9]+$ ]]; then + if [[ $value -ge $min && $value -le $max ]]; then + return 0 + fi + fi + return 1 +} + +ask_integer() { + local prompt="$1" + local default="$2" + local min="${3:--2147483648}" + local max="${4:-2147483647}" + local value + + while true; do + read -r -p "$prompt [$default]: " value + value="${value:-$default}" + + if validate_integer "$value" "$min" "$max"; then + echo "$value" + return 0 + else + echo "❌ Invalid number. Please enter an integer between $min and $max" >&2 + echo >&2 + fi + done +} + +validate_url() { + local url="$1" + if [[ $url =~ ^(https?|file|data)://.*$ ]] || [[ $url =~ ^about: ]]; then + return 0 + else + return 1 + fi +} + +ask_url() { + local prompt="$1" + local default="$2" + local url + + while true; do + read -r -p "$prompt [$default]: " url + url="${url:-$default}" + + if validate_url "$url"; then + echo "$url" + return 0 + else + echo "❌ Invalid URL. Must start with http://, https://, file://, data:, or about:" >&2 + echo >&2 + fi + done +} + +ask_text() { + local prompt="$1" + local default="${2:-}" + local value + + read -r -p "$prompt [$default]: " value + echo "${value:-$default}" +} + +validate_menu_choice() { + local choice="$1" + local max="$2" + validate_integer "$choice" 0 "$max" +} + +ask_menu_choice() { + local max="$1" + local choice + + while true; do + read -r -p "Choose [0-$max]: " choice + + if validate_menu_choice "$choice" "$max"; then + echo "$choice" + return 0 + else + echo "❌ Invalid choice. Please enter a number between 0 and $max" >&2 + echo >&2 + fi + done +} + +################################################################################ +# Menu framework +################################################################################ + +print_menu_header() { + local title="$1" + echo "══════════════════════════════════════════════════════════" + printf " %s\n" "$title" + echo "══════════════════════════════════════════════════════════" + echo +} + +# run_menu TITLE BUILDER_FUNC [STATUS_FUNC] [EXIT_LABEL] +# +# BUILDER_FUNC must set the globals MENU_LABELS and MENU_HANDLERS (parallel +# indexed arrays). It is called once per redraw, so it can reflect current +# state. STATUS_FUNC, if given, is called right after the header to print +# read-only context (current settings, current list, etc). +# +# Entries are auto-numbered 1..N. "0" always returns from run_menu - no +# menu file needs to hand-roll its own exit case. +run_menu() { + local title="$1" + local builder="$2" + local status_func="${3:-}" + local exit_label="${4:-Return}" + + while true; do + clear + print_menu_header "$title" + + if [[ -n "$status_func" ]]; then + "$status_func" + echo + fi + + local -a MENU_LABELS=() + local -a MENU_HANDLERS=() + "$builder" + + if [[ "${#MENU_LABELS[@]}" -eq 0 ]]; then + log_warning "Nothing to do here yet." + echo " 0. $exit_label" + echo + ask_menu_choice 0 >/dev/null + return 0 + fi + + local i=1 + for label in "${MENU_LABELS[@]}"; do + printf " %2d. %s\n" "$i" "$label" + i=$((i + 1)) + done + echo " 0. $exit_label" + echo + + local choice + choice=$(ask_menu_choice "${#MENU_LABELS[@]}") + + if [[ "$choice" == "0" ]]; then + return 0 + fi + + "${MENU_HANDLERS[$((choice - 1))]}" + done +} diff --git a/menus/sites.sh b/menus/sites.sh new file mode 100644 index 0000000..1affe4b --- /dev/null +++ b/menus/sites.sh @@ -0,0 +1,363 @@ +#!/bin/bash +################################################################################ +# menus/sites.sh - "Sites & Page Timing" menu. +# +# First real menu built on lib/menu.sh + lib/config.sh, as a proof of +# concept for pulling menus out of the old 12k-line installer one at a +# time. Covers exactly what was asked for first: adding, deleting, and +# changing pages, and the timing (duration) of each. +# +# Depends on: lib/menu.sh, lib/config.sh being sourced first. +################################################################################ + +# Index of the page currently being edited by the nested "edit page" menu. +SITE_EDIT_IDX="" + +################################################################################ +# Small helpers +################################################################################ + +# Normalize whatever the user typed into a URL, same rules the old +# installer used: bare host -> https://, bare IP -> http://. +sites_parse_url() { + local raw="$1" + if [[ "$raw" =~ ^https?:// ]]; then + echo "$raw" + elif [[ "$raw" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+ ]]; then + echo "http://${raw}" + else + echo "https://${raw}" + fi +} + +# Human label for a duration value: >0 seconds = auto-rotate, 0 = manual, +# -1 = hidden (PIN-gated). +sites_duration_label() { + local dur="$1" + if [[ "$dur" == "-1" ]]; then + echo "hidden" + elif [[ "$dur" == "0" ]]; then + echo "manual" + else + echo "auto-rotate ${dur}s" + fi +} + +sites_display_label() { + local idx="$1" + local label="${URLS[$idx]}" + [[ -n "${NAMES[$idx]:-}" ]] && label="\"${NAMES[$idx]}\" - ${URLS[$idx]}" + echo "$label" +} + +################################################################################ +# Status line shown above the main Sites menu +################################################################################ + +sites_status() { + if [[ "${#URLS[@]}" -eq 0 ]]; then + echo "No pages configured yet." + return + fi + + echo "Current pages:" + local has_rotation=false + for idx in "${!URLS[@]}"; do + local dur="${DURS[$idx]}" + local flags="" + [[ "$dur" != "0" && "$dur" != "-1" ]] && has_rotation=true + [[ -n "${USERS[$idx]:-}" ]] && flags+=" [auth]" + [[ "$HOME_TAB_INDEX" == "$idx" ]] && flags+=" [HOME]" + + printf " %2d. %s (%s)%s\n" "$((idx + 1))" "$(sites_display_label "$idx")" "$(sites_duration_label "$dur")" "$flags" + done + echo + if $has_rotation; then + echo "Auto-rotation: active (pages with duration > 0)" + else + echo "Auto-rotation: off (all pages manual/hidden)" + fi + if [[ "$HOME_TAB_INDEX" != "-1" ]]; then + echo "Home page: #$((HOME_TAB_INDEX + 1)), ${INACTIVITY_TIMEOUT}s inactivity timeout" + else + echo "Home page: disabled" + fi +} + +################################################################################ +# Top-level Sites menu +################################################################################ + +sites_menu_builder() { + MENU_LABELS=("Add a page") + MENU_HANDLERS=(action_add_page) + + if [[ "${#URLS[@]}" -gt 0 ]]; then + MENU_LABELS+=("Edit a page" "Delete a page") + MENU_HANDLERS+=(action_pick_and_edit_page action_delete_page) + fi + + if [[ "${#URLS[@]}" -gt 1 ]]; then + MENU_LABELS+=("Reorder pages") + MENU_HANDLERS+=(action_reorder_page) + fi + + if [[ "${#URLS[@]}" -gt 0 ]]; then + MENU_LABELS+=("Set/clear home page") + MENU_HANDLERS+=(action_set_home_page) + fi +} + +sites_menu() { + load_existing_config + run_menu "SITES & PAGE TIMING" sites_menu_builder sites_status +} + +################################################################################ +# Add +################################################################################ + +action_add_page() { + echo + echo "Duration controls rotation:" + echo " > 0 = auto-rotates every X seconds" + echo " 0 = manual only (swipe/nav menu to reach it)" + echo " -1 = hidden (PIN-gated, F10 or 3-finger swipe)" + echo + + local raw_url url dur name needs_auth user pass + read -r -p "URL: " raw_url + if [[ -z "$raw_url" ]]; then + echo "Cancelled" + return + fi + url=$(sites_parse_url "$raw_url") + + dur=$(ask_integer "Duration in seconds (-1=hidden, 0=manual)" "180" -1 86400) + name=$(ask_text "Page name (optional, blank = show URL)" "") + + user="" + pass="" + if ask_yes_no "Does this page need HTTP Basic Auth?" "n"; then + read -r -p " Username: " user + read -r -s -p " Password: " pass + echo + fi + + URLS+=("$url") + DURS+=("$dur") + USERS+=("$user") + PASSES+=("$pass") + NAMES+=("$name") + + log_success "Added: $url ($(sites_duration_label "$dur"))" + save_config +} + +################################################################################ +# Edit (nested menu on the selected page) +################################################################################ + +action_pick_and_edit_page() { + echo + for idx in "${!URLS[@]}"; do + echo " $((idx + 1)). $(sites_display_label "$idx")" + done + echo + local num + num=$(ask_integer "Edit which page? (0=cancel)" "0" 0 "${#URLS[@]}") + [[ "$num" == "0" ]] && return + + SITE_EDIT_IDX=$((num - 1)) + run_menu "EDIT PAGE #${num}" edit_page_menu_builder edit_page_status +} + +edit_page_status() { + local idx="$SITE_EDIT_IDX" + echo "URL: ${URLS[$idx]}" + echo "Name: ${NAMES[$idx]:-(none)}" + echo "Timing: $(sites_duration_label "${DURS[$idx]}")" + if [[ -n "${USERS[$idx]:-}" ]]; then + echo "Basic auth: enabled (user: ${USERS[$idx]})" + else + echo "Basic auth: disabled" + fi +} + +edit_page_menu_builder() { + MENU_LABELS=("Change URL" "Change name" "Change timing (duration)" "Change Basic Auth") + MENU_HANDLERS=(action_edit_url action_edit_name action_edit_duration action_edit_auth) +} + +action_edit_url() { + local idx="$SITE_EDIT_IDX" + local raw_url + raw_url=$(ask_text "New URL" "${URLS[$idx]}") + URLS[$idx]=$(sites_parse_url "$raw_url") + log_success "URL updated" + save_config +} + +action_edit_name() { + local idx="$SITE_EDIT_IDX" + NAMES[$idx]=$(ask_text "New name (blank = show URL)" "${NAMES[$idx]:-}") + log_success "Name updated" + save_config +} + +action_edit_duration() { + local idx="$SITE_EDIT_IDX" + echo + echo " > 0 = auto-rotates every X seconds" + echo " 0 = manual only" + echo " -1 = hidden (PIN-gated)" + DURS[$idx]=$(ask_integer "Duration in seconds" "${DURS[$idx]}" -1 86400) + log_success "Timing updated: $(sites_duration_label "${DURS[$idx]}")" + save_config +} + +action_edit_auth() { + local idx="$SITE_EDIT_IDX" + if ask_yes_no "Enable HTTP Basic Auth for this page?" "$([[ -n "${USERS[$idx]:-}" ]] && echo y || echo n)"; then + read -r -p " Username: " USERS_new + read -r -s -p " Password: " PASSES_new + echo + USERS[$idx]="$USERS_new" + PASSES[$idx]="$PASSES_new" + log_success "Basic Auth updated" + else + USERS[$idx]="" + PASSES[$idx]="" + log_success "Basic Auth disabled" + fi + save_config +} + +################################################################################ +# Delete +################################################################################ + +action_delete_page() { + echo + for idx in "${!URLS[@]}"; do + echo " $((idx + 1)). $(sites_display_label "$idx")" + done + echo + local num + num=$(ask_integer "Delete which page? (0=cancel)" "0" 0 "${#URLS[@]}") + [[ "$num" == "0" ]] && { echo "Cancelled"; return; } + + local del_idx=$((num - 1)) + echo "Deleting: $(sites_display_label "$del_idx")" + + unset 'URLS[del_idx]' 'DURS[del_idx]' 'USERS[del_idx]' 'PASSES[del_idx]' 'NAMES[del_idx]' + URLS=("${URLS[@]}") + DURS=("${DURS[@]}") + USERS=("${USERS[@]}") + PASSES=("${PASSES[@]}") + NAMES=("${NAMES[@]}") + + if [[ "$HOME_TAB_INDEX" == "$del_idx" ]]; then + HOME_TAB_INDEX=-1 + log_warning "Home page was deleted - home feature disabled" + elif [[ "$HOME_TAB_INDEX" -gt "$del_idx" ]]; then + HOME_TAB_INDEX=$((HOME_TAB_INDEX - 1)) + fi + + log_success "Page deleted" + save_config +} + +################################################################################ +# Reorder +################################################################################ + +action_reorder_page() { + echo + echo "Current order:" + for idx in "${!URLS[@]}"; do + echo " $((idx + 1)). $(sites_display_label "$idx")" + done + echo + + local max="${#URLS[@]}" + local from_num to_num + from_num=$(ask_integer "Move which page? (0=cancel)" "0" 0 "$max") + [[ "$from_num" == "0" ]] && { echo "Cancelled"; return; } + to_num=$(ask_integer "Move to position?" "1" 1 "$max") + + local from_idx=$((from_num - 1)) + local to_idx=$((to_num - 1)) + if [[ "$from_idx" == "$to_idx" ]]; then + echo "Same position - no change" + return + fi + + # Work out where the home page (if any, and not the one being moved) + # will land, before we touch the arrays. Removing the moved item and + # then re-inserting it at $to_idx always places it at index $to_idx of + # the *final* array - no further adjustment needed there. Everything + # else only shifts by the remove and the insert individually. + local new_home_idx="$HOME_TAB_INDEX" + if [[ "$HOME_TAB_INDEX" == "$from_idx" ]]; then + new_home_idx="$to_idx" + elif [[ "$HOME_TAB_INDEX" != "-1" ]]; then + [[ "$from_idx" -lt "$HOME_TAB_INDEX" ]] && new_home_idx=$((new_home_idx - 1)) + [[ "$to_idx" -le "$new_home_idx" ]] && new_home_idx=$((new_home_idx + 1)) + fi + HOME_TAB_INDEX="$new_home_idx" + + local move_url="${URLS[$from_idx]}" move_dur="${DURS[$from_idx]}" \ + move_user="${USERS[$from_idx]}" move_pass="${PASSES[$from_idx]}" move_name="${NAMES[$from_idx]}" + + unset 'URLS[from_idx]' 'DURS[from_idx]' 'USERS[from_idx]' 'PASSES[from_idx]' 'NAMES[from_idx]' + URLS=("${URLS[@]}") + DURS=("${DURS[@]}") + USERS=("${USERS[@]}") + PASSES=("${PASSES[@]}") + NAMES=("${NAMES[@]}") + + URLS=("${URLS[@]:0:$to_idx}" "$move_url" "${URLS[@]:$to_idx}") + DURS=("${DURS[@]:0:$to_idx}" "$move_dur" "${DURS[@]:$to_idx}") + USERS=("${USERS[@]:0:$to_idx}" "$move_user" "${USERS[@]:$to_idx}") + PASSES=("${PASSES[@]:0:$to_idx}" "$move_pass" "${PASSES[@]:$to_idx}") + NAMES=("${NAMES[@]:0:$to_idx}" "$move_name" "${NAMES[@]:$to_idx}") + + log_success "Pages reordered" + save_config +} + +################################################################################ +# Home page +################################################################################ + +action_set_home_page() { + echo + if [[ "$HOME_TAB_INDEX" != "-1" ]]; then + echo "Current home page: #$((HOME_TAB_INDEX + 1)), ${INACTIVITY_TIMEOUT}s timeout" + else + echo "Home page currently disabled" + fi + echo + for idx in "${!URLS[@]}"; do + echo " $((idx + 1)). $(sites_display_label "$idx")" + done + echo + + local num + num=$(ask_integer "Set which page as home? (0=disable)" "0" 0 "${#URLS[@]}") + if [[ "$num" == "0" ]]; then + HOME_TAB_INDEX=-1 + log_success "Home page disabled" + save_config + return + fi + + HOME_TAB_INDEX=$((num - 1)) + local timeout_min + timeout_min=$(ask_integer "Inactivity timeout in minutes" "2" 1 240) + INACTIVITY_TIMEOUT=$((timeout_min * 60)) + + log_success "Home page: #${num} (${timeout_min}m inactivity timeout)" + save_config +} From c1370edc3e36231fe2a2edc98cd7f39e7bf02af2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 15:48:21 +0000 Subject: [PATCH 02/19] Migrate Display & Interaction menu; drop version number from installer filename; bump to v2.0.0 - menus/display.sh: second menu migrated onto lib/menu.sh + lib/config.sh, covering touch gesture mode, link navigation security, and the pause/keyboard/navigation button toggles (previously three separate Core Settings entries). Deliberately a different shape from Sites (toggle list vs. list CRUD) to exercise the framework more broadly. Wired into install.sh's top-level menu alongside Sites. - Renamed ubuntu-based-kiosk-v1.0.3.sh -> ubuntu-based-kiosk.sh so the installer can be updated in place instead of growing a new version-numbered filename every release; released versions are now tracked via git history and the in-script changelog. Updated all Readme download/re-run commands accordingly. Older versioned files (ubuntu-based-kiosk-v*.sh, install_kiosk_*.sh) are left in place as archived releases. - Bumped SCRIPT_VERSION to 2.0.0 (new script-level changelog entry) and the Readme version/changelog to match, given the new modular management path, the rename, and the two real bugs fixed along the way (settings clobbered on save, off-by-one in reorder). Verified before moving on to the web admin work: - Regression: re-ran the full Sites scratch-config test suite (add, edit, delete, reorder, home) - still clean, no invalid-input paths hit. - New: scratch-config test for every display.sh action (touch mode, navigation security, all three toggles), confirming values persist through save/reload and that a previously-added site survives untouched across Display-menu saves. - End-to-end: ran the real install.sh (not just sourced functions) as a genuine non-root, non-"kiosk" user with real sudo, driving actual menu input through Sites -> add a page -> Display -> toggle a setting -> exit. Confirmed final config.json on disk matches every action taken, and both guard clauses (run as root; no installed kiosk found) fire correctly. --- Readme.md | 48 +++--- install.sh | 10 +- menus/display.sh | 137 ++++++++++++++++++ ...d-kiosk-v1.0.3.sh => ubuntu-based-kiosk.sh | 21 ++- 4 files changed, 188 insertions(+), 28 deletions(-) create mode 100644 menus/display.sh rename ubuntu-based-kiosk-v1.0.3.sh => ubuntu-based-kiosk.sh (99%) diff --git a/Readme.md b/Readme.md index c13cf53..66a48a3 100644 --- a/Readme.md +++ b/Readme.md @@ -1,6 +1,6 @@ # Ubuntu Based Kiosk -**Current Version:** 1.0.3 (check script header for latest version) +**Current Version:** 2.0.0 (check script header for latest version) **Built with Claude Sonnet 4.6 AI assistance** **License:** GPL v3 - Keep derivatives open source **Repository:** https://github.com/outis1one/ubuntu-based-kiosk/ @@ -47,12 +47,9 @@ Home/office kiosk for reusing old hardware, displaying: # Configure WiFi if no ethernet available # Enable SSH during installation -# Download and run the latest installer -LATEST=$(curl -fsSL https://api.github.com/repos/outis1one/ubuntu-based-kiosk/contents \ - | grep -oP 'ubuntu-based-kiosk-v[0-9.]+\.sh' \ - | grep -v beta | sort -V | tail -1) -wget "https://github.com/outis1one/ubuntu-based-kiosk/raw/main/$LATEST" -chmod +x "$LATEST" && ./"$LATEST" +# Download and run the installer +wget https://github.com/outis1one/ubuntu-based-kiosk/raw/main/ubuntu-based-kiosk.sh +chmod +x ubuntu-based-kiosk.sh && ./ubuntu-based-kiosk.sh ``` The installer will guide you through configuration during setup. @@ -68,13 +65,10 @@ If the kiosk machine can't reach GitHub directly (no browser, restrictive proxy, **On a machine with internet access:** ```bash -# Option A: download just the latest installer script -LATEST=$(curl -fsSL https://api.github.com/repos/outis1one/ubuntu-based-kiosk/contents \ - | grep -oP 'ubuntu-based-kiosk-v[0-9.]+\.sh' \ - | grep -v beta | sort -V | tail -1) -wget "https://github.com/outis1one/ubuntu-based-kiosk/raw/main/$LATEST" +# Option A: download just the installer script +wget https://github.com/outis1one/ubuntu-based-kiosk/raw/main/ubuntu-based-kiosk.sh -# Option B: download the whole repo as a ZIP (includes all installer versions and addon scripts) +# Option B: download the whole repo as a ZIP (includes install.sh, addon scripts, and older archived installer versions) wget https://github.com/outis1one/ubuntu-based-kiosk/archive/refs/heads/main.zip unzip main.zip ``` @@ -83,8 +77,8 @@ Copy the downloaded `.sh` file (or the extracted ZIP contents) to a USB drive, t ```bash # Mount the USB drive and copy the script over, then: -chmod +x ubuntu-based-kiosk-v*.sh -./ubuntu-based-kiosk-v*.sh +chmod +x ubuntu-based-kiosk.sh +./ubuntu-based-kiosk.sh ``` The kiosk machine still needs a working internet connection (ethernet, or WiFi configured during Ubuntu install) for the script to complete. @@ -160,7 +154,7 @@ After running the addon it prints the full server-side setup, but the summary is ```bash ssh user@kiosk-machine -./$(ls ubuntu-based-kiosk-v*.sh | sort -V | tail -1) +./ubuntu-based-kiosk.sh # Addons → 5. Authelia Auto-Login # Enter your Authelia URL, username, and password when prompted ``` @@ -591,7 +585,7 @@ smb://WORKGROUP/COMPUTER/PrinterName ```bash # Run installer script again to access menu -./$(ls ubuntu-based-kiosk-v*.sh | sort -V | tail -1) +./ubuntu-based-kiosk.sh # Menu structure: # 1. Core Settings - Sites, WiFi, schedules, passwords, full reinstall, complete uninstall @@ -606,7 +600,7 @@ The Easy Asterisk Intercom addon provides voice communication capabilities to yo **Access the addon menu:** ```bash -./$(ls ubuntu-based-kiosk-v*.sh | sort -V | tail -1) +./ubuntu-based-kiosk.sh # Select: 2) Addons # Then: 4) Easy Asterisk Intercom ``` @@ -636,7 +630,7 @@ asterisk -rvvv systemctl restart asterisk # Configure intercom (rerun installation to update) -./$(ls ubuntu-based-kiosk-v*.sh | sort -V | tail -1) +./ubuntu-based-kiosk.sh # Select: 2) Addons → 4) Easy Asterisk Intercom ``` @@ -1020,7 +1014,7 @@ Full system cleanup that removes all kiosk components and restores the system to **Access:** ```bash # Core Settings menu → option 11 -./$(ls ubuntu-based-kiosk-v*.sh | sort -V | tail -1) +./ubuntu-based-kiosk.sh # Choose: Core Settings → Complete Uninstall ``` @@ -1186,6 +1180,10 @@ terminal menu and the web UI, so they can't drift apart). delete, and reorder pages, and set the duration/timing mode (auto-rotate / manual / hidden) and home page — as a working proof of concept for this approach. +- `menus/display.sh` — **Display & Interaction**: touch gesture mode, + link navigation security, and the on-screen pause/keyboard/navigation + button toggles. A second proof of concept covering a different menu + shape (settings toggles vs. the list CRUD in Sites). - `install.sh` — entry point for the modular tool. Run it against an *already-installed* kiosk: ```bash @@ -1204,9 +1202,15 @@ menu at a time, and `install.sh` will eventually take over the whole ## Project Status & Future Plans -**Current Version:** 1.0.3 +**Current Version:** 2.0.0 -**Recent Updates (v1.0.3):** +**Recent Updates (v2.0.0):** +- **Modular management path:** new `lib/menu.sh` (reusable numbered-menu framework: auto-numbered entries, `0` always exits/returns) and `lib/config.sh` (single load/save for `config.json`), with menus migrating into `menus/*.sh` one at a time — **Sites & Page Timing** and **Display & Interaction** are migrated so far. Run via `./install.sh` after cloning the repo, against an already-installed kiosk (see "Modular Management" below). Groundwork for the planned web-based GUI, which will share this same `lib/config.sh` layer. +- **Bug fix:** the old Sites menu could save `config.json` without first loading swipe/navigation/lockout settings, silently resetting them to script defaults. +- **Bug fix:** reordering sites had an off-by-one that left the moved site one slot short of the requested position. +- **Renamed installer:** the main script is now `ubuntu-based-kiosk.sh` (no version number in the filename), updated in place going forward. Released versions are tracked via git history and this changelog instead of the filename; older `ubuntu-based-kiosk-v*.sh` / `install_kiosk_*.sh` files remain in the repo as archived releases. + +**Previous (v1.0.3):** - **HDMI/external display mirroring:** any connected display beyond the primary (e.g. HDMI-out to a monitor/TV) is now mirrored automatically at the primary's exact resolution — generating a custom `cvt` mode if the external display doesn't natively list it — both at kiosk login/boot and live on plug/unplug via a new udev-triggered `kiosk-hotplug.service`. Previously the external output was left inactive even when detected by X, and would otherwise mirror at its own native resolution instead of matching the kiosk panel - **HDMI audio routing:** audio now follows the same hotplug event — the default PipeWire sink automatically switches to the HDMI audio output when an external display is connected/mirrored, and back to the built-in sink when it's disconnected (`kiosk-audio-route.sh`) - **Package install:** installer now also installs `net-tools` and `ncdu` (alongside the already-installed `curl` and `git`) diff --git a/install.sh b/install.sh index 5e84efe..0996998 100755 --- a/install.sh +++ b/install.sh @@ -10,8 +10,8 @@ # lib/ and menus/, so a change to (say) the Sites menu can't accidentally # break WiFi setup or the uninstaller three thousand lines away. # -# Today this only wires up the Sites & Page Timing menu (menus/sites.sh) -# as a working proof of concept. The rest of Core Settings/Addons/Advanced +# Today this wires up Sites & Page Timing (menus/sites.sh) and Display & +# Interaction (menus/display.sh). The rest of Core Settings/Addons/Advanced # will move over the same way, one menus/*.sh file at a time. # # Usage (once the kiosk has already been installed): @@ -30,6 +30,8 @@ source "$SCRIPT_DIR/lib/menu.sh" source "$SCRIPT_DIR/lib/config.sh" # shellcheck source=menus/sites.sh source "$SCRIPT_DIR/menus/sites.sh" +# shellcheck source=menus/display.sh +source "$SCRIPT_DIR/menus/display.sh" ################################################################################ # Preflight @@ -66,8 +68,8 @@ fi ################################################################################ main_menu_builder() { - MENU_LABELS=("Sites & Page Timing") - MENU_HANDLERS=(sites_menu) + MENU_LABELS=("Sites & Page Timing" "Display & Interaction") + MENU_HANDLERS=(sites_menu display_menu) } main_menu_status() { diff --git a/menus/display.sh b/menus/display.sh new file mode 100644 index 0000000..64442a7 --- /dev/null +++ b/menus/display.sh @@ -0,0 +1,137 @@ +#!/bin/bash +################################################################################ +# menus/display.sh - "Display & Interaction" menu. +# +# Second menu migrated off the old single-file installer, folding together +# three small settings screens that used to be separate Core Settings +# entries (Touch controls, Navigation security, Optional Features). All +# three are simple scalar/boolean fields on config.json, so this is a +# deliberately different shape from menus/sites.sh's list CRUD - a toggle +# list where each entry shows its current value and flips/edits itself, +# saving immediately (same immediate-save pattern as Sites, so behavior +# stays consistent no matter which menu you're in). +# +# Depends on: lib/menu.sh, lib/config.sh being sourced first. +################################################################################ + +display_status() { + echo "Touch gesture mode: $SWIPE_MODE" + echo "Link navigation: $ALLOW_NAVIGATION" + echo "Pause button: $(display_onoff "$ENABLE_PAUSE_BUTTON")" + echo "Keyboard button: $(display_onoff "$ENABLE_KEYBOARD_BUTTON")" + echo "Navigation button: $(display_onoff "$ENABLE_NAV_BUTTON")" +} + +display_onoff() { + [[ "$1" == "true" ]] && echo "ON" || echo "OFF" +} + +display_menu_builder() { + MENU_LABELS=( + "Touch gesture mode (currently: $SWIPE_MODE)" + "Link navigation security (currently: $ALLOW_NAVIGATION)" + "Toggle pause button (currently: $(display_onoff "$ENABLE_PAUSE_BUTTON"))" + "Toggle on-screen keyboard button (currently: $(display_onoff "$ENABLE_KEYBOARD_BUTTON"))" + "Toggle navigation/help button (currently: $(display_onoff "$ENABLE_NAV_BUTTON"))" + ) + MENU_HANDLERS=( + action_set_touch_mode + action_set_navigation_security + action_toggle_pause_button + action_toggle_keyboard_button + action_toggle_nav_button + ) +} + +display_menu() { + load_existing_config + run_menu "DISPLAY & INTERACTION" display_menu_builder display_status +} + +################################################################################ +# Touch gesture mode +################################################################################ + +action_set_touch_mode() { + echo + echo "DUAL-DIRECTION (recommended for touchscreens):" + echo " 2-finger swipe = switch pages, 1-finger swipe = navigate within page" + echo "STANDARD (simpler):" + echo " 2-finger swipe = switch pages only, 1-finger swipes do nothing" + echo + + local default + [[ "$SWIPE_MODE" == "dual" ]] && default="y" || default="n" + + if ask_yes_no "Use dual-direction mode?" "$default"; then + SWIPE_MODE="dual" + else + SWIPE_MODE="standard" + fi + + log_success "Touch mode: $SWIPE_MODE" + save_config +} + +################################################################################ +# Navigation security +################################################################################ + +action_set_navigation_security() { + echo + echo " r) restricted - only the loaded URL, no link clicking" + echo " s) same-origin - can click links within the same domain (recommended)" + echo " o) open - can click any link, browse anywhere" + echo + + local choice + read -r -p "(r)estricted / (s)ame-origin / (o)pen [${ALLOW_NAVIGATION}]: " choice + + case "${choice,,}" in + r) ALLOW_NAVIGATION="restricted" ;; + o) ALLOW_NAVIGATION="open" ;; + s) ALLOW_NAVIGATION="same-origin" ;; + "") ;; # keep current value + *) log_warning "Unrecognized choice, keeping '$ALLOW_NAVIGATION'" ;; + esac + + log_success "Link navigation: $ALLOW_NAVIGATION" + save_config +} + +################################################################################ +# On-screen button toggles +################################################################################ + +action_toggle_pause_button() { + if [[ "$ENABLE_PAUSE_BUTTON" == "true" ]]; then + ENABLE_PAUSE_BUTTON="false" + log_warning "Pause button disabled" + else + ENABLE_PAUSE_BUTTON="true" + log_success "Pause button enabled" + fi + save_config +} + +action_toggle_keyboard_button() { + if [[ "$ENABLE_KEYBOARD_BUTTON" == "true" ]]; then + ENABLE_KEYBOARD_BUTTON="false" + log_warning "On-screen keyboard button disabled" + else + ENABLE_KEYBOARD_BUTTON="true" + log_success "On-screen keyboard button enabled" + fi + save_config +} + +action_toggle_nav_button() { + if [[ "$ENABLE_NAV_BUTTON" == "true" ]]; then + ENABLE_NAV_BUTTON="false" + log_warning "Navigation/help button disabled" + else + ENABLE_NAV_BUTTON="true" + log_success "Navigation/help button enabled" + fi + save_config +} diff --git a/ubuntu-based-kiosk-v1.0.3.sh b/ubuntu-based-kiosk.sh similarity index 99% rename from ubuntu-based-kiosk-v1.0.3.sh rename to ubuntu-based-kiosk.sh index 0b42cbb..c9c408c 100644 --- a/ubuntu-based-kiosk-v1.0.3.sh +++ b/ubuntu-based-kiosk.sh @@ -1,8 +1,25 @@ #!/bin/bash ################################################################################ -### Ubuntu Based Kiosk v1.0.3 ### +### Ubuntu Based Kiosk v2.0.0 ### ################################################################################ # +# RELEASE v2.0.0 - Modular Management & Unversioned Filename +# - New git-clone-based management path: lib/menu.sh (reusable numbered-menu +# framework) + lib/config.sh (single config.json load/save) + menus/*.sh, +# run via ./install.sh against an already-installed kiosk. Sites & Page +# Timing and Display & Interaction are migrated; the rest of Core +# Settings/Addons/Advanced still live here and will move over the same +# way, one menu at a time. See Readme.md ("Modular Management"). +# - Fixed: the old Sites menu could save config.json without first loading +# swipe/navigation/lockout settings, silently resetting them to defaults. +# - Fixed: reordering sites had an off-by-one that left the moved site one +# slot short of the requested position. +# - This script is now distributed as ubuntu-based-kiosk.sh (no version +# number in the filename) so it can be updated in place; released +# versions are tracked via git history and this changelog instead. +# Older ubuntu-based-kiosk-v*.sh / install_kiosk_*.sh files remain in the +# repo as archived releases. +# # RELEASE v1.0.3 - Touch Screen Detection & Upgrade Reliability # - Authelia auto-login addon (Addons menu → 5) # Password encrypted with AES-256-CBC keyed from /etc/machine-id @@ -68,7 +85,7 @@ set -euo pipefail ### SECTION 1: CONSTANTS & GLOBALS ################################################################################ -SCRIPT_VERSION="1.0.3" +SCRIPT_VERSION="2.0.0" # Resolve the real path to this script file. # When piped (curl|bash or wget|bash), BASH_SOURCE[0] is a pipe descriptor, From 074b2ec2e3c34b7fff50fe23731beeabd1ad2584 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 16:33:12 +0000 Subject: [PATCH 03/19] Migrate Timezone and Hidden Site PIN menus; harden menu framework against set -e; bump to v2.1.0 Two more menus migrated onto lib/menu.sh + lib/config.sh, chosen specifically because neither touches config.json - a third and fourth shape for the framework (a system command via timedatectl, and a flat PIN file), on top of Sites' list CRUD and Display's JSON toggles. - menus/timezone.sh: also replaces the legacy script's hand-numbered 18-entry case statement with a plain data list (TIMEZONE_COMMON_ZONES) plus one handler that reads the number run_menu hands it - adding or removing a zone never touches numbering anywhere else. Required a small run_menu addition: handlers now receive the chosen 1-based number as $1, so one handler can serve a whole data-driven list instead of needing a wrapper function per entry. - menus/hidden_pin.sh: set/disable/reset the PIN gating hidden pages. Testing menus/timezone.sh surfaced a real bug before it ever shipped: this whole tool runs under `set -e`, and set_timezone() rejecting an invalid zone via a bare `return 1` as its last statement took down the *entire* install.sh session, not just that one action - a single typo would silently drop the user back to their shell. Fixed at the framework level in lib/menu.sh (run_menu now absorbs a failed handler's exit code) rather than patching set_timezone alone, since any future menu could hit the same trap. Verified against the real install.sh as a genuine non-root user: an invalid timezone now logs an error and redraws the Timezone menu instead of killing the session (confirmed exit code 0 at the end of the run). Note this specific hazard was introduced by this session's own return-1 idiom, not inherited from the legacy script, which never uses a bare return 1 in these functions. Also per the user: left the old configure_sites/configure_touch_controls/ configure_navigation_security/configure_optional_features functions in ubuntu-based-kiosk.sh untouched for now (still carrying the v2.0.0 settings-clobber and reorder bugs) rather than removing them - they'll be retired in one pass once enough of Core Settings/Addons/Advanced is migrated. Bumped SCRIPT_VERSION to 2.1.0 with matching changelog entries in the script header and Readme, and updated the Readme's "Modular Management" section to state plainly what is and isn't migrated yet. Verified: - Full regression: re-ran the Sites and Display scratch-config test suites against the updated run_menu signature - both still clean. - New scratch-config tests for hidden_pin.sh (set/mismatch/reject/ disable/reset, correct file permissions) and timezone.sh (builder entry count, common-zone pick by index, manual entry with legacy US/* alias normalization, region search + cancel, invalid-zone rejection) - all correct, with timedatectl/sudo stubbed only where needed to avoid mutating this sandbox's real system clock/timezone. - End-to-end: ran the real install.sh as a genuine non-root, non-"kiosk" user, navigating Timezone -> manual entry -> invalid zone -> confirmed no crash and a normal return to the menu, then Hidden Site PIN -> set a PIN -> confirmed the file on disk (mode 600, correct content) -> clean exit (code 0). --- Readme.md | 37 ++++++++---- install.sh | 28 +++++---- lib/menu.sh | 13 ++++- menus/hidden_pin.sh | 88 ++++++++++++++++++++++++++++ menus/timezone.sh | 129 ++++++++++++++++++++++++++++++++++++++++++ ubuntu-based-kiosk.sh | 28 ++++++++- 6 files changed, 299 insertions(+), 24 deletions(-) create mode 100644 menus/hidden_pin.sh create mode 100644 menus/timezone.sh diff --git a/Readme.md b/Readme.md index 66a48a3..25adc0a 100644 --- a/Readme.md +++ b/Readme.md @@ -1,6 +1,6 @@ # Ubuntu Based Kiosk -**Current Version:** 2.0.0 (check script header for latest version) +**Current Version:** 2.1.0 (check script header for latest version) **Built with Claude Sonnet 4.6 AI assistance** **License:** GPL v3 - Keep derivatives open source **Repository:** https://github.com/outis1one/ubuntu-based-kiosk/ @@ -1182,8 +1182,14 @@ terminal menu and the web UI, so they can't drift apart). concept for this approach. - `menus/display.sh` — **Display & Interaction**: touch gesture mode, link navigation security, and the on-screen pause/keyboard/navigation - button toggles. A second proof of concept covering a different menu - shape (settings toggles vs. the list CRUD in Sites). + button toggles. A different menu shape from Sites (settings toggles + vs. list CRUD). +- `menus/timezone.sh` — **Timezone**: also replaces the legacy script's + hand-numbered 18-entry `case` statement with a plain data list plus one + handler — the numbering is just `run_menu`'s job now. +- `menus/hidden_pin.sh` — **Hidden Site PIN**: the PIN gating hidden + pages (`duration: -1` in Sites). A fourth shape again — a flat file, + not `config.json`. - `install.sh` — entry point for the modular tool. Run it against an *already-installed* kiosk: ```bash @@ -1192,19 +1198,30 @@ terminal menu and the web UI, so they can't drift apart). ./install.sh ``` -This does **not** yet replace first-time installation — that's still the -single-file script above (`Quick Install`). The rest of Core -Settings/Addons/Advanced will move into `menus/*.sh` the same way, one -menu at a time, and `install.sh` will eventually take over the whole -`show_main_menu` from the legacy script. +**Honest status:** this does not yet replace first-time installation, or +most of the old installer. `ubuntu-based-kiosk.sh` is still ~12,000 +lines and still contains its own unremoved, unmodified copies of every +menu above (plus WiFi, Power/Display/Quiet Hours, Password Protection & +Lockout, Upgrade, Reinstall, Uninstall, all Addons, and all of Advanced — +none of that has moved yet). 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. --- ## Project Status & Future Plans -**Current Version:** 2.0.0 +**Current Version:** 2.1.0 -**Recent Updates (v2.0.0):** +**Recent Updates (v2.1.0):** +- **Two more menus migrated:** Timezone (`menus/timezone.sh`) and Hidden Site PIN (`menus/hidden_pin.sh`), joining Sites & Page Timing and Display & Interaction in `./install.sh`. Timezone also replaces the old hand-numbered 18-entry list with a data-driven one built on the generic menu framework. +- **Bug fix (framework-level):** `install.sh` runs under `set -e`; a menu action that legitimately fails (e.g. rejecting an invalid timezone) and returns non-zero as its last statement could take down the *entire* session instead of just that action. Caught by testing before this ever shipped broadly; `run_menu()` now absorbs a failed handler's exit code, protecting every menu — present and future. +- The old, unmigrated `configure_sites`/`configure_touch_controls`/`configure_navigation_security`/`configure_optional_features` in `ubuntu-based-kiosk.sh` are staying in place for now (still carrying the v2.0.0 bugs below) until enough of Core Settings/Addons/Advanced is migrated to retire them in one pass — see "Modular Management" below for exactly what's covered so far. + +**Previous (v2.0.0):** - **Modular management path:** new `lib/menu.sh` (reusable numbered-menu framework: auto-numbered entries, `0` always exits/returns) and `lib/config.sh` (single load/save for `config.json`), with menus migrating into `menus/*.sh` one at a time — **Sites & Page Timing** and **Display & Interaction** are migrated so far. Run via `./install.sh` after cloning the repo, against an already-installed kiosk (see "Modular Management" below). Groundwork for the planned web-based GUI, which will share this same `lib/config.sh` layer. - **Bug fix:** the old Sites menu could save `config.json` without first loading swipe/navigation/lockout settings, silently resetting them to script defaults. - **Bug fix:** reordering sites had an off-by-one that left the moved site one slot short of the requested position. diff --git a/install.sh b/install.sh index 0996998..23ab743 100755 --- a/install.sh +++ b/install.sh @@ -3,16 +3,18 @@ # install.sh - Modular management entry point for Ubuntu Based Kiosk. # # This is NOT yet the full system installer - that is still the big -# single-file script (ubuntu-based-kiosk-v1.0.3.sh etc) documented in -# Readme.md, and first-time provisioning of a new kiosk still goes through -# it. This entry point is the start of pulling the *menu system* out of -# that 12k-line file into small, independently editable modules under -# lib/ and menus/, so a change to (say) the Sites menu can't accidentally -# break WiFi setup or the uninstaller three thousand lines away. +# 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. # -# Today this wires up Sites & Page Timing (menus/sites.sh) and Display & -# Interaction (menus/display.sh). The rest of Core Settings/Addons/Advanced -# will move over the same way, one menus/*.sh file at a time. +# Migrated so far: Sites & Page Timing (menus/sites.sh), Display & +# Interaction (menus/display.sh), Timezone (menus/timezone.sh), Hidden +# Site PIN (menus/hidden_pin.sh). # # Usage (once the kiosk has already been installed): # git clone @@ -32,6 +34,10 @@ source "$SCRIPT_DIR/lib/config.sh" source "$SCRIPT_DIR/menus/sites.sh" # shellcheck source=menus/display.sh source "$SCRIPT_DIR/menus/display.sh" +# shellcheck source=menus/timezone.sh +source "$SCRIPT_DIR/menus/timezone.sh" +# shellcheck source=menus/hidden_pin.sh +source "$SCRIPT_DIR/menus/hidden_pin.sh" ################################################################################ # Preflight @@ -68,8 +74,8 @@ fi ################################################################################ main_menu_builder() { - MENU_LABELS=("Sites & Page Timing" "Display & Interaction") - MENU_HANDLERS=(sites_menu display_menu) + MENU_LABELS=("Sites & Page Timing" "Display & Interaction" "Timezone" "Hidden Site PIN") + MENU_HANDLERS=(sites_menu display_menu timezone_menu hidden_pin_menu) } main_menu_status() { diff --git a/lib/menu.sh b/lib/menu.sh index 1a25ae0..6fa6d3f 100644 --- a/lib/menu.sh +++ b/lib/menu.sh @@ -193,6 +193,11 @@ print_menu_header() { # # Entries are auto-numbered 1..N. "0" always returns from run_menu - no # menu file needs to hand-roll its own exit case. +# +# The handler is called as `handler "$choice"` (the 1-based number picked), +# so a data-driven list (e.g. a set of timezones) can share one handler +# instead of needing a distinct wrapper function per entry. Handlers that +# don't care can just ignore the argument. run_menu() { local title="$1" local builder="$2" @@ -235,6 +240,12 @@ run_menu() { return 0 fi - "${MENU_HANDLERS[$((choice - 1))]}" + # `|| true`: this whole tool runs under `set -e`. A handler that + # legitimately fails (invalid input, a guard clause, etc) and + # returns non-zero as its last statement must not be allowed to + # take the entire session down - it should just redraw the menu. + # Absorbing that here means no menus/*.sh file has to think about + # set -e at all. + "${MENU_HANDLERS[$((choice - 1))]}" "$choice" || true done } diff --git a/menus/hidden_pin.sh b/menus/hidden_pin.sh new file mode 100644 index 0000000..4ac937d --- /dev/null +++ b/menus/hidden_pin.sh @@ -0,0 +1,88 @@ +#!/bin/bash +################################################################################ +# menus/hidden_pin.sh - "Hidden Site PIN" menu. +# +# Guards access to hidden pages (duration = -1, see menus/sites.sh) via a +# flat PIN file rather than config.json - a fourth shape for the framework +# to prove out (plain file, not JSON at all). +# +# Depends on: lib/menu.sh, lib/config.sh being sourced first. +################################################################################ + +hidden_pin_file() { + echo "$KIOSK_DIR/.jitsi-pin" +} + +hidden_pin_status() { + local pin_file + pin_file=$(hidden_pin_file) + + if sudo -u "$KIOSK_USER" test -f "$pin_file" 2>/dev/null; then + local current_pin + current_pin=$(sudo -u "$KIOSK_USER" cat "$pin_file" 2>/dev/null) + if [[ "$current_pin" == "NOPIN" ]]; then + echo "Current: no PIN (hidden pages open to anyone)" + else + echo "Current: PIN set (${#current_pin} digits)" + fi + else + echo "Current: not configured (default: 1234)" + fi +} + +hidden_pin_menu_builder() { + MENU_LABELS=("Set new PIN (4-8 digits)" "Disable PIN (open access)" "Reset to default (1234)") + MENU_HANDLERS=(action_set_pin action_disable_pin action_reset_pin) +} + +hidden_pin_menu() { + run_menu "HIDDEN SITE PIN" hidden_pin_menu_builder hidden_pin_status +} + +################################################################################ +# Actions +################################################################################ + +write_pin() { + local value="$1" + local pin_file + pin_file=$(hidden_pin_file) + + sudo mkdir -p "$KIOSK_DIR" + echo "$value" | sudo -u "$KIOSK_USER" tee "$pin_file" > /dev/null + sudo -u "$KIOSK_USER" chmod 600 "$pin_file" + log_warning "Restart the kiosk display for this to take effect" +} + +action_set_pin() { + echo + local new_pin confirm_pin + while true; do + read -r -p "Enter new PIN (4-8 digits): " new_pin + + if [[ ! "$new_pin" =~ ^[0-9]{4,8}$ ]]; then + echo "❌ PIN must be 4-8 digits" + continue + fi + + read -r -p "Confirm PIN: " confirm_pin + + if [[ "$new_pin" == "$confirm_pin" ]]; then + write_pin "$new_pin" + log_success "PIN updated" + break + else + echo "❌ PINs don't match, try again" + fi + done +} + +action_disable_pin() { + write_pin "NOPIN" + log_success "PIN disabled - hidden pages accessible without a PIN" +} + +action_reset_pin() { + write_pin "1234" + log_success "PIN reset to default (1234)" +} diff --git a/menus/timezone.sh b/menus/timezone.sh new file mode 100644 index 0000000..6198d85 --- /dev/null +++ b/menus/timezone.sh @@ -0,0 +1,129 @@ +#!/bin/bash +################################################################################ +# menus/timezone.sh - "Timezone" menu. +# +# Third menu migrated off the old single-file installer, and a different +# shape again: not config.json at all - talks to timedatectl/system state +# directly. Also the clearest demonstration of the framework's value: the +# original hand-numbered an 18-entry list ("1) America/New_York ... 18) +# Enter manually") in a single case statement. Here the common-zone list +# is just data, one handler (action_pick_common_timezone) handles all of +# them using the number run_menu hands it, and adding/removing a zone +# from the list never touches numbering anywhere else. +# +# Depends on: lib/menu.sh, lib/config.sh being sourced first. +################################################################################ + +TIMEZONE_COMMON_ZONES=( + "America/New_York" "America/Chicago" "America/Denver" "America/Los_Angeles" + "America/Phoenix" "America/Anchorage" "Pacific/Honolulu" "Europe/London" + "Europe/Paris" "Europe/Berlin" "Europe/Rome" "Asia/Tokyo" "Asia/Shanghai" + "Asia/Dubai" "Australia/Sydney" "Pacific/Auckland" +) +TIMEZONE_COMMON_LABELS=( + "US Eastern" "US Central" "US Mountain" "US Pacific" "US Arizona" "US Alaska" + "US Hawaii" "UK" "Central Europe" "Germany" "Italy" "Japan" "China" "UAE" + "Australia East" "New Zealand" +) + +timezone_status() { + echo "Current timezone: $(timedatectl show -p Timezone --value)" +} + +timezone_menu_builder() { + MENU_LABELS=() + MENU_HANDLERS=() + for i in "${!TIMEZONE_COMMON_ZONES[@]}"; do + MENU_LABELS+=("${TIMEZONE_COMMON_ZONES[$i]} (${TIMEZONE_COMMON_LABELS[$i]})") + MENU_HANDLERS+=(action_pick_common_timezone) + done + MENU_LABELS+=("Search for timezone by region" "Enter timezone manually") + MENU_HANDLERS+=(action_search_timezone action_manual_timezone) +} + +timezone_menu() { + run_menu "TIMEZONE" timezone_menu_builder timezone_status +} + +################################################################################ +# Actions +################################################################################ + +# Called by run_menu as `action_pick_common_timezone "$choice"` - $choice is +# the 1-based menu number, which lines up directly with TIMEZONE_COMMON_ZONES. +action_pick_common_timezone() { + local choice="$1" + set_timezone "${TIMEZONE_COMMON_ZONES[$((choice - 1))]}" +} + +action_search_timezone() { + echo + echo "Available regions:" + local regions + regions=($(timedatectl list-timezones | cut -d'/' -f1 | sort -u)) + for i in "${!regions[@]}"; do + printf " %2d) %s\n" "$((i + 1))" "${regions[$i]}" + done + echo + + local region_num + region_num=$(ask_integer "Select region number (0=cancel)" "0" 0 "${#regions[@]}") + [[ "$region_num" == "0" ]] && { echo "Cancelled"; return; } + local selected_region="${regions[$((region_num - 1))]}" + + echo + echo "Timezones in $selected_region:" + local timezones + timezones=($(timedatectl list-timezones | grep "^${selected_region}/")) + for i in "${!timezones[@]}"; do + printf " %3d) %s\n" "$((i + 1))" "${timezones[$i]}" + done + echo + + local tz_num + tz_num=$(ask_integer "Select timezone number (0=cancel)" "0" 0 "${#timezones[@]}") + [[ "$tz_num" == "0" ]] && { echo "Cancelled"; return; } + set_timezone "${timezones[$((tz_num - 1))]}" +} + +action_manual_timezone() { + echo + local new_tz + read -r -p "Enter timezone (e.g., America/New_York): " new_tz + [[ -z "$new_tz" ]] && { echo "Cancelled"; return; } + set_timezone "$new_tz" +} + +################################################################################ +# Shared apply logic +################################################################################ + +set_timezone() { + local new_tz="$1" + + # A few legacy US/* aliases users might type manually - normalize before + # validating against the canonical IANA list. + case "$new_tz" in + "US/Eastern") new_tz="America/New_York" ;; + "US/Central") new_tz="America/Chicago" ;; + "US/Mountain") new_tz="America/Denver" ;; + "US/Pacific") new_tz="America/Los_Angeles" ;; + "US/Alaska") new_tz="America/Anchorage" ;; + "US/Hawaii") new_tz="Pacific/Honolulu" ;; + "US/Arizona") new_tz="America/Phoenix" ;; + esac + + if ! timedatectl list-timezones | grep -qx "$new_tz"; then + log_error "Invalid timezone: $new_tz" + return 1 + fi + + if sudo timedatectl set-timezone "$new_tz"; then + log_success "Timezone updated to $new_tz" + else + # Fallback: set timezone directly without D-Bus + sudo ln -sf "/usr/share/zoneinfo/$new_tz" /etc/localtime + echo "$new_tz" | sudo tee /etc/timezone > /dev/null + log_success "Timezone updated to $new_tz (direct)" + fi +} diff --git a/ubuntu-based-kiosk.sh b/ubuntu-based-kiosk.sh index c9c408c..d15874f 100644 --- a/ubuntu-based-kiosk.sh +++ b/ubuntu-based-kiosk.sh @@ -1,8 +1,32 @@ #!/bin/bash ################################################################################ -### Ubuntu Based Kiosk v2.0.0 ### +### Ubuntu Based Kiosk v2.1.0 ### ################################################################################ # +# RELEASE v2.1.0 - Two More Menus Migrated, Menu Framework Hardened +# - New in ./install.sh: Timezone (menus/timezone.sh) and Hidden Site PIN +# (menus/hidden_pin.sh) menus, alongside Sites & Page Timing and Display +# & Interaction from v2.0.0. Timezone doubles as a demonstration of the +# framework: the old hand-numbered 18-entry case statement is now just +# a data list plus one handler. +# - Hardened lib/menu.sh: since this whole tool runs under `set -e`, a menu +# action that legitimately fails (e.g. rejecting an invalid timezone) and +# returns non-zero as its last statement could take down the *entire* +# session, not just that one action - one typo would silently drop the +# user back to their shell. Caught by testing menus/timezone.sh (its +# set_timezone() does `return 1` on an invalid zone) before this ever +# shipped; run_menu() now absorbs a failed handler's exit code so it +# only redraws the menu, protecting every menu, present and future. +# - The old (unmigrated) configure_sites/configure_touch_controls/ +# configure_navigation_security/configure_optional_features functions +# still live in this script, unchanged, and still have both v2.0.0 bugs +# above - left in place deliberately until enough of Core Settings/ +# Addons/Advanced is migrated to retire them in one pass. configure_ +# timezone/configure_hidden_site_pin don't share the set -e hazard +# (they never use a bare `return 1`), but are otherwise also still +# here unchanged pending the same cleanup. See Readme.md ("Modular +# Management") for current migration status. +# # RELEASE v2.0.0 - Modular Management & Unversioned Filename # - New git-clone-based management path: lib/menu.sh (reusable numbered-menu # framework) + lib/config.sh (single config.json load/save) + menus/*.sh, @@ -85,7 +109,7 @@ set -euo pipefail ### SECTION 1: CONSTANTS & GLOBALS ################################################################################ -SCRIPT_VERSION="2.0.0" +SCRIPT_VERSION="2.1.0" # Resolve the real path to this script file. # When piped (curl|bash or wget|bash), BASH_SOURCE[0] is a pipe descriptor, From 8672c154692c619d11666a9e6d5f9558dddaa9a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 16:48:30 +0000 Subject: [PATCH 04/19] Migrate Password Protection & Lockout menu; add missing ask_time helper; bump to v2.2.0 Fifth menu migrated onto lib/menu.sh + lib/config.sh: menus/lockout.sh covers enable/disable, changing the password, inactivity timeout, daily lock time, and boot password. The password is SHA-256 hashed before it's ever assigned to LOCKOUT_PASSWORD (matching main.js's comparison logic) - verified by test that the stored value is the correct hash and never plaintext. Rewrote the legacy configure_password_protection's linear "ask everything, confirm save at the end" wizard as the same immediate-save pattern used by every other migrated menu: each action (change password, change timeout, toggle boot password, ...) is a complete, standalone change, consistent with Sites/Display/Timezone/Hidden PIN. LOCKOUT_ACTIVE_START/END are deliberately left untouched - per the Readme they're inert leftover fields the app ignores, so lib/config.sh just carries whatever is already in config.json through unchanged. Testing this menu surfaced a real gap before it ever shipped: lib/menu.sh never had ask_time/validate_time at all (only validate_integer/ask_integer, ask_url, etc were ported when the framework was first built) - "set a daily lock time" would have failed for every single user with "ask_time: command not found". Ported both from the legacy script. Also promoted the ON/OFF toggle-label helper (previously private to menus/display.sh as display_onoff) to a shared onoff() in lib/menu.sh, since menus/lockout.sh needed the same thing and menu files should only ever depend on lib/, never on each other. Bumped SCRIPT_VERSION to 2.2.0 with matching changelog entries in the script header and Readme, and updated "Modular Management" to list the new menu and drop Password Protection & Lockout from the "not yet migrated" list. Verified: - Full regression: re-ran the Sites, Display, Timezone/PIN scratch-config suites after every change in this round (the onoff refactor, and again after adding ask_time) - all still clean. - New scratch-config test for lockout.sh: enable (password+timeout+daily lock+boot toggle), independently recomputed the expected SHA-256 hash and confirmed it matches config.json exactly, change password, change timeout, clear daily lock, toggle boot password, disable (confirmed every field clears), and that the menu builder's options correctly differ between the enabled and disabled states. - End-to-end: ran the real install.sh as a genuine non-root, non-"kiosk" user - Lockout menu -> enable protection with a real password entered via the masked prompt -> set 20m timeout, 23:00 daily lock, boot password on -> confirmed the menu redraws with the new state -> clean exit (code 0). Checked the resulting config.json and file permissions on disk. --- Readme.md | 23 ++++-- install.sh | 15 +++- lib/menu.sh | 30 +++++++ menus/display.sh | 16 ++-- menus/lockout.sh | 187 ++++++++++++++++++++++++++++++++++++++++++ ubuntu-based-kiosk.sh | 17 +++- 6 files changed, 266 insertions(+), 22 deletions(-) create mode 100644 menus/lockout.sh diff --git a/Readme.md b/Readme.md index 25adc0a..d4499d4 100644 --- a/Readme.md +++ b/Readme.md @@ -1,6 +1,6 @@ # Ubuntu Based Kiosk -**Current Version:** 2.1.0 (check script header for latest version) +**Current Version:** 2.2.0 (check script header for latest version) **Built with Claude Sonnet 4.6 AI assistance** **License:** GPL v3 - Keep derivatives open source **Repository:** https://github.com/outis1one/ubuntu-based-kiosk/ @@ -1190,6 +1190,10 @@ terminal menu and the web UI, so they can't drift apart). - `menus/hidden_pin.sh` — **Hidden Site PIN**: the PIN gating hidden pages (`duration: -1` in Sites). A fourth shape again — a flat file, not `config.json`. +- `menus/lockout.sh` — **Password Protection & Lockout**: enable/disable, + change password, inactivity timeout, daily lock time, boot password. + The password is SHA-256 hashed before it's ever written to disk, same + as the legacy menu — never stored as plaintext. - `install.sh` — entry point for the modular tool. Run it against an *already-installed* kiosk: ```bash @@ -1201,10 +1205,10 @@ terminal menu and the web UI, so they can't drift apart). **Honest status:** this does not yet replace first-time installation, or most of the old installer. `ubuntu-based-kiosk.sh` is still ~12,000 lines and still contains its own unremoved, unmodified copies of every -menu above (plus WiFi, Power/Display/Quiet Hours, Password Protection & -Lockout, Upgrade, Reinstall, Uninstall, all Addons, and all of Advanced — -none of that has moved yet). Both copies coexist deliberately: the old -ones stay until enough of Core Settings/Addons/Advanced is migrated to +menu above (plus WiFi, Power/Display/Quiet Hours, Upgrade, Reinstall, +Uninstall, all Addons, and all of Advanced — none of that has moved +yet). 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 @@ -1214,9 +1218,14 @@ at all. ## Project Status & Future Plans -**Current Version:** 2.1.0 +**Current Version:** 2.2.0 -**Recent Updates (v2.1.0):** +**Recent Updates (v2.2.0):** +- **Fifth menu migrated:** Password Protection & Lockout (`menus/lockout.sh`) — enable/disable, change password, inactivity timeout, daily lock time, boot password. The password is SHA-256 hashed before it's ever written to `config.json` (matching the Electron app's own comparison logic) — verified never stored as plaintext. +- **Bug fix:** `lib/menu.sh` was missing `ask_time`/`validate_time` entirely — caught by testing this menu before it shipped; "set a daily lock time" would otherwise have failed for every user. Ported from the legacy script. +- **Refactor:** promoted the ON/OFF toggle-label helper out of `menus/display.sh` into a shared `onoff()` in `lib/menu.sh`, so `menus/lockout.sh` doesn't need to depend on another menu file — menus only ever depend on `lib/`. + +**Previous (v2.1.0):** - **Two more menus migrated:** Timezone (`menus/timezone.sh`) and Hidden Site PIN (`menus/hidden_pin.sh`), joining Sites & Page Timing and Display & Interaction in `./install.sh`. Timezone also replaces the old hand-numbered 18-entry list with a data-driven one built on the generic menu framework. - **Bug fix (framework-level):** `install.sh` runs under `set -e`; a menu action that legitimately fails (e.g. rejecting an invalid timezone) and returns non-zero as its last statement could take down the *entire* session instead of just that action. Caught by testing before this ever shipped broadly; `run_menu()` now absorbs a failed handler's exit code, protecting every menu — present and future. - The old, unmigrated `configure_sites`/`configure_touch_controls`/`configure_navigation_security`/`configure_optional_features` in `ubuntu-based-kiosk.sh` are staying in place for now (still carrying the v2.0.0 bugs below) until enough of Core Settings/Addons/Advanced is migrated to retire them in one pass — see "Modular Management" below for exactly what's covered so far. diff --git a/install.sh b/install.sh index 23ab743..6e9bdcb 100755 --- a/install.sh +++ b/install.sh @@ -14,7 +14,8 @@ # # Migrated so far: Sites & Page Timing (menus/sites.sh), Display & # Interaction (menus/display.sh), Timezone (menus/timezone.sh), Hidden -# Site PIN (menus/hidden_pin.sh). +# Site PIN (menus/hidden_pin.sh), Password Protection & Lockout +# (menus/lockout.sh). # # Usage (once the kiosk has already been installed): # git clone @@ -38,6 +39,8 @@ source "$SCRIPT_DIR/menus/display.sh" source "$SCRIPT_DIR/menus/timezone.sh" # shellcheck source=menus/hidden_pin.sh source "$SCRIPT_DIR/menus/hidden_pin.sh" +# shellcheck source=menus/lockout.sh +source "$SCRIPT_DIR/menus/lockout.sh" ################################################################################ # Preflight @@ -74,8 +77,14 @@ fi ################################################################################ main_menu_builder() { - MENU_LABELS=("Sites & Page Timing" "Display & Interaction" "Timezone" "Hidden Site PIN") - MENU_HANDLERS=(sites_menu display_menu timezone_menu hidden_pin_menu) + MENU_LABELS=( + "Sites & Page Timing" + "Display & Interaction" + "Timezone" + "Hidden Site PIN" + "Password Protection & Lockout" + ) + MENU_HANDLERS=(sites_menu display_menu timezone_menu hidden_pin_menu lockout_menu) } main_menu_status() { diff --git a/lib/menu.sh b/lib/menu.sh index 6fa6d3f..542f43a 100644 --- a/lib/menu.sh +++ b/lib/menu.sh @@ -38,6 +38,12 @@ log_warning() { echo "⚠ $*" } +# Shared "true"/"false" -> "ON"/"OFF" label for status lines and menu +# entries showing a boolean setting's current value. +onoff() { + [[ "$1" == "true" ]] && echo "ON" || echo "OFF" +} + pause() { read -r -p "Press Enter to continue..." } @@ -112,6 +118,30 @@ ask_integer() { done } +validate_time() { + local time="$1" + [[ $time =~ ^([0-1][0-9]|2[0-3]):([0-5][0-9])$ ]] +} + +ask_time() { + local prompt="$1" + local default="$2" + local time + + while true; do + read -r -p "$prompt [$default]: " time + time="${time:-$default}" + + if validate_time "$time"; then + echo "$time" + return 0 + else + echo "❌ Invalid time format. Please use HH:MM (00:00 to 23:59)" >&2 + echo >&2 + fi + done +} + validate_url() { local url="$1" if [[ $url =~ ^(https?|file|data)://.*$ ]] || [[ $url =~ ^about: ]]; then diff --git a/menus/display.sh b/menus/display.sh index 64442a7..56172ec 100644 --- a/menus/display.sh +++ b/menus/display.sh @@ -17,22 +17,18 @@ display_status() { echo "Touch gesture mode: $SWIPE_MODE" echo "Link navigation: $ALLOW_NAVIGATION" - echo "Pause button: $(display_onoff "$ENABLE_PAUSE_BUTTON")" - echo "Keyboard button: $(display_onoff "$ENABLE_KEYBOARD_BUTTON")" - echo "Navigation button: $(display_onoff "$ENABLE_NAV_BUTTON")" -} - -display_onoff() { - [[ "$1" == "true" ]] && echo "ON" || echo "OFF" + echo "Pause button: $(onoff "$ENABLE_PAUSE_BUTTON")" + echo "Keyboard button: $(onoff "$ENABLE_KEYBOARD_BUTTON")" + echo "Navigation button: $(onoff "$ENABLE_NAV_BUTTON")" } display_menu_builder() { MENU_LABELS=( "Touch gesture mode (currently: $SWIPE_MODE)" "Link navigation security (currently: $ALLOW_NAVIGATION)" - "Toggle pause button (currently: $(display_onoff "$ENABLE_PAUSE_BUTTON"))" - "Toggle on-screen keyboard button (currently: $(display_onoff "$ENABLE_KEYBOARD_BUTTON"))" - "Toggle navigation/help button (currently: $(display_onoff "$ENABLE_NAV_BUTTON"))" + "Toggle pause button (currently: $(onoff "$ENABLE_PAUSE_BUTTON"))" + "Toggle on-screen keyboard button (currently: $(onoff "$ENABLE_KEYBOARD_BUTTON"))" + "Toggle navigation/help button (currently: $(onoff "$ENABLE_NAV_BUTTON"))" ) MENU_HANDLERS=( action_set_touch_mode diff --git a/menus/lockout.sh b/menus/lockout.sh new file mode 100644 index 0000000..9810c1a --- /dev/null +++ b/menus/lockout.sh @@ -0,0 +1,187 @@ +#!/bin/bash +################################################################################ +# menus/lockout.sh - "Password Protection & Lockout" menu. +# +# Fifth menu migrated. Back to config.json (like Display), but with a +# sensitive field: the lockout password is SHA-256 hashed before it's +# ever written to disk (matching the Electron app's comparison logic in +# main.js) - LOCKOUT_PASSWORD must never hold plaintext. +# +# Unlike the legacy configure_password_protection wizard (walk through +# every question once, then one final "save these changes? y/n"), this +# follows the same immediate-save pattern as every other migrated menu: +# each action is a complete, standalone change. Re-running "Enable" to +# change your mind is just as easy as the old "discard changes" path, +# and there's no separate confirm-at-the-end step to forget. +# +# LOCKOUT_ACTIVE_START/LOCKOUT_ACTIVE_END are intentionally never touched +# here - the app doesn't act on them (see Readme "Configuration Files"), +# so lib/config.sh just carries whatever is already in config.json +# through unchanged. +# +# Depends on: lib/menu.sh, lib/config.sh being sourced first. +################################################################################ + +lockout_status() { + if [[ "$ENABLE_PASSWORD_PROTECTION" == "true" ]]; then + echo "Password protection: ENABLED" + echo "Inactivity lockout: ${LOCKOUT_TIMEOUT} minutes$( [[ "$LOCKOUT_TIMEOUT" == "0" ]] && echo " (disabled - boot/wake only)")" + if [[ -n "$LOCKOUT_AT_TIME" ]]; then + echo "Daily lock time: $LOCKOUT_AT_TIME" + else + echo "Daily lock time: not set" + fi + echo "Password on boot: $(onoff "$REQUIRE_PASSWORD_ON_BOOT")" + else + echo "Password protection: disabled" + fi +} + +lockout_menu_builder() { + if [[ "$ENABLE_PASSWORD_PROTECTION" == "true" ]]; then + MENU_LABELS=( + "Change lockout password" + "Change inactivity lockout timeout (currently: ${LOCKOUT_TIMEOUT}m)" + "Set/clear daily lock time (currently: ${LOCKOUT_AT_TIME:-not set})" + "Toggle require password on boot (currently: $(onoff "$REQUIRE_PASSWORD_ON_BOOT"))" + "Disable password protection" + ) + MENU_HANDLERS=( + action_change_password + action_change_timeout + action_change_daily_lock + action_toggle_boot_password + action_disable_protection + ) + else + MENU_LABELS=("Enable password protection") + MENU_HANDLERS=(action_enable_protection) + fi +} + +lockout_menu() { + load_existing_config + run_menu "PASSWORD PROTECTION & LOCKOUT" lockout_menu_builder lockout_status +} + +################################################################################ +# Shared helpers +################################################################################ + +# Prompts for a new password twice, hashes it, and assigns to +# LOCKOUT_PASSWORD. Returns 1 (without saving) if the user gives up. +prompt_and_hash_password() { + local pass1 pass2 + while true; do + read -r -s -p "Enter password: " pass1 + echo + read -r -s -p "Confirm password: " pass2 + echo + + if [[ -z "$pass1" ]]; then + echo "❌ Password cannot be empty" + continue + fi + + if [[ "$pass1" != "$pass2" ]]; then + echo "❌ Passwords don't match, try again" + continue + fi + + LOCKOUT_PASSWORD=$(echo -n "$pass1" | sha256sum | cut -d' ' -f1) + return 0 + done +} + +################################################################################ +# Actions +################################################################################ + +action_enable_protection() { + echo + echo "Add password protection with automatic lockout:" + echo " • Blank screen after an inactivity period" + echo " • Password required to unlock" + echo " • Password required after display schedule wake-up" + echo " • Optional: lock at a specific time daily" + echo " • Optional: require password on system boot" + echo + + echo "Set lockout password:" + prompt_and_hash_password + + echo + echo "Session lockout time (minutes of inactivity)." + echo "Enter 0 to only require a password after display wake or boot." + LOCKOUT_TIMEOUT=$(ask_integer "Lockout timeout in minutes" "30" 0 1440) + + echo + if ask_yes_no "Lock automatically at a specific time each day?" "n"; then + LOCKOUT_AT_TIME=$(ask_time "Time to lock (24-hour HH:MM)" "17:00") + else + LOCKOUT_AT_TIME="" + fi + + echo + if ask_yes_no "Require password on system boot/power on?" "y"; then + REQUIRE_PASSWORD_ON_BOOT="true" + else + REQUIRE_PASSWORD_ON_BOOT="false" + fi + + ENABLE_PASSWORD_PROTECTION="true" + log_success "Password protection enabled (lockout: ${LOCKOUT_TIMEOUT}m)" + save_config +} + +action_disable_protection() { + ENABLE_PASSWORD_PROTECTION="false" + LOCKOUT_PASSWORD="" + LOCKOUT_TIMEOUT=0 + LOCKOUT_AT_TIME="" + REQUIRE_PASSWORD_ON_BOOT="false" + log_success "Password protection disabled" + save_config +} + +action_change_password() { + echo + prompt_and_hash_password + log_success "Password updated" + save_config +} + +action_change_timeout() { + echo + echo "Session lockout time (minutes of inactivity)." + echo "Enter 0 to only require a password after display wake or boot." + LOCKOUT_TIMEOUT=$(ask_integer "Lockout timeout in minutes" "$LOCKOUT_TIMEOUT" 0 1440) + log_success "Lockout timeout: ${LOCKOUT_TIMEOUT}m" + save_config +} + +action_change_daily_lock() { + echo + local default_prompt + [[ -n "$LOCKOUT_AT_TIME" ]] && default_prompt="y" || default_prompt="n" + + if ask_yes_no "Lock automatically at a specific time each day?" "$default_prompt"; then + LOCKOUT_AT_TIME=$(ask_time "Time to lock (24-hour HH:MM)" "${LOCKOUT_AT_TIME:-17:00}") + log_success "Will lock at ${LOCKOUT_AT_TIME} daily" + else + LOCKOUT_AT_TIME="" + log_success "Daily lock time cleared" + fi + save_config +} + +action_toggle_boot_password() { + if [[ "$REQUIRE_PASSWORD_ON_BOOT" == "true" ]]; then + REQUIRE_PASSWORD_ON_BOOT="false" + log_warning "Password on boot disabled" + else + REQUIRE_PASSWORD_ON_BOOT="true" + log_success "Password on boot enabled" + fi + save_config +} diff --git a/ubuntu-based-kiosk.sh b/ubuntu-based-kiosk.sh index d15874f..4574bfd 100644 --- a/ubuntu-based-kiosk.sh +++ b/ubuntu-based-kiosk.sh @@ -1,8 +1,21 @@ #!/bin/bash ################################################################################ -### Ubuntu Based Kiosk v2.1.0 ### +### Ubuntu Based Kiosk v2.2.0 ### ################################################################################ # +# RELEASE v2.2.0 - Password Protection & Lockout Migrated +# - New in ./install.sh: Password Protection & Lockout (menus/lockout.sh) - +# enable/disable, change password (SHA-256 hashed before it's ever +# written to disk, matching main.js's comparison logic - never +# plaintext), inactivity timeout, daily lock time, boot password. +# - Fixed: lib/menu.sh was missing ask_time/validate_time entirely (only +# caught by testing this menu, before it shipped - "Set daily lock +# time" would have failed with "ask_time: command not found" for every +# user). Ported from the legacy script; also promoted the ON/OFF +# toggle-label helper (previously private to menus/display.sh) to a +# shared `onoff()` in lib/menu.sh so menus/lockout.sh doesn't have to +# depend on menus/display.sh - menus should only ever depend on lib/. +# # RELEASE v2.1.0 - Two More Menus Migrated, Menu Framework Hardened # - New in ./install.sh: Timezone (menus/timezone.sh) and Hidden Site PIN # (menus/hidden_pin.sh) menus, alongside Sites & Page Timing and Display @@ -109,7 +122,7 @@ set -euo pipefail ### SECTION 1: CONSTANTS & GLOBALS ################################################################################ -SCRIPT_VERSION="2.1.0" +SCRIPT_VERSION="2.2.0" # Resolve the real path to this script file. # When piped (curl|bash or wget|bash), BASH_SOURCE[0] is a pipe descriptor, From 2375bf5eab09792668651499215cafba55c15523 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 18:28:49 +0000 Subject: [PATCH 05/19] Migrate WiFi and Power/Display/Quiet Hours menus; bump to v2.3.0 By far the riskiest menus migrated so far. Both can affect real system state outside config.json in ways that are hard to reverse: WiFi rewrites live netplan config and, over SSH, can disconnect the very session configuring it; power scheduling can shut the physical machine down and wake it via RTC. - lib/config.sh: new $SYSTEMD_DIR/$CRON_D_DIR/$BIN_DIR/$NETPLAN_DIR, same `: "${VAR:=default}"` pattern as $KIOSK_DIR. Nothing under menus/ hardcodes /etc/systemd/system, /etc/cron.d, /usr/local/bin, or /etc/netplan directly, so every test in this change points them at scratch space instead of ever touching this sandbox's real systemd units, cron, or network config. - lib/menu.sh: ported get_ip_address (also fixing its "No IP" fallback, which never actually fired before - `hostname -I | awk` always exits 0 even on empty output). - menus/wifi.sh: apply_wifi_config split out from wifi_menu specifically so tests can drive the netplan-writing logic without needing real scan hardware. Preserves the legacy netplan backup, 60s SSH watchdog, and restore-on-failure behavior exactly. - menus/power_schedule.sh: power schedule (+ RTC wake), display schedule, quiet hours, and an Electron reload timer (with its own nested run_menu, mirroring the legacy configured/not-configured dispatch), plus remove-all. Deliberately excludes the legacy dispatcher's "Test schedules & system" - a shared diagnostics submenu (audio/network/keyboard tests) that isn't specific to scheduling and belongs with a future Advanced/Diagnostics migration instead. Bugs found and fixed along the way, none papered over: - The legacy dispatcher refused to open "Configure power schedule" at all without RTC hardware, even though shutdown-only mode never needed RTC. Now always available. - None of the six HH:MM prompts across these menus (shutdown, wake, display off/on, quiet start/end, custom Electron reload time) were validated before - plain `read`, no format check. All now go through ask_time. - set -e safety (same class as the v2.1.0 run_menu fix), three more instances: `ls *.yaml` when no netplan file exists still fails under pipefail even with stderr silenced (masked in practice by cloud-init usually leaving a file behind); the restore-and-reapply `netplan apply` after an initial failure was a bare unguarded statement; and `systemctl enable`/`start` after writing each of the four timer pairs was unguarded too - caught only by testing in an environment without a live systemd, but a real enable/start failure on actual hardware (bad unit, daemon-reload skipped, ...) would hit the exact same crash. Added a shared enable_and_start_timers() helper used at all four call sites; all now report a clear warning and return to the menu instead of taking the session down. Testing discipline for this round, given the risk: - No automated test calls the real netplan/nmcli/iw/wpa_cli/systemctl - confirmed no WiFi tools or `wl*` interface exist in this sandbox, so wifi_menu's own tools-check safely short-circuits before touching anything; apply_wifi_config's actual YAML/backup/failure-recovery logic is tested with sudo/netplan/get_ip_address stubbed instead. - One stubbing pitfall caught and fixed in the test itself: `nohup sudo bash "$watchdog" ... &` execs nohup as a real external binary, which then execs the real sudo - a bash function stub named `sudo` does NOT intercept that, only stubbing `nohup` itself does. Verified via pgrep that no real watchdog process or `sleep 60` was ever spawned. - power_schedule.sh tested with SYSTEMD_DIR/CRON_D_DIR/BIN_DIR pointed at scratch dirs and only `sudo systemctl` stubbed (tee/rm/chmod/cp left real, since they only ever touch scratch paths): full lifecycle for all four schedule types plus remove-all, the RTC-available branch (including the wake-time-before-shutdown-time hour/day wraparound arithmetic) via a stubbed rtc_wake_available, and the new enable_and_start_timers failure path via a stub that fails `enable` specifically. - End-to-end: ran the real install.sh as a genuine non-root, non- "kiosk" user for both menus. WiFi correctly short-circuits on missing tools without crashing. Power/Display/Quiet Hours (SYSTEMD_DIR/ CRON_D_DIR/BIN_DIR redirected to scratch space) configured all four schedule types in sequence including the nested Electron Reload menu, survived four consecutive real "systemctl enable/start failed" warnings (this container has no live systemd) without the session dying, then removed everything - confirmed the scratch dirs ended up empty and config.json was never touched (correctly out of scope for this menu). --- Readme.md | 26 +- install.sh | 19 +- lib/config.sh | 10 + lib/menu.sh | 13 + menus/power_schedule.sh | 660 ++++++++++++++++++++++++++++++++++++++++ menus/wifi.sh | 241 +++++++++++++++ ubuntu-based-kiosk.sh | 39 ++- 7 files changed, 998 insertions(+), 10 deletions(-) create mode 100644 menus/power_schedule.sh create mode 100644 menus/wifi.sh diff --git a/Readme.md b/Readme.md index d4499d4..8e137e6 100644 --- a/Readme.md +++ b/Readme.md @@ -1,6 +1,6 @@ # Ubuntu Based Kiosk -**Current Version:** 2.2.0 (check script header for latest version) +**Current Version:** 2.3.0 (check script header for latest version) **Built with Claude Sonnet 4.6 AI assistance** **License:** GPL v3 - Keep derivatives open source **Repository:** https://github.com/outis1one/ubuntu-based-kiosk/ @@ -1194,6 +1194,14 @@ terminal menu and the web UI, so they can't drift apart). change password, inactivity timeout, daily lock time, boot password. The password is SHA-256 hashed before it's ever written to disk, same as the legacy menu — never stored as plaintext. +- `menus/wifi.sh` — **WiFi**: the riskiest menu so far — rewrites live + netplan config and, over SSH, can disconnect the session configuring + it. Preserves the legacy menu's netplan backup, 60-second SSH + watchdog, and restore-on-failure exactly. +- `menus/power_schedule.sh` — **Power/Display/Quiet Hours**: scheduled + shutdown (+ RTC wake where available), display on/off, quiet-hours + audio muting, and an Electron reload timer, each as systemd timers. + Can power the physical machine off and on a schedule. - `install.sh` — entry point for the modular tool. Run it against an *already-installed* kiosk: ```bash @@ -1205,9 +1213,8 @@ terminal menu and the web UI, so they can't drift apart). **Honest status:** this does not yet replace first-time installation, or most of the old installer. `ubuntu-based-kiosk.sh` is still ~12,000 lines and still contains its own unremoved, unmodified copies of every -menu above (plus WiFi, Power/Display/Quiet Hours, Upgrade, Reinstall, -Uninstall, all Addons, and all of Advanced — none of that has moved -yet). Both copies coexist deliberately: the old ones stay until enough +menu above (plus Upgrade, Reinstall, Uninstall, all Addons, and all of +Advanced — none of that has moved yet). 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 @@ -1218,9 +1225,16 @@ at all. ## Project Status & Future Plans -**Current Version:** 2.2.0 +**Current Version:** 2.3.0 -**Recent Updates (v2.2.0):** +**Recent Updates (v2.3.0):** +- **WiFi and Power/Display/Quiet Hours migrated** — by far the riskiest menus tackled so far. WiFi rewrites live netplan config and, over SSH, can disconnect the session configuring it; power scheduling can shut the physical machine down and wake it via RTC. Every legacy safety mechanism is preserved exactly: netplan backup, 60-second SSH watchdog, restore-on-failure for WiFi; RTC availability detection for power scheduling. +- **Bug fix:** the legacy menu refused to open "Configure power schedule" at all without RTC hardware, even though shutdown-only scheduling never needed it. +- **Bug fix:** none of the six HH:MM time prompts across these menus were format-validated before — a typo silently produced a broken schedule. All now go through the same `ask_time` validator as everywhere else. +- **Bug fix (set -e safety):** several more bare statements whose failure would have killed the entire session — `ls *.yaml` with no netplan file present, the backup-restore reapply after a failed `netplan apply`, and `systemctl enable`/`start` after writing each timer pair. The last was only caught by testing without a live systemd; a real failure on actual hardware would have hit the same crash. All now report a warning and return to the menu. +- Deliberately **not** migrated: the legacy "Test schedules & system" option, which leads into a shared diagnostics submenu (audio/network/keyboard tests) unrelated to scheduling — that belongs with a future Advanced/Diagnostics pass. + +**Previous (v2.2.0):** - **Fifth menu migrated:** Password Protection & Lockout (`menus/lockout.sh`) — enable/disable, change password, inactivity timeout, daily lock time, boot password. The password is SHA-256 hashed before it's ever written to `config.json` (matching the Electron app's own comparison logic) — verified never stored as plaintext. - **Bug fix:** `lib/menu.sh` was missing `ask_time`/`validate_time` entirely — caught by testing this menu before it shipped; "set a daily lock time" would otherwise have failed for every user. Ported from the legacy script. - **Refactor:** promoted the ON/OFF toggle-label helper out of `menus/display.sh` into a shared `onoff()` in `lib/menu.sh`, so `menus/lockout.sh` doesn't need to depend on another menu file — menus only ever depend on `lib/`. diff --git a/install.sh b/install.sh index 6e9bdcb..fbf9eb4 100755 --- a/install.sh +++ b/install.sh @@ -15,7 +15,8 @@ # Migrated so far: Sites & Page Timing (menus/sites.sh), Display & # Interaction (menus/display.sh), Timezone (menus/timezone.sh), Hidden # Site PIN (menus/hidden_pin.sh), Password Protection & Lockout -# (menus/lockout.sh). +# (menus/lockout.sh), WiFi (menus/wifi.sh), Power/Display/Quiet Hours +# (menus/power_schedule.sh). # # Usage (once the kiosk has already been installed): # git clone @@ -41,6 +42,10 @@ source "$SCRIPT_DIR/menus/timezone.sh" source "$SCRIPT_DIR/menus/hidden_pin.sh" # shellcheck source=menus/lockout.sh source "$SCRIPT_DIR/menus/lockout.sh" +# shellcheck source=menus/wifi.sh +source "$SCRIPT_DIR/menus/wifi.sh" +# shellcheck source=menus/power_schedule.sh +source "$SCRIPT_DIR/menus/power_schedule.sh" ################################################################################ # Preflight @@ -83,8 +88,18 @@ main_menu_builder() { "Timezone" "Hidden Site PIN" "Password Protection & Lockout" + "WiFi" + "Power/Display/Quiet Hours" + ) + MENU_HANDLERS=( + sites_menu + display_menu + timezone_menu + hidden_pin_menu + lockout_menu + wifi_menu + power_schedule_menu ) - MENU_HANDLERS=(sites_menu display_menu timezone_menu hidden_pin_menu lockout_menu) } main_menu_status() { diff --git a/lib/config.sh b/lib/config.sh index 7d10b80..3fd763c 100644 --- a/lib/config.sh +++ b/lib/config.sh @@ -20,6 +20,16 @@ : "${KIOSK_DIR:=${KIOSK_HOME}/kiosk-app}" : "${CONFIG_PATH:=${KIOSK_DIR}/config.json}" +# System paths that menus (e.g. power/display/quiet-hours scheduling) +# write units, scripts, and cron entries into. Overridable so tests can +# point them at a scratch directory instead of the real system - nothing +# under menus/ should ever hardcode /etc/systemd/system, /etc/cron.d, or +# /usr/local/bin directly. +: "${SYSTEMD_DIR:=/etc/systemd/system}" +: "${CRON_D_DIR:=/etc/cron.d}" +: "${BIN_DIR:=/usr/local/bin}" +: "${NETPLAN_DIR:=/etc/netplan}" + # Site/tab arrays declare -a URLS=() declare -a DURS=() diff --git a/lib/menu.sh b/lib/menu.sh index 542f43a..95c6177 100644 --- a/lib/menu.sh +++ b/lib/menu.sh @@ -44,6 +44,19 @@ onoff() { [[ "$1" == "true" ]] && echo "ON" || echo "OFF" } +# Current primary IP, or the literal "No IP" if there isn't one (e.g. no +# network yet). Callers that only care whether there's an address should +# still check for -n on top of this, since "No IP" is itself non-empty. +get_ip_address() { + local ip + ip=$(hostname -I 2>/dev/null | awk '{print $1}') + if [[ -n "$ip" ]]; then + echo "$ip" + else + echo "No IP" + fi +} + pause() { read -r -p "Press Enter to continue..." } diff --git a/menus/power_schedule.sh b/menus/power_schedule.sh new file mode 100644 index 0000000..80832f4 --- /dev/null +++ b/menus/power_schedule.sh @@ -0,0 +1,660 @@ +#!/bin/bash +################################################################################ +# menus/power_schedule.sh - "Power / Display / Quiet Hours" menu. +# +# Sixth menu migrated, and the biggest and riskiest so far: it writes +# systemd timers/services, a cron entry, and shell scripts that can power +# off the physical machine, blank the display, mute audio, and (via RTC) +# wake the machine back up on a schedule. Every write goes through +# $SYSTEMD_DIR / $CRON_D_DIR / $BIN_DIR (lib/config.sh) rather than +# hardcoded /etc/systemd/system, /etc/cron.d, /usr/local/bin, so tests can +# point them at a scratch directory - this file must never assume it's +# safe to actually mutate the real system just because it's running. +# +# Deliberately out of scope: the legacy dispatcher's "6. Test schedules & +# system" led into a shared diagnostics submenu (audio test, network +# test, keyboard test, ...) that isn't specific to scheduling and belongs +# with a future Advanced/Diagnostics migration instead. What *is* in +# scope - testing the schedule you just configured - stays here as the +# same inline "test now?" prompts the legacy menu already had. +# +# Bug fixed vs. the legacy configure_power_display_quiet: it refused to +# even open "Configure power schedule" when no RTC wake was detected, +# even though shutdown-only scheduling (configure_power_schedule's own +# fallback) never needed RTC in the first place. Also: none of shutdown +# time/wake time/display off/on/quiet start/end/custom Electron reload +# time were validated as HH:MM in the legacy menu (plain `read`, no +# format check) - a typo would silently produce a broken OnCalendar= +# value. All of those now go through ask_time. +# +# Depends on: lib/menu.sh, lib/config.sh being sourced first. +################################################################################ + +################################################################################ +# Shared helpers +################################################################################ + +rtc_wake_available() { + [[ -w /sys/class/rtc/rtc0/wakealarm ]] || sudo test -w /sys/class/rtc/rtc0/wakealarm 2>/dev/null +} + +timer_exists() { + [[ -f "$SYSTEMD_DIR/$1" ]] +} + +timer_oncalendar() { + grep "^OnCalendar=" "$SYSTEMD_DIR/$1" 2>/dev/null | cut -d'=' -f2 | sed 's/\*-\*-\* //' | sed 's/:00$//' +} + +# enable_and_start_timers TIMER [TIMER...] +# Reloads systemd and enables+starts the given timer units, returning +# non-zero if enable or start fails (e.g. systemd/D-Bus unreachable). +# Always call this from an `if`/`&&`/`||` context: this whole tool runs +# under set -e, so a bare, unguarded call whose last command fails would +# take down the entire session instead of just this one action. +enable_and_start_timers() { + sudo systemctl daemon-reload 2>/dev/null || true + sudo systemctl enable "$@" 2>/dev/null && sudo systemctl start "$@" 2>/dev/null +} + +################################################################################ +# Top-level menu +################################################################################ + +power_schedule_status() { + local any=false + + if timer_exists kiosk-shutdown.timer; then + any=true + local t; t=$(timer_oncalendar kiosk-shutdown.timer) + echo "Power: shutdown daily at ${t:-an unknown time}" + fi + if timer_exists kiosk-display-off.timer; then + any=true + echo "Display: off at $(timer_oncalendar kiosk-display-off.timer), on at $(timer_oncalendar kiosk-display-on.timer)" + fi + if timer_exists kiosk-quiet-start.timer; then + any=true + echo "Quiet: $(timer_oncalendar kiosk-quiet-start.timer) to $(timer_oncalendar kiosk-quiet-end.timer)" + fi + if timer_exists kiosk-electron-reload.timer; then + any=true + echo "Reload: enabled ($(timer_oncalendar kiosk-electron-reload.timer))" + fi + $any || echo "No schedules configured" + + echo + if rtc_wake_available; then + echo "RTC wake: available (can schedule power on/off)" + else + echo "RTC wake: not available (display/quiet/reload scheduling still works)" + fi +} + +power_schedule_menu_builder() { + MENU_LABELS=( + "Configure power schedule$(rtc_wake_available || echo ' (shutdown only - no RTC wake)')" + "Configure display schedule" + "Configure quiet hours" + "Configure Electron reload schedule" + "Remove all schedules" + ) + MENU_HANDLERS=( + action_configure_power_schedule + action_configure_display_schedule + action_configure_quiet_hours + electron_reload_menu + action_remove_all_schedules + ) +} + +power_schedule_menu() { + run_menu "POWER / DISPLAY / QUIET HOURS" power_schedule_menu_builder power_schedule_status +} + +################################################################################ +# Power schedule +################################################################################ + +action_configure_power_schedule() { + echo + local rtc_ok=false + rtc_wake_available && rtc_ok=true + + if $rtc_ok; then + echo "RTC wake capability detected - can schedule shutdown and wake." + else + echo "RTC wake not available - shutdown only, no auto-wake." + fi + echo + + local shutdown_time wake_time="" + shutdown_time=$(ask_time "Shutdown time (24-hour HH:MM)" "22:00") + $rtc_ok && wake_time=$(ask_time "Wake time (24-hour HH:MM)" "06:00") + + sudo systemctl stop kiosk-shutdown.timer 2>/dev/null || true + sudo systemctl disable kiosk-shutdown.timer 2>/dev/null || true + sudo rm -f "$SYSTEMD_DIR/kiosk-shutdown.service" "$SYSTEMD_DIR/kiosk-shutdown.timer" + sudo rm -f "$BIN_DIR/kiosk-power-off.sh" "$BIN_DIR/rtc-wake.sh" + sudo rm -f "$CRON_D_DIR/kiosk-rtc-wake" + + sudo tee "$BIN_DIR/kiosk-power-off.sh" > /dev/null <<'EOF' +#!/bin/bash +logger "KIOSK: Scheduled shutdown initiated" +systemctl poweroff +EOF + sudo chmod +x "$BIN_DIR/kiosk-power-off.sh" + + sudo tee "$SYSTEMD_DIR/kiosk-shutdown.service" > /dev/null < /dev/null < /dev/null <<'RTCSCRIPT' +#!/bin/bash +WAKE_TIME="$1" +CURRENT=$(date +%s) +WAKE=$(date -d "$WAKE_TIME" +%s) + +# If wake time is earlier than current time, schedule for tomorrow +[[ $WAKE -le $CURRENT ]] && WAKE=$(date -d "tomorrow $WAKE_TIME" +%s) + +# Clear existing alarm +echo 0 > /sys/class/rtc/rtc0/wakealarm 2>/dev/null || true + +# Set new alarm +if echo $WAKE > /sys/class/rtc/rtc0/wakealarm 2>/dev/null; then + logger "KIOSK: RTC wake set for $(date -d @$WAKE '+%Y-%m-%d %H:%M:%S')" + echo "RTC wake set for $(date -d @$WAKE '+%Y-%m-%d %H:%M:%S')" +else + logger "KIOSK: ERROR - Failed to set RTC wake" + echo "ERROR: Failed to set RTC wake" + exit 1 +fi +RTCSCRIPT + sudo chmod +x "$BIN_DIR/rtc-wake.sh" + + local shutdown_hour="${shutdown_time%%:*}" + local shutdown_min="${shutdown_time##*:}" + local wake_min=$((10#$shutdown_min - 5)) + local wake_hour=$((10#$shutdown_hour)) + [[ $wake_min -lt 0 ]] && { wake_min=$((wake_min + 60)); wake_hour=$((wake_hour - 1)); } + [[ $wake_hour -lt 0 ]] && wake_hour=$((wake_hour + 24)) + + sudo tee "$CRON_D_DIR/kiosk-rtc-wake" > /dev/null <> /var/log/kiosk-rtc.log 2>&1 +EOF + log_info "RTC wake cron job created" + fi + + if enable_and_start_timers kiosk-shutdown.timer; then + log_success "Power schedule configured: shutdown at ${shutdown_time}$( [[ -n "$wake_time" ]] && echo ", wake at ${wake_time}")" + else + log_warning "Schedule files written, but systemctl enable/start failed - check 'systemctl status kiosk-shutdown.timer'" + fi +} + +################################################################################ +# Display schedule +################################################################################ + +action_configure_display_schedule() { + echo + if timer_exists kiosk-shutdown.timer; then + log_warning "Power shutdown configured at $(timer_oncalendar kiosk-shutdown.timer) - display will already be off by then" + echo + fi + + local doff don + doff=$(ask_time "Display OFF time (24-hour HH:MM)" "22:00") + don=$(ask_time "Display ON time (24-hour HH:MM)" "06:00") + + sudo systemctl stop kiosk-display-off.timer kiosk-display-on.timer 2>/dev/null || true + sudo systemctl disable kiosk-display-off.timer kiosk-display-on.timer 2>/dev/null || true + sudo rm -f "$SYSTEMD_DIR"/kiosk-display-off.{service,timer} "$SYSTEMD_DIR"/kiosk-display-on.{service,timer} + sudo rm -f "$BIN_DIR/kiosk-display-off.sh" "$BIN_DIR/kiosk-display-on.sh" + + sudo tee "$BIN_DIR/kiosk-display-off.sh" > /dev/null </dev/null && logger "KIOSK: xset dpms off success" || logger "KIOSK: xset dpms off failed" + +# Method 2: vbetool (if available) +if command -v vbetool &>/dev/null; then + vbetool dpms off 2>/dev/null && echo "✓ vbetool off" || echo "✗ vbetool failed" +fi + +# Method 3: Backlight control (laptops) +if [[ -d /sys/class/backlight ]]; then + for bl in /sys/class/backlight/*/brightness; do + if [[ -w "\$bl" ]]; then + echo 0 > "\$bl" 2>/dev/null && echo "✓ backlight off: \$bl" || echo "✗ backlight failed" + fi + done +fi + +logger "KIOSK: Display turned OFF (scheduled)" +EOF + sudo chmod +x "$BIN_DIR/kiosk-display-off.sh" + + sudo tee "$BIN_DIR/kiosk-display-on.sh" > /dev/null </dev/null && logger "KIOSK: xset dpms on success" || logger "KIOSK: xset dpms on failed" + +# Method 2: vbetool (if available) +if command -v vbetool &>/dev/null; then + vbetool dpms on 2>/dev/null && echo "✓ vbetool on" || echo "✗ vbetool failed" +fi + +# Method 3: Backlight control (laptops) +if [[ -d /sys/class/backlight ]]; then + for bl in /sys/class/backlight/*/brightness; do + if [[ -w "\$bl" ]]; then + cat "\${bl%/*}/max_brightness" > "\$bl" 2>/dev/null && echo "✓ backlight on: \$bl" || echo "✗ backlight failed" + fi + done +fi + +# Method 4: Wake up input (move mouse) +sudo -u ${KIOSK_USER} DISPLAY=:0 XAUTHORITY=${KIOSK_HOME}/.Xauthority xdotool mousemove 1 1 2>/dev/null && logger "KIOSK: mouse wiggle success" || logger "KIOSK: mouse wiggle failed" + +# Method 5: Signal Electron app to require password if enabled +sudo -u ${KIOSK_USER} touch ${KIOSK_DIR}/.display-wake 2>/dev/null && logger "KIOSK: password flag set" || logger "KIOSK: password flag failed" + +logger "KIOSK: Display turned ON (scheduled)" +EOF + sudo chmod +x "$BIN_DIR/kiosk-display-on.sh" + + sudo tee "$SYSTEMD_DIR/kiosk-display-off.service" > /dev/null < /dev/null < /dev/null < /dev/null </dev/null || true + sudo systemctl disable kiosk-quiet-start.timer kiosk-quiet-end.timer 2>/dev/null || true + sudo rm -f "$SYSTEMD_DIR"/kiosk-quiet-start.{service,timer} "$SYSTEMD_DIR"/kiosk-quiet-end.{service,timer} + sudo rm -f "$BIN_DIR/kiosk-quiet-start.sh" "$BIN_DIR/kiosk-quiet-end.sh" + + case "$qmode" in + 2) + sudo tee "$BIN_DIR/kiosk-quiet-start.sh" > /dev/null <<'EOF' +#!/bin/bash +systemctl stop squeezelite 2>/dev/null +logger "KIOSK: Quiet hours started - Squeezelite stopped" +echo "✓ Quiet hours: Squeezelite stopped" +EOF + sudo tee "$BIN_DIR/kiosk-quiet-end.sh" > /dev/null <<'EOF' +#!/bin/bash +systemctl start squeezelite 2>/dev/null +logger "KIOSK: Quiet hours ended - Squeezelite started" +echo "✓ Quiet hours ended: Squeezelite started" +EOF + ;; + *) + qmode=1 + sudo tee "$BIN_DIR/kiosk-quiet-start.sh" > /dev/null <<'EOF' +#!/bin/bash +# Save current volume before muting +pactl get-sink-volume @DEFAULT_SINK@ | grep -oE '[0-9]+%' | head -1 | tr -d '%' > /tmp/kiosk-vol-backup 2>/dev/null || echo "100" > /tmp/kiosk-vol-backup +pactl set-sink-mute @DEFAULT_SINK@ 1 2>/dev/null +logger "KIOSK: Quiet hours started - all audio muted" +echo "✓ Quiet hours: All audio muted" +EOF + sudo tee "$BIN_DIR/kiosk-quiet-end.sh" > /dev/null <<'EOF' +#!/bin/bash +# Restore previous volume +VOL=$(cat /tmp/kiosk-vol-backup 2>/dev/null || echo "100") +pactl set-sink-mute @DEFAULT_SINK@ 0 2>/dev/null +pactl set-sink-volume @DEFAULT_SINK@ ${VOL}% 2>/dev/null +logger "KIOSK: Quiet hours ended - audio restored to ${VOL}%" +echo "✓ Quiet hours ended: Audio restored to ${VOL}%" +EOF + ;; + esac + sudo chmod +x "$BIN_DIR/kiosk-quiet-start.sh" "$BIN_DIR/kiosk-quiet-end.sh" + + sudo tee "$SYSTEMD_DIR/kiosk-quiet-start.service" > /dev/null < /dev/null < /dev/null < /dev/null </dev/null || true + sudo systemctl disable kiosk-electron-reload.timer 2>/dev/null || true + sudo rm -f "$SYSTEMD_DIR"/kiosk-electron-reload.{service,timer} + sudo rm -f "$BIN_DIR/kiosk-reload-electron" + + sudo tee "$BIN_DIR/kiosk-reload-electron" > /dev/null <<'RELOADSCRIPT' +#!/bin/bash +logger "KIOSK: Scheduled Electron reload" +systemctl restart lightdm +RELOADSCRIPT + sudo chmod +x "$BIN_DIR/kiosk-reload-electron" + + sudo tee "$SYSTEMD_DIR/kiosk-electron-reload.service" > /dev/null < /dev/null </dev/null || true + sudo systemctl disable kiosk-electron-reload.timer 2>/dev/null || true + sudo rm -f "$SYSTEMD_DIR"/kiosk-electron-reload.{service,timer} + sudo rm -f "$BIN_DIR/kiosk-reload-electron" + sudo systemctl daemon-reload 2>/dev/null || true + log_success "Automatic Electron reload disabled" + fi +} + +################################################################################ +# Remove all +################################################################################ + +action_remove_all_schedules() { + echo + ask_yes_no "Remove ALL power/display/quiet/reload schedules?" "n" || { echo "Cancelled"; return; } + + for timer in kiosk-shutdown kiosk-display-off kiosk-display-on kiosk-quiet-start kiosk-quiet-end kiosk-electron-reload; do + sudo systemctl stop "${timer}.timer" 2>/dev/null || true + sudo systemctl disable "${timer}.timer" 2>/dev/null || true + done + + sudo rm -f "$SYSTEMD_DIR"/kiosk-shutdown.{service,timer} + sudo rm -f "$SYSTEMD_DIR"/kiosk-display-off.{service,timer} "$SYSTEMD_DIR"/kiosk-display-on.{service,timer} + sudo rm -f "$SYSTEMD_DIR"/kiosk-quiet-start.{service,timer} "$SYSTEMD_DIR"/kiosk-quiet-end.{service,timer} + sudo rm -f "$SYSTEMD_DIR"/kiosk-electron-reload.{service,timer} + sudo rm -f "$BIN_DIR/kiosk-power-off.sh" "$BIN_DIR/kiosk-display-off.sh" "$BIN_DIR/kiosk-display-on.sh" + sudo rm -f "$BIN_DIR/kiosk-quiet-start.sh" "$BIN_DIR/kiosk-quiet-end.sh" + sudo rm -f "$BIN_DIR/rtc-wake.sh" "$BIN_DIR/kiosk-reload-electron" + sudo rm -f "$CRON_D_DIR/kiosk-rtc-wake" + + sudo systemctl daemon-reload 2>/dev/null || true + + log_success "All schedules removed" +} diff --git a/menus/wifi.sh b/menus/wifi.sh new file mode 100644 index 0000000..e81834e --- /dev/null +++ b/menus/wifi.sh @@ -0,0 +1,241 @@ +#!/bin/bash +################################################################################ +# menus/wifi.sh - "WiFi" configuration. +# +# HIGH RISK, unlike anything migrated so far: this changes live network +# configuration and, if run over SSH, can disconnect the very session +# configuring it. Every safety mechanism from the legacy configure_wifi +# is preserved exactly: a netplan backup before writing, a 60-second +# watchdog (armed only when $SSH_CONNECTION is set) that reverts to the +# backup if the new config never comes up, and an explicit "restore +# backup?" prompt if `netplan apply` itself fails outright. +# +# Netplan's directory is $NETPLAN_DIR (lib/config.sh) rather than a +# hardcoded /etc/netplan, so a test can point it at scratch space. But +# unlike every other migrated menu, there is deliberately no automated +# test - not even a stubbed one - that calls the real `netplan apply`, +# `nmcli`, `iw`, `wpa_cli`, or `sudo ip link set ... up`. Only the pure +# logic (SSID/password handling, YAML generation, backup naming) is +# covered by tests with those commands stubbed; the actual apply step +# is exercised by hand against real hardware only. +# +# Unlike the other migrated menus, this one has no sub-options - it's a +# single linear wizard, same as the legacy configure_wifi - so wifi_menu +# IS the action, not a run_menu wrapper. +# +# Depends on: lib/menu.sh, lib/config.sh being sourced first. +################################################################################ + +wifi_menu() { + echo + echo " ═══ WIFI CONFIGURATION ═══" + echo + + local has_tools=false + if command -v nmcli &>/dev/null || command -v iw &>/dev/null || command -v wpa_cli &>/dev/null; then + has_tools=true + fi + + if ! $has_tools; then + log_error "No WiFi tools found (nmcli, iw, or wpa_cli)" + echo "Install: sudo apt install network-manager wireless-tools wpasupplicant" + return 1 + fi + + local wifi_iface + wifi_iface=$(ls /sys/class/net 2>/dev/null | grep -E "^wl" | head -1) + + if [[ -z "$wifi_iface" ]]; then + log_warning "No WiFi hardware detected" + echo "If you have a USB WiFi adapter, ensure it's plugged in." + return 1 + fi + + echo "Interface: $wifi_iface" + echo "Current IP: $(get_ip_address)" + echo + + if [[ -n "${SSH_CONNECTION:-}" ]]; then + log_warning "SSH detected - changes auto-revert after 60s if the connection fails" + echo + fi + + ask_yes_no "Configure WiFi?" "n" || return 0 + + echo "Bringing up interface..." + if ! sudo ip link set "$wifi_iface" up 2>/dev/null; then + log_error "Failed to bring up interface" + return 1 + fi + sleep 3 + + echo "Scanning for networks (this takes 5-10 seconds)..." + local scan_results="" + + if command -v nmcli &>/dev/null; then + if sudo nmcli device wifi rescan 2>/dev/null; then + sleep 5 + scan_results=$(nmcli -t -f SSID,SIGNAL device wifi list 2>/dev/null | sort -t: -k2 -rn | cut -d: -f1 | grep -v "^$" | uniq) + fi + fi + + if [[ -z "$scan_results" ]] && command -v iw &>/dev/null; then + local scan_tmp + scan_tmp=$(mktemp) + if sudo iw dev "$wifi_iface" scan 2>/dev/null | grep -E "^BSS|SSID:" > "$scan_tmp"; then + scan_results=$(grep "SSID:" "$scan_tmp" | sed 's/.*SSID: //' | grep -v "^$" | sort -u) + fi + rm -f "$scan_tmp" + fi + + if [[ -z "$scan_results" ]]; then + sudo wpa_cli -i "$wifi_iface" scan >/dev/null 2>&1 || true + sleep 5 + scan_results=$(sudo wpa_cli -i "$wifi_iface" scan_results 2>/dev/null | awk -F'\t' 'NR>1 && $5!="" {print $5}' | sort -u) + fi + + local ssid="" + if [[ -z "$scan_results" ]]; then + log_warning "No networks found in scan" + echo "This could mean:" + echo " • WiFi is disabled in BIOS/UEFI" + echo " • Hardware WiFi switch is off" + echo " • Driver not loaded" + echo " • Networks out of range" + echo + if ask_yes_no "Enter SSID manually anyway?" "n"; then + read -r -p "SSID: " ssid + else + return 1 + fi + else + echo + echo "Available networks (strongest first):" + echo "$scan_results" | nl -w2 -s'. ' + echo " 0. Manual entry" + echo + local choice + read -r -p "Select network number or enter SSID: " choice + if [[ "$choice" == "0" ]]; then + read -r -p "SSID: " ssid + elif [[ "$choice" =~ ^[0-9]+$ ]]; then + ssid=$(echo "$scan_results" | sed -n "${choice}p") + else + ssid="$choice" + fi + fi + + if [[ -z "$ssid" ]]; then + log_error "No SSID provided" + return 1 + fi + + local password + read -r -s -p "Password for '$ssid': " password + echo + if [[ -z "$password" ]]; then + log_error "No password provided" + return 1 + fi + + apply_wifi_config "$wifi_iface" "$ssid" "$password" +} + +# apply_wifi_config IFACE SSID PASSWORD +# Split out from wifi_menu so a test can drive it directly without going +# through interface detection/scanning, which don't exist in a container. +apply_wifi_config() { + local wifi_iface="$1" + local ssid="$2" + local password="$3" + + # `|| true`: under set -e + pipefail (this whole tool runs under both), + # `ls` matching nothing exits non-zero even with stderr silenced, which + # would abort this function outright instead of falling through to the + # default filename below. Same class of bug as the run_menu fix in + # lib/menu.sh - masked here in practice because cloud-init almost + # always leaves a *.yaml file behind, but not guaranteed. + local netplan_file + netplan_file=$(ls "$NETPLAN_DIR"/*.yaml 2>/dev/null | head -1) || true + [[ -z "$netplan_file" ]] && netplan_file="$NETPLAN_DIR/50-cloud-init.yaml" + + local backup="" + if [[ -f "$netplan_file" ]]; then + backup="${netplan_file}.backup-$(date +%Y%m%d-%H%M%S)" + sudo cp "$netplan_file" "$backup" + log_success "Backup: $backup" + fi + + local temp_plan + temp_plan=$(mktemp --suffix=.yaml) + cat > "$temp_plan" < "$watchdog" <<'WATCHEOF' +#!/bin/bash +sleep 60 +if [[ -f "$1" && -f "$2" ]]; then + ip=$(hostname -I | awk '{print $1}') + if [[ -z "$ip" ]] || ! ping -c 2 8.8.8.8 >/dev/null 2>&1; then + cp "$1" "$2" + netplan apply 2>/dev/null + echo "WiFi config reverted - connection failed" | wall + fi +fi +rm -f "$0" +WATCHEOF + chmod +x "$watchdog" + nohup sudo bash "$watchdog" "$backup" "$netplan_file" >/dev/null 2>&1 & + echo "Watchdog started - will revert in 60s if the connection fails" + fi + + sudo cp "$temp_plan" "$netplan_file" + sudo chmod 0600 "$netplan_file" + rm -f "$temp_plan" + + echo "Applying configuration..." + local netplan_log + netplan_log=$(mktemp) + if sudo netplan apply > "$netplan_log" 2>&1; then + cat "$netplan_log" + sleep 10 + local new_ip + new_ip=$(get_ip_address) + if [[ -n "$new_ip" && "$new_ip" != "No IP" ]]; then + log_success "Connected: $ssid ($new_ip)" + [[ -n "${SSH_CONNECTION:-}" ]] && echo "Connection successful - watchdog will not revert" + else + log_warning "Config applied but no IP yet" + echo "Check: sudo journalctl -u systemd-networkd -f" + fi + else + log_error "netplan apply failed" + echo "Error log:" + cat "$netplan_log" + if [[ -n "$backup" ]] && ask_yes_no "Restore backup?" "y"; then + sudo cp "$backup" "$netplan_file" + # Last resort after everything else failed: report, don't crash + # the session if even the restore-and-reapply doesn't work. + if sudo netplan apply; then + log_success "Backup restored and applied" + else + log_error "Failed to reapply the restored backup - manual intervention needed" + fi + fi + fi + rm -f "$netplan_log" +} diff --git a/ubuntu-based-kiosk.sh b/ubuntu-based-kiosk.sh index 4574bfd..563a768 100644 --- a/ubuntu-based-kiosk.sh +++ b/ubuntu-based-kiosk.sh @@ -1,8 +1,43 @@ #!/bin/bash ################################################################################ -### Ubuntu Based Kiosk v2.2.0 ### +### Ubuntu Based Kiosk v2.3.0 ### ################################################################################ # +# RELEASE v2.3.0 - WiFi and Power/Display/Quiet Hours Migrated +# - New in ./install.sh: WiFi (menus/wifi.sh) and Power/Display/Quiet +# Hours (menus/power_schedule.sh) - by far the biggest and riskiest +# menus migrated so far. WiFi can rewrite live netplan config and, if +# run over SSH, disconnect the very session configuring it; Power +# schedule can shut the physical machine down and wake it via RTC. +# Every safety mechanism from the legacy menus is preserved exactly: +# netplan backup + 60s SSH watchdog + restore-on-apply-failure for +# WiFi; RTC availability detection for power scheduling. New +# $SYSTEMD_DIR/$CRON_D_DIR/$BIN_DIR/$NETPLAN_DIR variables (lib/config.sh) +# mean nothing under menus/ hardcodes /etc/systemd/system, /etc/cron.d, +# /usr/local/bin, or /etc/netplan - tests point them at scratch space. +# - Fixed: the legacy dispatcher refused to open "Configure power +# schedule" at all when no RTC wake was detected, even though +# shutdown-only scheduling never needed RTC in the first place. +# - Fixed: none of shutdown/wake/display-off/display-on/quiet-start/ +# quiet-end/custom-Electron-reload times were validated as HH:MM in +# the legacy menus (plain `read`, no format check) - now all go +# through ask_time. +# - Fixed (set -e safety, same class as v2.1.0's run_menu fix): several +# bare, unguarded statements whose failure would have taken down the +# entire session instead of just that action - `ls *.yaml` when no +# netplan file exists (masked in practice by cloud-init usually +# leaving one behind), the restore-and-reapply `netplan apply` after +# an initial apply failure, and `systemctl enable`/`start` after +# writing each of the four timer pairs. The last of these was caught +# only by testing in an environment without a live systemd - a real +# `enable`/`start` failure on actual hardware (bad unit, daemon-reload +# skipped, ...) would have hit the same bug. All now report a clear +# warning and return to the menu instead. +# - Deliberately NOT migrated: the legacy dispatcher's "Test schedules & +# system" led into a shared diagnostics submenu (audio/network/ +# keyboard tests) that isn't specific to scheduling and belongs with a +# future Advanced/Diagnostics migration instead. +# # RELEASE v2.2.0 - Password Protection & Lockout Migrated # - New in ./install.sh: Password Protection & Lockout (menus/lockout.sh) - # enable/disable, change password (SHA-256 hashed before it's ever @@ -122,7 +157,7 @@ set -euo pipefail ### SECTION 1: CONSTANTS & GLOBALS ################################################################################ -SCRIPT_VERSION="2.2.0" +SCRIPT_VERSION="2.3.0" # Resolve the real path to this script file. # When piped (curl|bash or wget|bash), BASH_SOURCE[0] is a pipe descriptor, From 459da5318210ba0befd06aa5f7eb307335e8952a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 18:48:26 +0000 Subject: [PATCH 06/19] Migrate Diagnostics menu; bump to v2.4.0 Deliberately skipped Upgrade/Full Reinstall/Complete Uninstall for now: all three are large (130-250 lines), genuinely destructive (wipe/ reinstall the kiosk), and Upgrade specifically is coupled to the legacy script's own self-extraction mechanism (it greps its own running source for embedded heredocs to pull out main.js/preload.js) - there's no modular equivalent to migrate it to yet, since those files don't exist as separate assets outside the monolith. Migrated Diagnostics instead: 4 of the legacy Advanced menu's 12 items (System Status, View Logs, Audio Diagnostics, Network Test), all read-only except one optional "play a test sound?" prompt - a deliberate change of pace with no destructive-action risk to design around, after Sites/WiFi/Power. - lib/menu.sh: ported get_vpn_ips alongside get_ip_address. - menus/diagnostics.sh: straight port, using $KIOSK_USER/$KIOSK_HOME throughout instead of the legacy code's mix of the variable and a hardcoded "kiosk" literal. Bug fixed, same set -e-safety class as v2.1.0's run_menu fix and v2.3.0's netplan/systemctl fixes, but a bigger batch this time: nearly every diagnostic command here was a bare unguarded statement whose *expected, common* failure - no lightdm running, no audio hardware, no network, missing log files, ping/nslookup not even installed - would have crashed the entire session instead of reporting "not found" and continuing. A diagnostics tool has to be the most crash-proof code in the project, since it exists to run when something is already broken. Fixed at every call site: systemctl status | head, tail on lightdm's log, journalctl, ping, nslookup, and three pactl-backed variable assignments. Also noted for future menus in this migration: writing `local var;` and `var=$(cmd)` as separate statements (good practice, and how several earlier real bugs were caught) removes an accidental safety net - `local x=$(cmd)` on one line masks the substitution's exit code with `local`'s own always-success status. Splitting them is correct, but each split assignment needs an explicit `|| true` (or real fallback) where failure is expected and non-fatal, rather than relying on that masking by accident. Caught three instances of exactly this while writing this file fresh, not just porting old bugs. Verified: - Full regression: re-ran every existing scratch-config/stub test suite (sites, display, timezone/pin, lockout, power schedule + RTC, wifi) - all still clean after the lib/menu.sh change. - New test for diagnostics.sh, exercised mostly for real (no destructive-mutation risk here, so minimal stubbing needed): system status, all three log views (including the "no such file" paths for lightdm log and electron log), full 8-step audio diagnostic with test sound declined, and network test - all report gracefully instead of crashing, confirmed by re-running after each fix until every bare unguarded statement was accounted for. - End-to-end: ran the real install.sh as a genuine non-root, non- "kiosk" user, navigating Diagnostics -> System status -> View Logs -> System journal -> Audio diagnostics (declined test sound) -> Network test -> exit. Confirmed every diagnostic path completes and returns to its menu cleanly (exit code 0) even with ping/nslookup missing and no audio hardware/network present in this environment. --- Readme.md | 19 +++- install.sh | 7 +- lib/menu.sh | 27 +++++ menus/diagnostics.sh | 244 ++++++++++++++++++++++++++++++++++++++++++ ubuntu-based-kiosk.sh | 35 +++++- 5 files changed, 324 insertions(+), 8 deletions(-) create mode 100644 menus/diagnostics.sh diff --git a/Readme.md b/Readme.md index 8e137e6..160b7fd 100644 --- a/Readme.md +++ b/Readme.md @@ -1,6 +1,6 @@ # Ubuntu Based Kiosk -**Current Version:** 2.3.0 (check script header for latest version) +**Current Version:** 2.4.0 (check script header for latest version) **Built with Claude Sonnet 4.6 AI assistance** **License:** GPL v3 - Keep derivatives open source **Repository:** https://github.com/outis1one/ubuntu-based-kiosk/ @@ -1202,6 +1202,9 @@ terminal menu and the web UI, so they can't drift apart). shutdown (+ RTC wake where available), display on/off, quiet-hours audio muting, and an Electron reload timer, each as systemd timers. Can power the physical machine off and on a schedule. +- `menus/diagnostics.sh` — **Diagnostics**: system status, log viewing, + audio diagnostics, network test — 4 of the legacy Advanced menu's 12 + items, all read-only. - `install.sh` — entry point for the modular tool. Run it against an *already-installed* kiosk: ```bash @@ -1213,8 +1216,9 @@ terminal menu and the web UI, so they can't drift apart). **Honest status:** this does not yet replace first-time installation, or most of the old installer. `ubuntu-based-kiosk.sh` is still ~12,000 lines and still contains its own unremoved, unmodified copies of every -menu above (plus Upgrade, Reinstall, Uninstall, all Addons, and all of -Advanced — none of that has moved yet). Both copies coexist deliberately: the old ones stay until enough +menu above (plus Upgrade, Reinstall, Uninstall, all Addons, and the +other 8 Advanced items — none of that has moved yet). 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 @@ -1225,9 +1229,14 @@ at all. ## Project Status & Future Plans -**Current Version:** 2.3.0 +**Current Version:** 2.4.0 -**Recent Updates (v2.3.0):** +**Recent Updates (v2.4.0):** +- **Diagnostics migrated** — system status, log viewing (Electron/LightDM/journal), an 8-step audio diagnostic, and a ping+DNS network test, from the legacy Advanced menu. A change of pace: everything here is read-only, no destructive-action risk to manage. +- **Bug fix (set -e safety):** every diagnostic whose failure is the expected case — no lightdm running, no audio hardware, no network, missing logs, `ping`/`nslookup` not even installed — was a bare unguarded statement that would have crashed the whole session instead of reporting "not found" and moving on. Fixed throughout; a diagnostics tool has to survive exactly the broken states it exists to diagnose. +- Manual Electron Update, Factory Reset, Export/Import Settings, Emergency Hotspot, and Fix Blank Screen are staying in the legacy script for now — destructive/mutating, and some share Upgrade's coupling to the legacy script's self-extraction mechanism (see v2.3.0 notes). + +**Previous (v2.3.0):** - **WiFi and Power/Display/Quiet Hours migrated** — by far the riskiest menus tackled so far. WiFi rewrites live netplan config and, over SSH, can disconnect the session configuring it; power scheduling can shut the physical machine down and wake it via RTC. Every legacy safety mechanism is preserved exactly: netplan backup, 60-second SSH watchdog, restore-on-failure for WiFi; RTC availability detection for power scheduling. - **Bug fix:** the legacy menu refused to open "Configure power schedule" at all without RTC hardware, even though shutdown-only scheduling never needed it. - **Bug fix:** none of the six HH:MM time prompts across these menus were format-validated before — a typo silently produced a broken schedule. All now go through the same `ask_time` validator as everywhere else. diff --git a/install.sh b/install.sh index fbf9eb4..8bc4ee1 100755 --- a/install.sh +++ b/install.sh @@ -16,7 +16,8 @@ # Interaction (menus/display.sh), Timezone (menus/timezone.sh), Hidden # Site PIN (menus/hidden_pin.sh), Password Protection & Lockout # (menus/lockout.sh), WiFi (menus/wifi.sh), Power/Display/Quiet Hours -# (menus/power_schedule.sh). +# (menus/power_schedule.sh), Diagnostics (menus/diagnostics.sh - system +# status/logs/audio/network from the legacy Advanced menu). # # Usage (once the kiosk has already been installed): # git clone @@ -46,6 +47,8 @@ source "$SCRIPT_DIR/menus/lockout.sh" source "$SCRIPT_DIR/menus/wifi.sh" # shellcheck source=menus/power_schedule.sh source "$SCRIPT_DIR/menus/power_schedule.sh" +# shellcheck source=menus/diagnostics.sh +source "$SCRIPT_DIR/menus/diagnostics.sh" ################################################################################ # Preflight @@ -90,6 +93,7 @@ main_menu_builder() { "Password Protection & Lockout" "WiFi" "Power/Display/Quiet Hours" + "Diagnostics" ) MENU_HANDLERS=( sites_menu @@ -99,6 +103,7 @@ main_menu_builder() { lockout_menu wifi_menu power_schedule_menu + diagnostics_menu ) } diff --git a/lib/menu.sh b/lib/menu.sh index 95c6177..34ad654 100644 --- a/lib/menu.sh +++ b/lib/menu.sh @@ -57,6 +57,33 @@ get_ip_address() { fi } +# "WireGuard: 10.x.x.x | Tailscale: 100.x.x.x" for whichever VPN clients +# are installed and connected, or "None" if none are. +get_vpn_ips() { + local vpn_info="" + + if command -v wg &>/dev/null && sudo wg show 2>/dev/null | grep -q interface; then + local wg_ip + wg_ip=$(sudo wg show all | grep "allowed ips" | head -1 | awk '{print $3}' | cut -d'/' -f1) + [[ -n "$wg_ip" ]] && vpn_info="${vpn_info}WireGuard: $wg_ip | " + fi + + if command -v tailscale &>/dev/null; then + local ts_ip + ts_ip=$(tailscale ip -4 2>/dev/null) + [[ -n "$ts_ip" ]] && vpn_info="${vpn_info}Tailscale: $ts_ip | " + fi + + if command -v netbird &>/dev/null; then + local nb_ip + nb_ip=$(netbird status 2>/dev/null | grep "NetBird IP:" | awk '{print $3}') + [[ -n "$nb_ip" ]] && vpn_info="${vpn_info}Netbird: $nb_ip | " + fi + + vpn_info="${vpn_info% | }" + [[ -n "$vpn_info" ]] && echo "$vpn_info" || echo "None" +} + pause() { read -r -p "Press Enter to continue..." } diff --git a/menus/diagnostics.sh b/menus/diagnostics.sh new file mode 100644 index 0000000..34db7e4 --- /dev/null +++ b/menus/diagnostics.sh @@ -0,0 +1,244 @@ +#!/bin/bash +################################################################################ +# menus/diagnostics.sh - "Diagnostics" menu (from the legacy Advanced menu). +# +# A deliberate change of pace after Sites/WiFi/Power: everything here is +# read-only (system/audio status, log tailing, ping+DNS) except one +# optional "play a test sound?" prompt, so there's no destructive-action +# risk profile to design around. Straight port, using $KIOSK_USER/ +# $KIOSK_HOME instead of the legacy code's mix of the variable and a +# hardcoded "kiosk" literal. +# +# Only 4 of the legacy Advanced menu's 12 items are here (System +# Diagnostics, View Logs, Audio Diagnostics, Network Test) - Manual +# Electron Update, Factory Reset, Export/Import Settings, Emergency +# Hotspot, and Fix Blank Screen are mutating/destructive and belong with +# a later, more careful pass (some, like Manual Electron Update, share +# Upgrade's issue of being coupled to the legacy script's own +# self-extraction mechanism - see ubuntu-based-kiosk.sh's changelog for +# why Upgrade/Reinstall/Uninstall aren't migrated yet either). +# +# Depends on: lib/menu.sh, lib/config.sh being sourced first. +################################################################################ + +diagnostics_menu_builder() { + MENU_LABELS=("System status" "View logs" "Audio diagnostics" "Network test") + MENU_HANDLERS=(action_system_diagnostics view_logs_menu action_audio_diagnostics action_network_test) +} + +diagnostics_menu() { + run_menu "DIAGNOSTICS" diagnostics_menu_builder +} + +################################################################################ +# System status +################################################################################ + +action_system_diagnostics() { + clear + echo " ═══ SYSTEM DIAGNOSTICS ═══" + echo + + echo "=== Kiosk Status ===" + systemctl status lightdm --no-pager -l 2>&1 | head -20 || true + + echo + echo "=== Audio Status ===" + sudo -u "$KIOSK_USER" pactl info 2>/dev/null | grep -E "Server|User" || echo "Not running" + + echo + echo "=== Network ===" + echo "IP: $(get_ip_address)" + echo "VPN: $(get_vpn_ips)" + echo + + pause +} + +################################################################################ +# Logs +################################################################################ + +view_logs_menu_builder() { + MENU_LABELS=("Electron log (last 50 lines)" "LightDM log (last 50 lines)" "System journal (last 100 lines)") + MENU_HANDLERS=(action_view_electron_log action_view_lightdm_log action_view_journal) +} + +view_logs_menu() { + run_menu "VIEW LOGS" view_logs_menu_builder +} + +action_view_electron_log() { + echo + if sudo test -f "$KIOSK_HOME/electron.log"; then + sudo tail -50 "$KIOSK_HOME/electron.log" || true + else + echo "No electron log found yet" + fi + pause +} + +action_view_lightdm_log() { + echo + sudo tail -50 /var/log/lightdm/lightdm.log 2>&1 || echo "No lightdm log found" + pause +} + +action_view_journal() { + echo + sudo journalctl -n 100 || log_error "Could not read the system journal" + pause +} + +################################################################################ +# Audio diagnostics +################################################################################ + +audio_diagnostics_pactl() { + sudo -u "$KIOSK_USER" XDG_RUNTIME_DIR="/run/user/$(id -u "$KIOSK_USER")" pactl "$@" +} + +action_audio_diagnostics() { + clear + echo "═══ AUDIO DIAGNOSTICS ═══" + echo + + local issue_found=false + + echo "[1/8] Checking audio hardware..." + if lspci 2>/dev/null | grep -i audio || lsusb 2>/dev/null | grep -i audio; then + log_success "Audio hardware detected" + lspci 2>/dev/null | grep -i audio || true + lsusb 2>/dev/null | grep -i audio | head -3 || true + else + log_error "No audio hardware detected" + issue_found=true + fi + echo + + echo "[2/8] Checking ALSA devices..." + if aplay -l &>/dev/null; then + log_success "ALSA devices found" + aplay -l 2>/dev/null | grep -E "^card|device" || true + else + log_error "No ALSA devices" + issue_found=true + fi + echo + + echo "[3/8] Checking PipeWire status..." + local pipewire_running=false + if audio_diagnostics_pactl info &>/dev/null; then + log_success "PipeWire accessible" + pipewire_running=true + audio_diagnostics_pactl info 2>/dev/null | grep -E "Server|User|Host" || true + else + log_error "PipeWire not accessible to kiosk user" + issue_found=true + echo " Try: sudo -u ${KIOSK_USER} systemctl --user start pipewire pipewire-pulse" + fi + echo + + if $pipewire_running; then + echo "[4/8] Checking audio sinks..." + local sinks + sinks=$(audio_diagnostics_pactl list sinks short 2>/dev/null) || true + if [[ -n "$sinks" ]]; then + echo "$sinks" + local default_sink + default_sink=$(audio_diagnostics_pactl get-default-sink 2>/dev/null || echo "none") + echo "Default: $default_sink" + else + log_error "No audio sinks found" + issue_found=true + fi + echo + + echo "[5/8] Checking active streams..." + local sink_inputs + sink_inputs=$(audio_diagnostics_pactl list sink-inputs short 2>/dev/null) || true + if [[ -n "$sink_inputs" ]]; then + echo "Active streams:" + echo "$sink_inputs" + else + echo "No active streams" + fi + echo + else + echo "[4/8] Skipped - PipeWire not running" + echo "[5/8] Skipped - PipeWire not running" + echo + fi + + echo "[6/8] Checking Squeezelite..." + if systemctl is-active --quiet squeezelite; then + log_success "Squeezelite running" + + if $pipewire_running; then + local sq_pid + sq_pid=$(pgrep -f squeezelite | head -1) || true + if [[ -n "$sq_pid" ]]; then + if audio_diagnostics_pactl list sink-inputs 2>/dev/null | grep -q "application.process.id = \"$sq_pid\""; then + log_success "Squeezelite connected to audio" + else + log_warning "Squeezelite NOT connected to audio sink" + issue_found=true + fi + fi + fi + else + echo "Squeezelite not running" + fi + echo + + if $pipewire_running; then + echo "[7/8] Checking volume..." + local volume muted + volume=$(audio_diagnostics_pactl get-sink-volume @DEFAULT_SINK@ 2>/dev/null | grep -oE '[0-9]+%' | head -1 || echo "unknown") + muted=$(audio_diagnostics_pactl get-sink-mute @DEFAULT_SINK@ 2>/dev/null || echo "unknown") + echo "Volume: $volume" + echo "Muted: $muted" + else + echo "[7/8] Skipped - PipeWire not running" + fi + echo + + echo "[8/8] Audio test..." + if ask_yes_no "Play test sound?" "n" && $pipewire_running; then + echo "Playing beep..." + audio_diagnostics_pactl_play_test + fi + echo + + echo "═══════════════════════════════" + if $issue_found; then + echo "⚠️ ISSUES DETECTED - See above" + else + echo "✓ All checks passed" + fi + echo "═══════════════════════════════" + + pause +} + +audio_diagnostics_pactl_play_test() { + sudo -u "$KIOSK_USER" XDG_RUNTIME_DIR="/run/user/$(id -u "$KIOSK_USER")" paplay /usr/share/sounds/alsa/Front_Center.wav 2>/dev/null || \ + sudo -u "$KIOSK_USER" XDG_RUNTIME_DIR="/run/user/$(id -u "$KIOSK_USER")" speaker-test -t sine -f 1000 -l 1 2>/dev/null || \ + echo "No test available" +} + +################################################################################ +# Network test +################################################################################ + +action_network_test() { + echo + echo " ═══ NETWORK TEST ═══" + echo + echo "Ping test..." + ping -c 4 8.8.8.8 || log_error "Ping failed" + echo + echo "DNS test..." + nslookup google.com || log_error "DNS lookup failed" + pause +} diff --git a/ubuntu-based-kiosk.sh b/ubuntu-based-kiosk.sh index 563a768..433d4a5 100644 --- a/ubuntu-based-kiosk.sh +++ b/ubuntu-based-kiosk.sh @@ -1,8 +1,39 @@ #!/bin/bash ################################################################################ -### Ubuntu Based Kiosk v2.3.0 ### +### Ubuntu Based Kiosk v2.4.0 ### ################################################################################ # +# RELEASE v2.4.0 - Diagnostics Migrated +# - New in ./install.sh: Diagnostics (menus/diagnostics.sh) - system +# status, log viewing (Electron/LightDM/journal), an 8-step audio +# diagnostic, and a ping+DNS network test, pulled from the legacy +# Advanced menu. Everything here is read-only except one optional +# "play a test sound?" prompt - a deliberate change of pace after +# Sites/WiFi/Power, with no destructive-action risk to design around. +# Manual Electron Update, Factory Reset, Export/Import Settings, +# Emergency Hotspot, and Fix Blank Screen are staying in the legacy +# script for now - they're mutating/destructive, and some share +# Upgrade's coupling to the legacy script's own self-extraction +# mechanism (see v2.3.0 below for why Upgrade/Reinstall/Uninstall +# aren't migrated either). +# - Fixed (set -e safety, same class as v2.1.0/v2.3.0): every diagnostic +# command whose failure is actually the expected, common case - no +# lightdm running, no audio hardware, no network, missing log files, +# `ping`/`nslookup` not even installed - was a bare unguarded +# statement that would have crashed the whole session instead of +# reporting "not found" and moving on. A diagnostics tool has to be +# the most crash-proof code in the project, since it exists to run +# *when something is already broken*; every one of these now reports +# and continues instead. Also worth noting for future menus: writing +# `local var;` and `var=$(cmd)` as separate statements (good practice, +# and how earlier real bugs in this migration were caught) removes an +# accidental safety net bash's `local x=$(cmd)` has on one line - that +# form masks the substitution's exit code with `local`'s own +# always-success status. Splitting them is correct, but each split +# assignment needs its own explicit `|| true` (or real fallback) where +# a failure is expected and non-fatal, rather than relying on that +# quirk by accident. +# # RELEASE v2.3.0 - WiFi and Power/Display/Quiet Hours Migrated # - New in ./install.sh: WiFi (menus/wifi.sh) and Power/Display/Quiet # Hours (menus/power_schedule.sh) - by far the biggest and riskiest @@ -157,7 +188,7 @@ set -euo pipefail ### SECTION 1: CONSTANTS & GLOBALS ################################################################################ -SCRIPT_VERSION="2.3.0" +SCRIPT_VERSION="2.4.0" # Resolve the real path to this script file. # When piped (curl|bash or wget|bash), BASH_SOURCE[0] is a pipe descriptor, From 1b16bcf3ee0ae871b3d4048e099feddec15c77b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 19:10:43 +0000 Subject: [PATCH 07/19] Migrate CUPS Printing addon; restructure install.sh into Core Settings/Addons/Advanced; bump to v2.5.0 First Addon migrated: menus/addon_cups.sh (install, reconfigure for network access, complete uninstall/purge). Different risk profile from everything migrated so far - it genuinely mutates real system state (apt install/remove --purge, /etc/cups, ufw) at fixed paths CUPS itself doesn't let us relocate, unlike the systemd/cron/bin paths this project already controls via $SYSTEMD_DIR etc. Only the polkit rule's directory is parameterized ($POLKIT_DIR, lib/config.sh, since that one is ours to place); everything else gets full command-level `sudo` stubbing in every test - there is no scratch equivalent for a real apt-managed subsystem's own file layout. Also added $BUILD_USER (the admin account actually running the tool, as opposed to $KIOSK_USER) since CUPS needs to grant it lpadmin group membership. Restructured install.sh's top-level menu into Core Settings / Addons / Advanced (matching the legacy tool) instead of one flat list, now that Addons exists as its own category - cheap to do with one item in it, much more annoying to retrofit once the flat list has fifteen. Two bugs caught and fixed before they shipped: - A "wait for CUPS to start" retry loop used a bare `cmd1 && cmd2 && break` as its body while "simplifying" the legacy script's `if cmd1 && cmd2; then break; fi`. Being inside a loop doesn't protect a bare &&/|| list from set -e - only if/while/until conditions and the protected side of &&/|| do that - so the first command failing on an early iteration (near-certain right after a fresh install, before CUPS has actually started) would have crashed the entire session. Restored the `if` form; noted the lesson in the file's own header comment since it's a general trap, not CUPS-specific. - Resolved real uncertainty, rather than assuming: how far does run_menu's `handler || true` guard (v2.1.0) actually protect? Wrote a minimal isolated test (a bare `false` three function calls deep, called via `outer || true` at the top) and confirmed bash's errexit exemption for the left side of `||` covers the *entire* evaluation, arbitrarily deep through function calls - not just the immediately invoked function. So the session-crash risk this project has been chasing since v2.1.0 is already covered end-to-end by that one fix. Per-statement guards (`|| true`, explicit `if`) still earn their keep for a different reason: without them a deep failure bubbles silently past the menu actually responsible for it to wherever the nearest `|| true` happens to sit, which can be several menu levels above where the user actually was - not a crash, but a confusing jump. Verified: - Full regression: re-ran every existing scratch-config/stub test suite after the lib/config.sh change (new $BUILD_USER/$POLKIT_DIR) and after the install.sh restructuring - all still clean. - New scratch/stub test for addon_cups.sh: full state-machine coverage (not installed -> decline -> install -> running -> reconfigure -> stopped -> start -> uninstall decline -> uninstall confirm -> not installed again) with every `sudo` call intercepted and only `rm` targeting the scratch $POLKIT_DIR ever actually executed; confirmed the polkit rule's content and that declining install makes zero sudo calls. Added both apt-failure paths (update fails, install fails) and confirmed the tool reports clearly and returns to the menu instead of dying, exercising the exact bug class just fixed. - End-to-end: ran the real install.sh as a genuine non-root, non- "kiosk" user through the full new three-level structure - Core Settings -> Sites -> back -> back, Addons -> CUPS -> declined install (using this container's real, unstubbed dpkg check, correctly reporting "not installed" and making no apt/systemctl calls) -> back -> back, Advanced -> Diagnostics -> System status -> back -> back -> Exit. Zero invalid-choice errors, clean exit code 0 throughout. --- Readme.md | 21 ++++-- install.sh | 50 +++++++++++--- lib/config.sh | 6 ++ menus/addon_cups.sh | 156 ++++++++++++++++++++++++++++++++++++++++++ ubuntu-based-kiosk.sh | 42 +++++++++++- 5 files changed, 258 insertions(+), 17 deletions(-) create mode 100644 menus/addon_cups.sh diff --git a/Readme.md b/Readme.md index 160b7fd..9a7a95f 100644 --- a/Readme.md +++ b/Readme.md @@ -1,6 +1,6 @@ # Ubuntu Based Kiosk -**Current Version:** 2.4.0 (check script header for latest version) +**Current Version:** 2.5.0 (check script header for latest version) **Built with Claude Sonnet 4.6 AI assistance** **License:** GPL v3 - Keep derivatives open source **Repository:** https://github.com/outis1one/ubuntu-based-kiosk/ @@ -1205,7 +1205,12 @@ terminal menu and the web UI, so they can't drift apart). - `menus/diagnostics.sh` — **Diagnostics**: system status, log viewing, audio diagnostics, network test — 4 of the legacy Advanced menu's 12 items, all read-only. -- `install.sh` — entry point for the modular tool. Run it against an +- `menus/addon_cups.sh` — **CUPS Printing** (Addons): install, + reconfigure for network access, complete uninstall (purge). The first + Addon migrated — genuinely mutates real system state (apt packages, + `/etc/cups`, ufw) rather than this project's own files. +- `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: ```bash git clone https://github.com/outis1one/ubuntu-based-kiosk/ @@ -1216,7 +1221,7 @@ terminal menu and the web UI, so they can't drift apart). **Honest status:** this does not yet replace first-time installation, or most of the old installer. `ubuntu-based-kiosk.sh` is still ~12,000 lines and still contains its own unremoved, unmodified copies of every -menu above (plus Upgrade, Reinstall, Uninstall, all Addons, and the +menu above (plus Upgrade, Reinstall, Uninstall, 4 more Addons, and the other 8 Advanced items — none of that has moved yet). Both copies coexist deliberately: the old ones stay until enough of Core Settings/Addons/Advanced is migrated to @@ -1229,9 +1234,15 @@ at all. ## Project Status & Future Plans -**Current Version:** 2.4.0 +**Current Version:** 2.5.0 -**Recent Updates (v2.4.0):** +**Recent Updates (v2.5.0):** +- **First Addon migrated:** CUPS Printing — install/reconfigure/complete uninstall, in `./install.sh`. Genuinely mutates real system state (`apt install`/`remove --purge`, `/etc/cups`, `ufw`) at fixed paths CUPS itself doesn't let us relocate, so every test uses full command-level `sudo` stubbing rather than the scratch-directory approach used for this project's own files. +- **Menu restructured:** `install.sh`'s top level is now grouped Core Settings / Addons / Advanced, matching the legacy tool, instead of one flat list — done now while it's cheap, ahead of the list getting unwieldy. +- **Bug fix:** a "wait for service to start" retry loop used a bare `cmd1 && cmd2 && break` as its body — that's not made safe by being inside a loop; a bare `&&`/`||` list used as a standalone statement is fully subject to `set -e`, and the first command failing on an early iteration (near-certain right after a fresh install) would have killed the whole session. Restored the `if cmd1 && cmd2; then break; fi` form. +- **Resolved:** real uncertainty about how far `run_menu`'s `handler || true` guard (added in v2.1.0) actually reaches — confirmed with an isolated test that it protects against a failing command no matter how many function calls deep, so the session-crash risk chased since v2.1.0 is already covered end-to-end by that one fix. Per-statement guards still matter for a different reason: without them, a deep failure bubbles past the menu actually responsible for it to wherever the nearest `|| true` happens to catch it. + +**Previous (v2.4.0):** - **Diagnostics migrated** — system status, log viewing (Electron/LightDM/journal), an 8-step audio diagnostic, and a ping+DNS network test, from the legacy Advanced menu. A change of pace: everything here is read-only, no destructive-action risk to manage. - **Bug fix (set -e safety):** every diagnostic whose failure is the expected case — no lightdm running, no audio hardware, no network, missing logs, `ping`/`nslookup` not even installed — was a bare unguarded statement that would have crashed the whole session instead of reporting "not found" and moving on. Fixed throughout; a diagnostics tool has to survive exactly the broken states it exists to diagnose. - Manual Electron Update, Factory Reset, Export/Import Settings, Emergency Hotspot, and Fix Blank Screen are staying in the legacy script for now — destructive/mutating, and some share Upgrade's coupling to the legacy script's self-extraction mechanism (see v2.3.0 notes). diff --git a/install.sh b/install.sh index 8bc4ee1..2e50f7b 100755 --- a/install.sh +++ b/install.sh @@ -12,12 +12,13 @@ # at a time, so a change to (say) the Sites menu can't accidentally break # WiFi setup or the uninstaller three thousand lines away. # -# Migrated so far: Sites & Page Timing (menus/sites.sh), Display & -# Interaction (menus/display.sh), Timezone (menus/timezone.sh), Hidden -# Site PIN (menus/hidden_pin.sh), Password Protection & Lockout -# (menus/lockout.sh), WiFi (menus/wifi.sh), Power/Display/Quiet Hours -# (menus/power_schedule.sh), Diagnostics (menus/diagnostics.sh - system -# status/logs/audio/network from the legacy Advanced menu). +# 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. +# Addons: CUPS Printing (menus/addon_cups.sh). +# Advanced: Diagnostics (menus/diagnostics.sh - system status/logs/ +# audio/network). # # Usage (once the kiosk has already been installed): # git clone @@ -49,6 +50,8 @@ source "$SCRIPT_DIR/menus/wifi.sh" source "$SCRIPT_DIR/menus/power_schedule.sh" # shellcheck source=menus/diagnostics.sh source "$SCRIPT_DIR/menus/diagnostics.sh" +# shellcheck source=menus/addon_cups.sh +source "$SCRIPT_DIR/menus/addon_cups.sh" ################################################################################ # Preflight @@ -81,10 +84,12 @@ if ! is_kiosk_installed; then fi ################################################################################ -# Top-level menu +# Top-level menu - grouped the same way the legacy menu groups them +# (Core Settings / Addons / Advanced), so the structure stays familiar +# and the flat list doesn't grow unwieldy as more menus migrate in. ################################################################################ -main_menu_builder() { +core_settings_menu_builder() { MENU_LABELS=( "Sites & Page Timing" "Display & Interaction" @@ -93,7 +98,6 @@ main_menu_builder() { "Password Protection & Lockout" "WiFi" "Power/Display/Quiet Hours" - "Diagnostics" ) MENU_HANDLERS=( sites_menu @@ -103,10 +107,36 @@ main_menu_builder() { lockout_menu wifi_menu power_schedule_menu - diagnostics_menu ) } +core_settings_menu() { + run_menu "CORE SETTINGS" core_settings_menu_builder +} + +addons_menu_builder() { + MENU_LABELS=("CUPS Printing") + MENU_HANDLERS=(addon_cups_menu) +} + +addons_menu() { + run_menu "ADDONS" addons_menu_builder +} + +advanced_menu_builder() { + MENU_LABELS=("Diagnostics") + MENU_HANDLERS=(diagnostics_menu) +} + +advanced_menu() { + run_menu "ADVANCED" advanced_menu_builder +} + +main_menu_builder() { + MENU_LABELS=("Core Settings" "Addons" "Advanced") + MENU_HANDLERS=(core_settings_menu addons_menu advanced_menu) +} + main_menu_status() { echo "Managing kiosk at: ${KIOSK_DIR}" } diff --git a/lib/config.sh b/lib/config.sh index 3fd763c..c4ce7b5 100644 --- a/lib/config.sh +++ b/lib/config.sh @@ -29,6 +29,12 @@ : "${CRON_D_DIR:=/etc/cron.d}" : "${BIN_DIR:=/usr/local/bin}" : "${NETPLAN_DIR:=/etc/netplan}" +: "${POLKIT_DIR:=/etc/polkit-1/localauthority/50-local.d}" + +# The admin account actually running this tool (as opposed to $KIOSK_USER, +# the kiosk's own restricted account) - used where an addon needs to grant +# *this* user a group membership (e.g. lpadmin for CUPS). +: "${BUILD_USER:=${SUDO_USER:-$(whoami)}}" # Site/tab arrays declare -a URLS=() diff --git a/menus/addon_cups.sh b/menus/addon_cups.sh new file mode 100644 index 0000000..c3a7db1 --- /dev/null +++ b/menus/addon_cups.sh @@ -0,0 +1,156 @@ +#!/bin/bash +################################################################################ +# menus/addon_cups.sh - "CUPS Printing" addon (from the legacy Addons menu). +# +# First Addon migrated. Genuinely mutates real system state - installs/ +# purges apt packages, writes /etc/cups/cupsd.conf and a polkit rule, +# touches ufw - at fixed paths CUPS itself doesn't let us relocate the +# way $SYSTEMD_DIR/$CRON_D_DIR/etc let us relocate our own files. Only +# the polkit rule's directory is parameterized ($POLKIT_DIR, since that's +# ours to place); everything else (cupsd.conf, apt, systemctl, ufw) gets +# full command-level `sudo` stubbing in every test - there is no scratch +# equivalent for a real apt-managed subsystem's own file layout. +# +# Depends on: lib/menu.sh, lib/config.sh being sourced first. +################################################################################ + +cups_is_installed() { + dpkg -l 2>/dev/null | grep -q "^ii\s\+cups\s" +} + +cups_is_active() { + systemctl is-active --quiet cups +} + +addon_cups_status() { + if cups_is_installed && cups_is_active; then + echo "CUPS: installed and running (http://$(get_ip_address):631)" + elif cups_is_installed; then + echo "CUPS: installed but not running" + else + echo "CUPS: not installed" + fi +} + +addon_cups_menu_builder() { + if cups_is_installed && cups_is_active; then + MENU_LABELS=("Reconfigure for network access" "Complete uninstall (purge)") + MENU_HANDLERS=(action_reconfigure_cups action_cups_uninstall) + elif cups_is_installed; then + MENU_LABELS=("Start CUPS" "Complete uninstall (purge)") + MENU_HANDLERS=(action_start_cups action_cups_uninstall) + else + MENU_LABELS=("Install CUPS printing") + MENU_HANDLERS=(action_install_cups) + fi +} + +addon_cups_menu() { + run_menu "CUPS PRINTING SUPPORT" addon_cups_menu_builder addon_cups_status +} + +################################################################################ +# Actions +################################################################################ + +action_install_cups() { + echo + ask_yes_no "Install CUPS printing?" "n" || { echo "Cancelled"; return; } + + echo "Installing CUPS from scratch..." + if ! sudo apt update; then + log_error "apt update failed - check network/package sources and try again" + return 1 + fi + if ! sudo apt install -y cups cups-client cups-filters printer-driver-all \ + printer-driver-cups-pdf hplip printer-driver-gutenprint \ + foomatic-db-compressed-ppds openprinting-ppds; then + log_error "CUPS package installation failed" + return 1 + fi + + sudo systemctl enable cups 2>/dev/null || true + sudo systemctl start cups 2>/dev/null || true + + echo "Waiting for CUPS to start..." + for _ in {1..30}; do + # Must stay in an `if` - a bare `cmd1 && cmd2` statement is + # subject to set -e itself when cmd1 fails, which is virtually + # guaranteed on early iterations right after install. + if cups_is_active && lpstat -r &>/dev/null 2>&1; then + break + fi + sleep 1 + done + + # $BUILD_USER already resolves to $SUDO_USER when the tool was run via + # sudo, so a single usermod covers it - the legacy code ran this twice + # (once for a hardcoded computed user, once again for $SUDO_USER + # directly), which was harmless but genuinely redundant. + sudo usermod -aG lpadmin "$BUILD_USER" + + action_reconfigure_cups + + log_success "CUPS installed" + echo " Web interface: http://$(get_ip_address):631" +} + +action_start_cups() { + sudo systemctl enable cups + sudo systemctl start cups + log_success "CUPS started" +} + +action_reconfigure_cups() { + if [[ -f /etc/cups/cupsd.conf ]]; then + sudo cp /etc/cups/cupsd.conf "/etc/cups/cupsd.conf.backup-$(date +%Y%m%d-%H%M%S)" + fi + + if command -v cupsctl &>/dev/null; then + sudo cupsctl --remote-admin --remote-any --share-printers 2>/dev/null || true + fi + + sudo sed -i 's/^Listen localhost:631/Port 631/' /etc/cups/cupsd.conf 2>/dev/null || true + sudo sed -i 's/^Listen 127.0.0.1:631/Port 631/' /etc/cups/cupsd.conf 2>/dev/null || true + + sudo mkdir -p "$POLKIT_DIR" + sudo tee "$POLKIT_DIR/kiosk-printing.pkla" > /dev/null </dev/null || true + sudo systemctl restart cups 2>/dev/null || true + + log_success "CUPS configured for network access" +} + +action_cups_uninstall() { + echo + ask_yes_no "Completely remove CUPS, including all queues and settings (purge)?" "n" || { echo "Cancelled"; return; } + + echo "Performing complete CUPS uninstall..." + + sudo systemctl stop cups cups-browsed 2>/dev/null || true + sudo systemctl disable cups cups-browsed 2>/dev/null || true + + sudo apt remove --purge -y cups cups-daemon cups-client cups-filters \ + cups-common cups-core-drivers cups-server-common cups-browsed \ + cups-ppdc cups-bsd libcups2 libcupsimage2 2>/dev/null || true + + sudo apt remove --purge -y printer-driver-all printer-driver-cups-pdf \ + hplip printer-driver-gutenprint foomatic-db-compressed-ppds \ + openprinting-ppds 2>/dev/null || true + + sudo rm -rf /etc/cups /var/cache/cups /var/spool/cups /var/log/cups /usr/share/cups + sudo rm -f "$POLKIT_DIR/kiosk-printing.pkla" + + sudo apt autoremove -y + sudo apt clean + + log_success "CUPS completely removed" +} diff --git a/ubuntu-based-kiosk.sh b/ubuntu-based-kiosk.sh index 433d4a5..c6d0581 100644 --- a/ubuntu-based-kiosk.sh +++ b/ubuntu-based-kiosk.sh @@ -1,8 +1,46 @@ #!/bin/bash ################################################################################ -### Ubuntu Based Kiosk v2.4.0 ### +### Ubuntu Based Kiosk v2.5.0 ### ################################################################################ # +# RELEASE v2.5.0 - First Addon Migrated (CUPS), Menu Restructured +# - New in ./install.sh: CUPS Printing (menus/addon_cups.sh) - the first +# Addon migrated. Install/reconfigure/complete uninstall (purge), +# genuinely mutating real system state (apt install/remove --purge, +# /etc/cups, ufw) at fixed paths CUPS itself doesn't let us relocate - +# unlike the systemd/cron/bin paths this project controls, there is no +# scratch equivalent for a real apt-managed subsystem's own file +# layout, so every test uses full command-level `sudo` stubbing +# instead. Only the polkit rule's directory is parameterized +# ($POLKIT_DIR, since that one is ours to place). +# - install.sh's top-level menu is now grouped the same way the legacy +# menu groups things - Core Settings / Addons / Advanced - instead of +# one flat list, ahead of that list getting unwieldy as more Addons +# and Advanced items migrate in. +# - Two bugs caught and fixed before they ever shipped, both instructive +# beyond this one file: +# - A "wait for service to start" retry loop used a bare `cmd1 && +# cmd2 && break` as its body. That's not safe merely because it's +# inside a loop - a bare &&/|| list used as a standalone statement +# (not the condition of if/while/until) is fully subject to set -e, +# and cmd1 failing on an early iteration (near-certain right after +# a fresh install) would have killed the whole session. Restored +# the `if cmd1 && cmd2; then break; fi` form the legacy script +# already used correctly, rather than "simplifying" it away. +# - Resolved real uncertainty about how far run_menu's `handler || +# true` guard (added in v2.1.0) actually reaches: verified with a +# minimal isolated test that it protects against a bare failing +# command no matter how many function calls deep it occurs - bash's +# errexit exemption for the left side of `||` covers the entire +# evaluation, not just the immediately-called function. So the +# session-crash risk this project has been chasing since v2.1.0 is +# already covered end-to-end by that one fix. Per-statement guards +# (`|| true`, explicit `if`) still matter for a different reason: +# without them a deep failure silently bubbles up past the menu +# that's actually responsible for it to wherever the nearest `|| +# true` happens to catch it, which may be several menu levels +# higher than where the user actually was. +# # RELEASE v2.4.0 - Diagnostics Migrated # - New in ./install.sh: Diagnostics (menus/diagnostics.sh) - system # status, log viewing (Electron/LightDM/journal), an 8-step audio @@ -188,7 +226,7 @@ set -euo pipefail ### SECTION 1: CONSTANTS & GLOBALS ################################################################################ -SCRIPT_VERSION="2.4.0" +SCRIPT_VERSION="2.5.0" # Resolve the real path to this script file. # When piped (curl|bash or wget|bash), BASH_SOURCE[0] is a pipe descriptor, From 6c688979350e3595b91060f910e47705f5b98679 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 21:56:21 +0000 Subject: [PATCH 08/19] Migrate Authelia addon; fix real config-clobbering bug in save_config; bump to v2.6.0 Second Addon migrated: menus/addon_authelia.sh (encrypted SSO credentials, AES-256-CBC with a key derived from /etc/machine-id via scrypt - same algorithm main.js decrypts with - plus the full Dockerized server-side setup instructions, now viewable again later without reconfiguring). Investigating how to wire its three config.json fields (autheliaURL/ autheliaUsername/autheliaEncryptedPassword) into lib/config.sh surfaced a real, currently-shipping bug that has nothing to do with Authelia specifically: save_config() did a full `jq -n` rebuild of config.json from a fixed list of known fields - identical to what the legacy script's own save_config still does. The legacy configure_authelia() writes its three fields via a careful `. + {...}` merge that preserves everything else already in the file, but neither save_config knew those fields existed - so the next time a user visited Sites, Touch Controls, Navigation, or Password Protection (all of which call save_config), their Authelia credentials were silently deleted. This bug already existed in the shipped single-file installer; it was ported faithfully into lib/config.sh's first version because no test happened to set an untracked field before calling save_config. Fixed in lib/config.sh: save_config now merges its known fields onto whatever's already in config.json (jq `. + {...}`) instead of rebuilding the file from nothing, with a `jq empty` validity check falling back to `{}` if the existing file is missing or corrupt. Any field this tool doesn't track - Authelia's three today, anything else a future addon adds tomorrow - now survives automatically. autheliaURL/ autheliaUsername/autheliaEncryptedPassword are also tracked fields in their own right now (load_existing_config/save_config), consistent with every other config.json field this tool manages, giving Authelia both a direct fix and the general safety net. The equivalent bug still exists, unfixed, in ubuntu-based-kiosk.sh's own save_config - noted in both that script's changelog and the Readme's "Modular Management" section as an open question: whether to backport just that one fix into the legacy script now, independent of the wider migration, given it's a real credential-loss bug affecting the currently-shipping installer today. Verified: - New dedicated test (test_save_merge.sh) proving the save_config fix itself: seeded config.json with a simulated untracked field via the same `. + {...}` merge Authelia's own code uses, called save_config from an unrelated context (Sites deleting a tab), and confirmed the untracked field survived while the tab deletion still correctly took effect (not undone by the merge) - plus corrupt-JSON and missing-file edge cases both handled without crashing. - New scratch-config test for addon_authelia.sh using REAL encryption (this sandbox has both Node and /etc/machine-id): configured with a real password, then decrypted the stored ciphertext using main.js's exact algorithm (independently reproduced in the test) and confirmed it recovers the original password exactly - true interoperability, not just "some ciphertext was produced." Also covered cancel paths, clearing the configuration, the encryption-unavailable failure path, and confirmed Authelia's config survives an unrelated Sites save. - Full regression: re-ran all 9 prior scratch/stub test suites after both the lib/config.sh changes - all still clean. - End-to-end: ran the real install.sh as a genuine non-root, non- "kiosk" user with a seeded minimal config.json, through Addons -> Authelia -> Configure with a real URL/username/password -> confirmed the resulting config.json on disk, and independently decrypted the stored password for real using main.js's algorithm to confirm it matches exactly. Clean exit code 0 throughout. --- Readme.md | 24 ++++- install.sh | 9 +- lib/config.sh | 35 +++++- menus/addon_authelia.sh | 232 ++++++++++++++++++++++++++++++++++++++++ ubuntu-based-kiosk.sh | 39 ++++++- 5 files changed, 328 insertions(+), 11 deletions(-) create mode 100644 menus/addon_authelia.sh diff --git a/Readme.md b/Readme.md index 9a7a95f..d662d1c 100644 --- a/Readme.md +++ b/Readme.md @@ -1,6 +1,6 @@ # Ubuntu Based Kiosk -**Current Version:** 2.5.0 (check script header for latest version) +**Current Version:** 2.6.0 (check script header for latest version) **Built with Claude Sonnet 4.6 AI assistance** **License:** GPL v3 - Keep derivatives open source **Repository:** https://github.com/outis1one/ubuntu-based-kiosk/ @@ -1209,6 +1209,9 @@ terminal menu and the web UI, so they can't drift apart). reconfigure for network access, complete uninstall (purge). The first Addon migrated — genuinely mutates real system state (apt packages, `/etc/cups`, ufw) rather than this project's own files. +- `menus/addon_authelia.sh` — **Authelia Auto-Login** (Addons): + encrypted SSO credentials plus the server-side setup instructions. + Prompted the `save_config` merge fix above. - `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: @@ -1221,7 +1224,7 @@ terminal menu and the web UI, so they can't drift apart). **Honest status:** this does not yet replace first-time installation, or most of the old installer. `ubuntu-based-kiosk.sh` is still ~12,000 lines and still contains its own unremoved, unmodified copies of every -menu above (plus Upgrade, Reinstall, Uninstall, 4 more Addons, and the +menu above (plus Upgrade, Reinstall, Uninstall, 3 more Addons, and the other 8 Advanced items — none of that has moved yet). Both copies coexist deliberately: the old ones stay until enough of Core Settings/Addons/Advanced is migrated to @@ -1230,13 +1233,26 @@ 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. +**Open question:** the config-clobbering bug fixed in `lib/config.sh` +(v2.6.0 — `save_config` silently deleting fields it doesn't know about, +like Authelia's credentials, on the next unrelated save) has the exact +same shape in `ubuntu-based-kiosk.sh`'s own `save_config`, unfixed. It's +a real bug in the currently-shipping single-file installer, independent +of whether the rest of that menu ever gets migrated. Worth deciding +separately whether to backport just that fix into the legacy script now +rather than waiting for a full migration pass. + --- ## Project Status & Future Plans -**Current Version:** 2.5.0 +**Current Version:** 2.6.0 -**Recent Updates (v2.5.0):** +**Recent Updates (v2.6.0):** +- **Authelia Auto-Login migrated** — encrypted SSO credentials (same AES-256-CBC/scrypt algorithm `main.js` decrypts with, verified by a real encrypt→decrypt round trip in testing) plus the full server-side Docker setup instructions, viewable again later without reconfiguring. +- **Important bug found and fixed, not specific to Authelia:** `save_config()` did a full rebuild of `config.json` from known fields — exactly like the legacy script's `save_config` still does. Authelia's own write is a careful merge that preserves everything else, but the *next* save from Sites, Touch Controls, Navigation, or Password Protection would silently delete the Authelia credentials, since none of those knew the three Authelia fields existed. **This is a real bug in the currently-shipping single-file installer**, not introduced by this migration. Fixed in `lib/config.sh` by changing `save_config` to merge its known fields onto whatever's already on disk instead of rebuilding from nothing, so any untracked field — Authelia's three today, anything else tomorrow — survives automatically. The equivalent bug still exists, unfixed, in `ubuntu-based-kiosk.sh`'s own `save_config` — see "Modular Management" below. + +**Previous (v2.5.0):** - **First Addon migrated:** CUPS Printing — install/reconfigure/complete uninstall, in `./install.sh`. Genuinely mutates real system state (`apt install`/`remove --purge`, `/etc/cups`, `ufw`) at fixed paths CUPS itself doesn't let us relocate, so every test uses full command-level `sudo` stubbing rather than the scratch-directory approach used for this project's own files. - **Menu restructured:** `install.sh`'s top level is now grouped Core Settings / Addons / Advanced, matching the legacy tool, instead of one flat list — done now while it's cheap, ahead of the list getting unwieldy. - **Bug fix:** a "wait for service to start" retry loop used a bare `cmd1 && cmd2 && break` as its body — that's not made safe by being inside a loop; a bare `&&`/`||` list used as a standalone statement is fully subject to `set -e`, and the first command failing on an early iteration (near-certain right after a fresh install) would have killed the whole session. Restored the `if cmd1 && cmd2; then break; fi` form. diff --git a/install.sh b/install.sh index 2e50f7b..fd6bc4e 100755 --- a/install.sh +++ b/install.sh @@ -16,7 +16,8 @@ # Core Settings: Sites & Page Timing, Display & Interaction, Timezone, # Hidden Site PIN, Password Protection & Lockout, WiFi, # Power/Display/Quiet Hours. -# Addons: CUPS Printing (menus/addon_cups.sh). +# Addons: CUPS Printing (menus/addon_cups.sh), Authelia Auto-Login +# (menus/addon_authelia.sh). # Advanced: Diagnostics (menus/diagnostics.sh - system status/logs/ # audio/network). # @@ -52,6 +53,8 @@ source "$SCRIPT_DIR/menus/power_schedule.sh" source "$SCRIPT_DIR/menus/diagnostics.sh" # shellcheck source=menus/addon_cups.sh source "$SCRIPT_DIR/menus/addon_cups.sh" +# shellcheck source=menus/addon_authelia.sh +source "$SCRIPT_DIR/menus/addon_authelia.sh" ################################################################################ # Preflight @@ -115,8 +118,8 @@ core_settings_menu() { } addons_menu_builder() { - MENU_LABELS=("CUPS Printing") - MENU_HANDLERS=(addon_cups_menu) + MENU_LABELS=("CUPS Printing" "Authelia Auto-Login") + MENU_HANDLERS=(addon_cups_menu addon_authelia_menu) } addons_menu() { diff --git a/lib/config.sh b/lib/config.sh index c4ce7b5..6007c2e 100644 --- a/lib/config.sh +++ b/lib/config.sh @@ -60,6 +60,9 @@ LOCKOUT_AT_TIME="" LOCKOUT_ACTIVE_START="" LOCKOUT_ACTIVE_END="" REQUIRE_PASSWORD_ON_BOOT="false" +AUTHELIA_URL="" +AUTHELIA_USERNAME="" +AUTHELIA_ENCRYPTED_PASSWORD="" kiosk_user_exists() { id "$KIOSK_USER" &>/dev/null @@ -125,6 +128,10 @@ load_existing_config() { boot_password=$(sudo -u "$KIOSK_USER" jq -r '.requirePasswordOnBoot // false' "$CONFIG_PATH" 2>/dev/null) [[ "$boot_password" == "true" ]] && REQUIRE_PASSWORD_ON_BOOT="true" || REQUIRE_PASSWORD_ON_BOOT="false" + + AUTHELIA_URL=$(sudo -u "$KIOSK_USER" jq -r '.autheliaURL // ""' "$CONFIG_PATH" 2>/dev/null || echo "") + AUTHELIA_USERNAME=$(sudo -u "$KIOSK_USER" jq -r '.autheliaUsername // ""' "$CONFIG_PATH" 2>/dev/null || echo "") + AUTHELIA_ENCRYPTED_PASSWORD=$(sudo -u "$KIOSK_USER" jq -r '.autheliaEncryptedPassword // ""' "$CONFIG_PATH" 2>/dev/null || echo "") } # Write every bash global back out to config.json, then offer to reload the @@ -159,7 +166,28 @@ save_config() { local boot_password_json="false" [[ "$REQUIRE_PASSWORD_ON_BOOT" == "true" ]] && boot_password_json="true" - jq -n \ + # Merge onto whatever's already in config.json rather than rebuilding + # the file from nothing. The legacy save_config did a full `jq -n` + # rebuild listing every known field - any field it doesn't know about + # (e.g. Authelia's autheliaURL/autheliaUsername/ + # autheliaEncryptedPassword, written by its own careful `. + {...}` + # merge) gets silently DELETED the next time any other menu that + # calls save_config runs. Real, currently-shipping bug in the legacy + # script, not unique to this migration - ported faithfully into this + # file's first version because no test happened to set an untracked + # field first. `. + {known fields...}` below preserves anything this + # tool doesn't track while still fully replacing every field it does + # (including tabs, via the same array-rebuild loop as before) - jq's + # `+` on objects takes the right-hand value for any key present on + # both sides, so a fully-specified `tabs` here still discards a + # deleted tab rather than merging old and new. + local existing="{}" + if sudo -u "$KIOSK_USER" test -f "$CONFIG_PATH" 2>/dev/null; then + existing=$(sudo -u "$KIOSK_USER" cat "$CONFIG_PATH" 2>/dev/null) + echo "$existing" | jq empty 2>/dev/null || existing="{}" + fi + + echo "$existing" | jq \ --argjson autoswitch true \ --argjson enableTouch true \ --argjson dualSwipe "$dual_json" \ @@ -177,7 +205,10 @@ save_config() { --arg lockoutActiveStart "${LOCKOUT_ACTIVE_START:-}" \ --arg lockoutActiveEnd "${LOCKOUT_ACTIVE_END:-}" \ --argjson requirePasswordOnBoot "$boot_password_json" \ - '{autoswitch:$autoswitch,enableTouch:$enableTouch,dualSwipe:$dualSwipe,swipeMode:$swipeMode,allowNavigation:$allowNavigation,homeTabIndex:$homeTabIndex,inactivityTimeout:$inactivityTimeout,enablePauseButton:$enablePauseButton,enableKeyboardButton:$enableKeyboardButton,enableNavButton:$enableNavButton,enablePasswordProtection:$enablePasswordProtection,lockoutPassword:$lockoutPassword,lockoutTimeout:$lockoutTimeout,lockoutAtTime:$lockoutAtTime,lockoutActiveStart:$lockoutActiveStart,lockoutActiveEnd:$lockoutActiveEnd,requirePasswordOnBoot:$requirePasswordOnBoot,tabs:[]}' > "$tmp" + --arg autheliaURL "${AUTHELIA_URL:-}" \ + --arg autheliaUsername "${AUTHELIA_USERNAME:-}" \ + --arg autheliaEncryptedPassword "${AUTHELIA_ENCRYPTED_PASSWORD:-}" \ + '. + {autoswitch:$autoswitch,enableTouch:$enableTouch,dualSwipe:$dualSwipe,swipeMode:$swipeMode,allowNavigation:$allowNavigation,homeTabIndex:$homeTabIndex,inactivityTimeout:$inactivityTimeout,enablePauseButton:$enablePauseButton,enableKeyboardButton:$enableKeyboardButton,enableNavButton:$enableNavButton,enablePasswordProtection:$enablePasswordProtection,lockoutPassword:$lockoutPassword,lockoutTimeout:$lockoutTimeout,lockoutAtTime:$lockoutAtTime,lockoutActiveStart:$lockoutActiveStart,lockoutActiveEnd:$lockoutActiveEnd,requirePasswordOnBoot:$requirePasswordOnBoot,autheliaURL:$autheliaURL,autheliaUsername:$autheliaUsername,autheliaEncryptedPassword:$autheliaEncryptedPassword,tabs:[]}' > "$tmp" if [[ ${#URLS[@]} -gt 0 ]]; then for idx in "${!URLS[@]}"; do diff --git a/menus/addon_authelia.sh b/menus/addon_authelia.sh new file mode 100644 index 0000000..265e0dd --- /dev/null +++ b/menus/addon_authelia.sh @@ -0,0 +1,232 @@ +#!/bin/bash +################################################################################ +# menus/addon_authelia.sh - "Authelia Auto-Login" addon. +# +# Stores encrypted Authelia SSO credentials so the kiosk authenticates +# automatically on every startup. The password is AES-256-CBC encrypted +# with a key derived from this machine's /etc/machine-id via scrypt - +# the encrypted blob is useless on any other machine - and is NEVER +# stored in plain text, matching the legacy addon exactly (same +# algorithm, same salt, same node crypto calls). +# +# autheliaURL/autheliaUsername/autheliaEncryptedPassword are tracked +# fields in lib/config.sh now (load_existing_config/save_config), same +# as every other config.json field this tool manages - this is also +# what motivated fixing save_config to merge onto the existing file +# instead of rebuilding it from scratch (see lib/config.sh): the legacy +# save_config had no idea these three fields existed, so configuring +# Authelia and then visiting Sites/Touch/Navigation/Password Protection +# in the legacy menu would silently wipe the credentials on the next +# save. Real bug in the shipped script, not unique to this migration. +# +# Depends on: lib/menu.sh, lib/config.sh being sourced first. +################################################################################ + +addon_authelia_status() { + if [[ -n "$AUTHELIA_URL" ]]; then + echo "Authelia: configured" + echo " URL: $AUTHELIA_URL" + echo " Username: $AUTHELIA_USERNAME" + else + echo "Authelia: not configured" + fi +} + +addon_authelia_menu_builder() { + if [[ -n "$AUTHELIA_URL" ]]; then + MENU_LABELS=("Reconfigure (overwrite)" "Show server-side setup instructions again" "Clear Authelia configuration") + MENU_HANDLERS=(action_configure_authelia action_show_authelia_server_setup action_clear_authelia) + else + MENU_LABELS=("Configure Authelia auto-login") + MENU_HANDLERS=(action_configure_authelia) + fi +} + +addon_authelia_menu() { + load_existing_config + + if ! sudo -u "$KIOSK_USER" test -f "$CONFIG_PATH" 2>/dev/null; then + log_error "config.json not found at $CONFIG_PATH - run a full install first" + pause + return + fi + + run_menu "AUTHELIA AUTO-LOGIN" addon_authelia_menu_builder addon_authelia_status +} + +################################################################################ +# Encryption +################################################################################ + +# Same algorithm as the legacy addon: AES-256-CBC, key derived from +# /etc/machine-id via scrypt with a fixed salt, random IV prepended to +# the ciphertext, everything base64-encoded. main.js decrypts with the +# same derivation - do not change this without updating main.js too. +encrypt_authelia_password() { + local password="$1" + command -v node &>/dev/null || return 1 + + node -e " +const crypto=require('crypto'),fs=require('fs'); +const id=fs.readFileSync('/etc/machine-id','utf8').trim(); +const key=crypto.scryptSync(id,'kiosk-authelia-v1',32); +const iv=crypto.randomBytes(16); +const c=crypto.createCipheriv('aes-256-cbc',key,iv); +const enc=Buffer.concat([c.update(process.argv[1],'utf8'),c.final()]); +process.stdout.write(Buffer.concat([iv,enc]).toString('base64')); +" "$password" 2>/dev/null +} + +################################################################################ +# Actions +################################################################################ + +action_configure_authelia() { + echo + echo "Stores encrypted Authelia credentials so the kiosk" + echo "authenticates automatically on every startup." + echo "Password is encrypted with this machine's unique ID -" + echo "the encrypted blob is useless on any other machine." + echo + + if [[ -n "$AUTHELIA_URL" ]]; then + echo "Current config:" + echo " URL: $AUTHELIA_URL" + echo " Username: $AUTHELIA_USERNAME" + echo + ask_yes_no "Overwrite existing Authelia config?" "n" || { echo "Cancelled"; return; } + echo + fi + + local url user pass + read -r -p "Authelia URL (e.g. https://auth.yourdomain.com): " url + [[ -z "$url" ]] && { echo "Cancelled"; return; } + read -r -p "Authelia username: " user + [[ -z "$user" ]] && { echo "Cancelled"; return; } + read -r -s -p "Authelia password: " pass + echo + [[ -z "$pass" ]] && { echo "Cancelled"; return; } + + echo "Encrypting with machine ID..." + local encrypted + encrypted=$(encrypt_authelia_password "$pass") + if [[ -z "$encrypted" ]]; then + log_error "Encryption failed - is Node.js installed?" + return 1 + fi + + AUTHELIA_URL="$url" + AUTHELIA_USERNAME="$user" + AUTHELIA_ENCRYPTED_PASSWORD="$encrypted" + save_config + + log_success "Authelia config saved (password encrypted, NOT stored in plain text)" + action_show_authelia_server_setup + + echo + if is_service_active lightdm && ask_yes_no "Restart kiosk display now?" "n"; then + sudo systemctl restart lightdm + fi +} + +action_clear_authelia() { + echo + ask_yes_no "Clear Authelia configuration?" "n" || { echo "Cancelled"; return; } + + AUTHELIA_URL="" + AUTHELIA_USERNAME="" + AUTHELIA_ENCRYPTED_PASSWORD="" + save_config + log_success "Authelia configuration cleared" +} + +action_show_authelia_server_setup() { + echo + echo "════════════════════════════════════════════════════════════" + echo " AUTHELIA SERVER-SIDE SETUP (Dockerized)" + echo "════════════════════════════════════════════════════════════" + echo + echo "1. Generate the argon2 password hash on your Docker host:" + echo + echo " docker run --rm authelia/authelia:latest \\" + echo " authelia crypto hash generate argon2 \\" + echo " --password 'yourpassword'" + echo + echo " Copy the \$argon2id\$... output — that is your hash." + echo + echo "2. ADD a kiosk user to ~/docker/authelia/config/users.yml" + echo " (append — do not replace existing users):" + echo + echo " kiosk:" + echo " displayname: \"Kiosk Display\"" + echo " password: '\$argon2id\$v=19\$m=65536,t=3,p=4\$'" + echo " email: kiosk@local.com" + echo " groups:" + echo " - kiosk" + echo + echo "3. MERGE into ~/docker/authelia/config/configuration.yml:" + echo + echo " ── access_control ─────────────────────────────────────" + echo " Find your EXISTING access_control block and add the" + echo " kiosk rule as the FIRST rule inside it." + echo + echo " !! DO NOT create a second access_control: block !!" + echo " YAML silently ignores duplicate keys — the kiosk rule" + echo " will be invisible to Authelia and you will get a white" + echo " screen on the kiosk." + echo + echo " Authelia reads rules top-down, first match wins." + echo " The kiosk rule MUST be above any two_factor rule or" + echo " the two_factor wildcard will match first." + echo + echo " ── EXAMPLE — before (your existing config): ──────────" + echo " access_control:" + echo " default_policy: deny" + echo " rules:" + echo " - domain: '*.yourdomain.com'" + echo " policy: two_factor" + echo + echo " ── EXAMPLE — after (add kiosk rule above two_factor): ─" + echo " access_control:" + echo " default_policy: deny" + echo " rules:" + echo " - domain: '*.yourdomain.com' # <-- kiosk first" + echo " subject: 'group:kiosk'" + echo " policy: one_factor" + echo " - domain: '*.yourdomain.com' # <-- existing" + echo " policy: two_factor" + echo + echo " Why one_factor? The kiosk authenticates via the API" + echo " (/api/firstfactor — password only). TOTP and WebAuthn" + echo " require a second interactive step that is impossible" + echo " from a script, so the kiosk group must use one_factor." + echo + echo " ── session ─────────────────────────────────────────────" + echo " Keep your existing session block — no changes needed." + echo " The kiosk re-authenticates via API on every startup so" + echo " session expiry barely matters for it." + echo + echo " If you do NOT yet have a session block, add:" + echo + echo " session:" + echo " expiration: 8h" + echo " inactivity: 1h" + echo " remember_me: 7d" + echo " cookies:" + echo " - domain: yourdomain.com" + echo " authelia_url: https://auth.yourdomain.com" + echo + echo "4. Restart Authelia on your Docker host:" + echo " docker compose restart authelia" + echo + echo "────────────────────────────────────────────────────────────" + echo " NOTE: HTTP Basic Auth (per-site username/password) still" + echo " works alongside Authelia for sites that use browser-popup" + echo " authentication rather than Authelia SSO." + echo "────────────────────────────────────────────────────────────" + echo + echo " To clear Authelia config later, use this menu's" + echo " 'Clear Authelia configuration' option." + echo + pause +} diff --git a/ubuntu-based-kiosk.sh b/ubuntu-based-kiosk.sh index c6d0581..49a7c86 100644 --- a/ubuntu-based-kiosk.sh +++ b/ubuntu-based-kiosk.sh @@ -1,8 +1,43 @@ #!/bin/bash ################################################################################ -### Ubuntu Based Kiosk v2.5.0 ### +### Ubuntu Based Kiosk v2.6.0 ### ################################################################################ # +# RELEASE v2.6.0 - Authelia Migrated; Real Config-Clobbering Bug Fixed +# - New in ./install.sh: Authelia Auto-Login (menus/addon_authelia.sh) - +# encrypted SSO credentials (AES-256-CBC, key derived from this +# machine's /etc/machine-id via scrypt - same algorithm main.js +# decrypts with, verified by test with a real round-trip encrypt/ +# decrypt, not just "some string came out"), plus the full Dockerized +# server-side setup instructions, viewable again later without +# reconfiguring. +# - IMPORTANT bug found and fixed in lib/config.sh, NOT specific to +# Authelia or to this migration: save_config() did a full `jq -n` +# rebuild of config.json from known fields, exactly like the legacy +# script's save_config still does. Authelia's own write is a careful +# `. + {...}` merge that preserves everything - but the legacy +# configure_authelia() writes autheliaURL/autheliaUsername/ +# autheliaEncryptedPassword into config.json via that merge, and +# *neither* the legacy save_config nor this project's own (before this +# fix) had any idea those three fields existed. The next time a user +# visited Sites, Touch Controls, Navigation, or Password Protection - +# all of which call save_config - their Authelia credentials were +# silently deleted. This is a real bug in the currently-shipping +# single-file installer, not introduced by this migration; ported +# faithfully into lib/config.sh's first version because no test +# happened to set an untracked field before calling save_config. +# Fixed here by changing save_config to merge its known fields onto +# whatever's already in config.json (jq `. + {...}`) instead of +# rebuilding the file from nothing, so any field this tool doesn't +# track - Authelia's three today, anything else tomorrow - survives +# automatically. autheliaURL/autheliaUsername/autheliaEncryptedPassword +# are also now tracked fields in their own right, same as every other +# config.json field this tool manages. NOTE: the equivalent bug still +# exists in this script's own save_config below, unfixed - see +# Readme.md ("Modular Management") for the open question of whether to +# backport this specific fix here independent of the wider migration, +# given it's a real, currently-shipping credential-loss bug. +# # RELEASE v2.5.0 - First Addon Migrated (CUPS), Menu Restructured # - New in ./install.sh: CUPS Printing (menus/addon_cups.sh) - the first # Addon migrated. Install/reconfigure/complete uninstall (purge), @@ -226,7 +261,7 @@ set -euo pipefail ### SECTION 1: CONSTANTS & GLOBALS ################################################################################ -SCRIPT_VERSION="2.5.0" +SCRIPT_VERSION="2.6.0" # Resolve the real path to this script file. # When piped (curl|bash or wget|bash), BASH_SOURCE[0] is a pipe descriptor, From cdd6f5adcfe01af1ff565a96eb6ed078930fe0c0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 01:21:43 +0000 Subject: [PATCH 09/19] 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. --- Readme.md | 23 +++++++++++--------- ubuntu-based-kiosk.sh | 50 +++++++++++++++++++++++++++++++++++++++---- 2 files changed, 59 insertions(+), 14 deletions(-) diff --git a/Readme.md b/Readme.md index d662d1c..7b13d40 100644 --- a/Readme.md +++ b/Readme.md @@ -1,6 +1,6 @@ # Ubuntu Based Kiosk -**Current Version:** 2.6.0 (check script header for latest version) +**Current Version:** 2.7.0 (check script header for latest version) **Built with Claude Sonnet 4.6 AI assistance** **License:** GPL v3 - Keep derivatives open source **Repository:** https://github.com/outis1one/ubuntu-based-kiosk/ @@ -1233,22 +1233,25 @@ 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. -**Open question:** the config-clobbering bug fixed in `lib/config.sh` +**Resolved (v2.7.0):** the config-clobbering bug fixed in `lib/config.sh` (v2.6.0 — `save_config` silently deleting fields it doesn't know about, -like Authelia's credentials, on the next unrelated save) has the exact -same shape in `ubuntu-based-kiosk.sh`'s own `save_config`, unfixed. It's -a real bug in the currently-shipping single-file installer, independent -of whether the rest of that menu ever gets migrated. Worth deciding -separately whether to backport just that fix into the legacy script now -rather than waiting for a full migration pass. +like Authelia's credentials, on the next unrelated save) had the exact +same shape in `ubuntu-based-kiosk.sh`'s own `save_config`. Backported +just that one fix into the legacy script, independent of migrating the +rest of that menu — it was a real credential-loss bug in the +currently-shipping single-file installer and didn't need to wait for a +full migration pass. --- ## Project Status & Future Plans -**Current Version:** 2.6.0 +**Current Version:** 2.7.0 -**Recent Updates (v2.6.0):** +**Recent Updates (v2.7.0):** +- **Backported fix:** `ubuntu-based-kiosk.sh`'s own `save_config()` had the identical config-clobbering bug fixed in `lib/config.sh` under v2.6.0 — it silently deleted Authelia credentials (or any field it doesn't explicitly know about) the next time Sites, Touch Controls, Navigation, or Password Protection saved. This was a real, currently-shipping credential-loss bug, so it's fixed directly in the legacy script now rather than waiting for those menus to be migrated. Verified in isolation against the exact extracted function before touching the shipping copy. Nothing else about those menus changed. + +**Previous (v2.6.0):** - **Authelia Auto-Login migrated** — encrypted SSO credentials (same AES-256-CBC/scrypt algorithm `main.js` decrypts with, verified by a real encrypt→decrypt round trip in testing) plus the full server-side Docker setup instructions, viewable again later without reconfiguring. - **Important bug found and fixed, not specific to Authelia:** `save_config()` did a full rebuild of `config.json` from known fields — exactly like the legacy script's `save_config` still does. Authelia's own write is a careful merge that preserves everything else, but the *next* save from Sites, Touch Controls, Navigation, or Password Protection would silently delete the Authelia credentials, since none of those knew the three Authelia fields existed. **This is a real bug in the currently-shipping single-file installer**, not introduced by this migration. Fixed in `lib/config.sh` by changing `save_config` to merge its known fields onto whatever's already on disk instead of rebuilding from nothing, so any untracked field — Authelia's three today, anything else tomorrow — survives automatically. The equivalent bug still exists, unfixed, in `ubuntu-based-kiosk.sh`'s own `save_config` — see "Modular Management" below. diff --git a/ubuntu-based-kiosk.sh b/ubuntu-based-kiosk.sh index 49a7c86..35b03fa 100644 --- a/ubuntu-based-kiosk.sh +++ b/ubuntu-based-kiosk.sh @@ -1,8 +1,35 @@ #!/bin/bash ################################################################################ -### Ubuntu Based Kiosk v2.6.0 ### +### Ubuntu Based Kiosk v2.7.0 ### ################################################################################ # +# RELEASE v2.7.0 - Backported Fix: save_config() No Longer Deletes +# Authelia Credentials (or Any Other Untracked Field) +# - This script's own save_config() had the exact bug described under +# v2.6.0 below: it rebuilt config.json from a fixed list of known +# fields via `jq -n`, which silently deleted anything it didn't know +# about - specifically autheliaURL/autheliaUsername/ +# autheliaEncryptedPassword, written by configure_authelia()'s own +# careful `. + {...}` merge. Configure Authelia, then visit Core +# Settings → Sites/Touch Controls/Navigation/Password Protection (all +# of which call save_config), and the Authelia credentials were +# silently gone - a real credential-loss bug that was shipping in this +# script independent of the modular migration. +# - Fixed the same way as lib/config.sh's save_config: merge the known +# fields onto whatever's already in config.json (`. + {...}`) instead +# of rebuilding it from nothing, with a `jq empty` validity check +# falling back to `{}` if the existing file is missing or corrupt. +# Verified in isolation (the exact function extracted and exercised +# against a stub config.json seeded with Authelia-style fields, +# confirming they survive a second save_config call while an actual +# settings change still takes effect, plus the corrupt/missing-file +# edge cases) before touching the shipping copy. +# - This is a standalone backport of one specific fix, not a wider +# migration of the Sites/Touch/Navigation/Authelia menus into this +# script - those still work exactly as before, just without the +# credential-loss bug. The modular ./install.sh path (lib/config.sh) +# got the equivalent fix in v2.6.0. +# # RELEASE v2.6.0 - Authelia Migrated; Real Config-Clobbering Bug Fixed # - New in ./install.sh: Authelia Auto-Login (menus/addon_authelia.sh) - # encrypted SSO credentials (AES-256-CBC, key derived from this @@ -261,7 +288,7 @@ set -euo pipefail ### SECTION 1: CONSTANTS & GLOBALS ################################################################################ -SCRIPT_VERSION="2.6.0" +SCRIPT_VERSION="2.7.0" # Resolve the real path to this script file. # When piped (curl|bash or wget|bash), BASH_SOURCE[0] is a pipe descriptor, @@ -3712,7 +3739,22 @@ save_config() { local boot_password_json="false" [[ "$REQUIRE_PASSWORD_ON_BOOT" == "true" ]] && boot_password_json="true" - jq -n \ + # Merge onto whatever's already in config.json rather than rebuilding + # it from nothing (fixed in v2.7.0). The old `jq -n` rebuild silently + # deleted any field this function doesn't explicitly know about - + # notably autheliaURL/autheliaUsername/autheliaEncryptedPassword, + # written by configure_authelia()'s own careful `. + {...}` merge. + # Configuring Authelia and then visiting Sites, Touch Controls, + # Navigation, or Password Protection (all of which call this + # function) silently deleted the Authelia credentials. See the + # RELEASE v2.7.0 note above. + local existing="{}" + if sudo -u "$KIOSK_USER" test -f "$CONFIG_PATH" 2>/dev/null; then + existing=$(sudo -u "$KIOSK_USER" cat "$CONFIG_PATH" 2>/dev/null) + echo "$existing" | jq empty 2>/dev/null || existing="{}" + fi + + echo "$existing" | jq \ --arg unit "s" \ --argjson autoswitch "$auto_json" \ --argjson enableTouch true \ @@ -3731,7 +3773,7 @@ save_config() { --arg lockoutActiveStart "${LOCKOUT_ACTIVE_START:-}" \ --arg lockoutActiveEnd "${LOCKOUT_ACTIVE_END:-}" \ --argjson requirePasswordOnBoot "$boot_password_json" \ - '{unit:$unit,autoswitch:$autoswitch,enableTouch:$enableTouch,dualSwipe:$dualSwipe,swipeMode:$swipeMode,allowNavigation:$allowNavigation,homeTabIndex:$homeTabIndex,inactivityTimeout:$inactivityTimeout,enablePauseButton:$enablePauseButton,enableKeyboardButton:$enableKeyboardButton,enableNavButton:$enableNavButton,enablePasswordProtection:$enablePasswordProtection,lockoutPassword:$lockoutPassword,lockoutTimeout:$lockoutTimeout,lockoutAtTime:$lockoutAtTime,lockoutActiveStart:$lockoutActiveStart,lockoutActiveEnd:$lockoutActiveEnd,requirePasswordOnBoot:$requirePasswordOnBoot,tabs:[]}' > "$tmp" + '. + {unit:$unit,autoswitch:$autoswitch,enableTouch:$enableTouch,dualSwipe:$dualSwipe,swipeMode:$swipeMode,allowNavigation:$allowNavigation,homeTabIndex:$homeTabIndex,inactivityTimeout:$inactivityTimeout,enablePauseButton:$enablePauseButton,enableKeyboardButton:$enableKeyboardButton,enableNavButton:$enableNavButton,enablePasswordProtection:$enablePasswordProtection,lockoutPassword:$lockoutPassword,lockoutTimeout:$lockoutTimeout,lockoutAtTime:$lockoutAtTime,lockoutActiveStart:$lockoutActiveStart,lockoutActiveEnd:$lockoutActiveEnd,requirePasswordOnBoot:$requirePasswordOnBoot,tabs:[]}' > "$tmp" if [[ ${#URLS[@]} -gt 0 ]]; then for idx in "${!URLS[@]}"; do From 0454a259fa9a09f25c0a5eaefb5f8e7d7d2d6941 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 01:35:15 +0000 Subject: [PATCH 10/19] 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 | 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. --- Readme.md | 15 +- install.sh | 9 +- lib/config.sh | 1 + lib/menu.sh | 21 +- menus/addon_remote_access.sh | 418 +++++++++++++++++++++++++++++++++++ menus/power_schedule.sh | 21 +- ubuntu-based-kiosk.sh | 36 ++- 7 files changed, 495 insertions(+), 26 deletions(-) create mode 100644 menus/addon_remote_access.sh diff --git a/Readme.md b/Readme.md index 7b13d40..20f7274 100644 --- a/Readme.md +++ b/Readme.md @@ -1,6 +1,6 @@ # Ubuntu Based Kiosk -**Current Version:** 2.7.0 (check script header for latest version) +**Current Version:** 2.8.0 (check script header for latest version) **Built with Claude Sonnet 4.6 AI assistance** **License:** GPL v3 - Keep derivatives open source **Repository:** https://github.com/outis1one/ubuntu-based-kiosk/ @@ -1212,6 +1212,8 @@ terminal menu and the web UI, so they can't drift apart). - `menus/addon_authelia.sh` — **Authelia Auto-Login** (Addons): encrypted SSO credentials plus the server-side setup instructions. Prompted the `save_config` merge fix above. +- `menus/addon_remote_access.sh` — **Remote Access** (Addons): VNC, + WireGuard, Tailscale, Netbird. The biggest Addon so far. - `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: @@ -1224,7 +1226,7 @@ terminal menu and the web UI, so they can't drift apart). **Honest status:** this does not yet replace first-time installation, or most of the old installer. `ubuntu-based-kiosk.sh` is still ~12,000 lines and still contains its own unremoved, unmodified copies of every -menu above (plus Upgrade, Reinstall, Uninstall, 3 more Addons, and the +menu above (plus Upgrade, Reinstall, Uninstall, 2 more Addons, and the other 8 Advanced items — none of that has moved yet). Both copies coexist deliberately: the old ones stay until enough of Core Settings/Addons/Advanced is migrated to @@ -1246,9 +1248,14 @@ full migration pass. ## Project Status & Future Plans -**Current Version:** 2.7.0 +**Current Version:** 2.8.0 -**Recent Updates (v2.7.0):** +**Recent Updates (v2.8.0):** +- **Remote Access migrated** — VNC, WireGuard, Tailscale, and Netbird, each with its own install/connect/status/uninstall flow. The biggest Addon so far. Tailscale/Netbird install via the vendors' own `curl | sh` method, preserved as-is. +- **Important framework-level bug found and fixed:** `run_menu()`'s *handler* call has been crash-guarded since v2.1.0, but its *status function* call was still completely bare. A status function is meant to be read-only display, but a pipeline whose `grep` matches nothing (which `pipefail` turns into a failure even though the actual last command succeeds) would crash the **entire session**, not just fail to show status. Found while building `wireguard_status()` and verifying its exact failure mode rather than assuming it was covered. Fixed once, in the framework, protecting every status function across every menu — present and future. Also audited every existing status function for the same shape and fixed one real instance in `power_schedule_status()`. +- Deduplicated: promoted `power_schedule.sh`'s `enable_and_start_timers()` to a shared `enable_and_start_units()` in `lib/menu.sh` (works for services now, not just timers) rather than writing the same helper a second time for VNC/WireGuard. + +**Previous (v2.7.0):** - **Backported fix:** `ubuntu-based-kiosk.sh`'s own `save_config()` had the identical config-clobbering bug fixed in `lib/config.sh` under v2.6.0 — it silently deleted Authelia credentials (or any field it doesn't explicitly know about) the next time Sites, Touch Controls, Navigation, or Password Protection saved. This was a real, currently-shipping credential-loss bug, so it's fixed directly in the legacy script now rather than waiting for those menus to be migrated. Verified in isolation against the exact extracted function before touching the shipping copy. Nothing else about those menus changed. **Previous (v2.6.0):** diff --git a/install.sh b/install.sh index fd6bc4e..3d38723 100755 --- a/install.sh +++ b/install.sh @@ -17,7 +17,8 @@ # Hidden Site PIN, Password Protection & Lockout, WiFi, # Power/Display/Quiet Hours. # Addons: CUPS Printing (menus/addon_cups.sh), Authelia Auto-Login -# (menus/addon_authelia.sh). +# (menus/addon_authelia.sh), Remote Access - VNC/WireGuard/ +# Tailscale/Netbird (menus/addon_remote_access.sh). # Advanced: Diagnostics (menus/diagnostics.sh - system status/logs/ # audio/network). # @@ -55,6 +56,8 @@ source "$SCRIPT_DIR/menus/diagnostics.sh" source "$SCRIPT_DIR/menus/addon_cups.sh" # shellcheck source=menus/addon_authelia.sh source "$SCRIPT_DIR/menus/addon_authelia.sh" +# shellcheck source=menus/addon_remote_access.sh +source "$SCRIPT_DIR/menus/addon_remote_access.sh" ################################################################################ # Preflight @@ -118,8 +121,8 @@ core_settings_menu() { } addons_menu_builder() { - MENU_LABELS=("CUPS Printing" "Authelia Auto-Login") - MENU_HANDLERS=(addon_cups_menu addon_authelia_menu) + MENU_LABELS=("CUPS Printing" "Authelia Auto-Login" "Remote Access") + MENU_HANDLERS=(addon_cups_menu addon_authelia_menu remote_access_menu) } addons_menu() { diff --git a/lib/config.sh b/lib/config.sh index 6007c2e..e6dea50 100644 --- a/lib/config.sh +++ b/lib/config.sh @@ -30,6 +30,7 @@ : "${BIN_DIR:=/usr/local/bin}" : "${NETPLAN_DIR:=/etc/netplan}" : "${POLKIT_DIR:=/etc/polkit-1/localauthority/50-local.d}" +: "${WIREGUARD_DIR:=/etc/wireguard}" # The admin account actually running this tool (as opposed to $KIOSK_USER, # the kiosk's own restricted account) - used where an addon needs to grant diff --git a/lib/menu.sh b/lib/menu.sh index 34ad654..6728082 100644 --- a/lib/menu.sh +++ b/lib/menu.sh @@ -84,6 +84,18 @@ get_vpn_ips() { [[ -n "$vpn_info" ]] && echo "$vpn_info" || echo "None" } +# enable_and_start_units UNIT [UNIT...] +# Reloads systemd and enables+starts the given unit(s) - services or +# timers - returning non-zero if enable or start fails (e.g. systemd/ +# D-Bus unreachable, or a real failure on real hardware). Always call +# this from an `if`/`&&`/`||` context: this whole tool runs under +# set -e, so a bare, unguarded call whose last command fails would take +# down the entire session instead of just this one action. +enable_and_start_units() { + sudo systemctl daemon-reload 2>/dev/null || true + sudo systemctl enable "$@" 2>/dev/null && sudo systemctl start "$@" 2>/dev/null +} + pause() { read -r -p "Press Enter to continue..." } @@ -279,7 +291,14 @@ run_menu() { print_menu_header "$title" if [[ -n "$status_func" ]]; then - "$status_func" + # `|| true`: same reasoning as the handler call below - a + # status function's job is read-only display, and a + # legitimately failing command inside it (e.g. a pipeline + # whose grep matches nothing, which pipefail turns into a + # pipeline failure even though the actual last command + # succeeded) must not be allowed to kill the whole session + # over what should be, at worst, incomplete status text. + "$status_func" || true echo fi diff --git a/menus/addon_remote_access.sh b/menus/addon_remote_access.sh new file mode 100644 index 0000000..1355e5b --- /dev/null +++ b/menus/addon_remote_access.sh @@ -0,0 +1,418 @@ +#!/bin/bash +################################################################################ +# menus/addon_remote_access.sh - "Remote Access" addon (VNC, WireGuard, +# Tailscale, Netbird). +# +# Third Addon migrated, and the biggest so far in scope (4 sub-areas). +# All four genuinely mutate real system state at fixed paths this project +# doesn't own the layout of (apt packages, /etc/wireguard, real VPN +# client CLIs) - same risk class as CUPS. Only $WIREGUARD_DIR and +# $SYSTEMD_DIR (lib/config.sh) are parameterized, since those are the +# only paths this file itself writes to; every command (apt, systemctl, +# wg, tailscale, netbird, x11vnc) gets full stubbing in every test. +# +# Tailscale and Netbird install themselves via `curl -fsSL | sh` - the vendors' own documented install method, preserved as- +# is rather than redesigned. This is NEVER allowed to run for real in +# any test: curl itself is stubbed, not just sudo, so there is no path +# by which a test could reach the network. +# +# None of x11vnc/wg/tailscale/netbird are installed in a fresh +# environment, so their "not installed" detection is real/unstubbed and +# safe to exercise end-to-end - only the "install" actions need stubs. +# +# Depends on: lib/menu.sh, lib/config.sh being sourced first. +################################################################################ + +remote_access_status() { + echo "VNC: $(is_service_active x11vnc && echo "running" || echo "not installed")" + echo "WireGuard: $(wireguard_connected && echo "connected" || (command -v wg &>/dev/null && echo "installed, not connected" || echo "not installed"))" + echo "Tailscale: $(command -v tailscale &>/dev/null && echo "installed" || echo "not installed")" + echo "Netbird: $(command -v netbird &>/dev/null && echo "installed" || echo "not installed")" +} + +remote_access_menu_builder() { + MENU_LABELS=("VNC Remote Desktop" "WireGuard VPN" "Tailscale VPN" "Netbird VPN") + MENU_HANDLERS=(vnc_menu wireguard_menu tailscale_menu netbird_menu) +} + +remote_access_menu() { + run_menu "REMOTE ACCESS" remote_access_menu_builder remote_access_status +} + +################################################################################ +# VNC (x11vnc) +################################################################################ + +vnc_status() { + if is_service_active x11vnc; then + echo "VNC: running - connect to $(get_ip_address):5900" + else + echo "VNC: not installed" + fi +} + +vnc_menu_builder() { + if is_service_active x11vnc; then + MENU_LABELS=("Reconfigure password" "Uninstall") + MENU_HANDLERS=(action_vnc_change_password action_vnc_uninstall) + else + MENU_LABELS=("Install x11vnc") + MENU_HANDLERS=(action_vnc_install) + fi +} + +vnc_menu() { + run_menu "VNC REMOTE DESKTOP" vnc_menu_builder vnc_status +} + +action_vnc_install() { + echo + ask_yes_no "Install x11vnc?" "n" || { echo "Cancelled"; return; } + + if ! sudo apt install -y x11vnc; then + log_error "x11vnc installation failed" + return 1 + fi + + local vnc_pass + read -r -s -p "VNC password: " vnc_pass + echo + if [[ -z "$vnc_pass" ]]; then + log_error "No password provided - cancelled" + return 1 + fi + + sudo -u "$KIOSK_USER" mkdir -p "$KIOSK_HOME/.vnc" + sudo -u "$KIOSK_USER" x11vnc -storepasswd "$vnc_pass" "$KIOSK_HOME/.vnc/passwd" + + sudo tee "$SYSTEMD_DIR/x11vnc.service" > /dev/null </dev/null || true + log_success "VNC installed - connect to $(get_ip_address):5900" + else + log_warning "x11vnc installed but systemctl enable/start failed - check 'systemctl status x11vnc'" + fi +} + +action_vnc_change_password() { + echo + local vnc_pass + read -r -s -p "New VNC password: " vnc_pass + echo + if [[ -z "$vnc_pass" ]]; then + log_error "No password provided - cancelled" + return 1 + fi + + sudo -u "$KIOSK_USER" x11vnc -storepasswd "$vnc_pass" "$KIOSK_HOME/.vnc/passwd" + if sudo systemctl restart x11vnc 2>/dev/null; then + log_success "VNC password updated" + else + log_warning "Password file updated, but restarting x11vnc failed - check 'systemctl status x11vnc'" + fi +} + +action_vnc_uninstall() { + echo + ask_yes_no "Remove VNC?" "n" || { echo "Cancelled"; return; } + + sudo systemctl stop x11vnc 2>/dev/null || true + sudo systemctl disable x11vnc 2>/dev/null || true + sudo rm -f "$SYSTEMD_DIR/x11vnc.service" + sudo apt remove -y x11vnc + log_success "VNC removed" +} + +################################################################################ +# WireGuard +################################################################################ + +wireguard_connected() { + command -v wg &>/dev/null && sudo wg show 2>/dev/null | grep -q interface +} + +wireguard_status() { + if wireguard_connected; then + echo "WireGuard: connected" + sudo wg show 2>/dev/null | grep -E "interface:|endpoint:|allowed ips:" | sed 's/^/ /' || true + elif command -v wg &>/dev/null; then + echo "WireGuard: installed, not connected" + else + echo "WireGuard: not installed" + fi +} + +wireguard_menu_builder() { + if wireguard_connected; then + MENU_LABELS=("Show full config" "Paste new config" "Uninstall") + MENU_HANDLERS=(action_wireguard_show_config action_wireguard_paste_config action_wireguard_uninstall) + elif command -v wg &>/dev/null; then + MENU_LABELS=("Paste config" "Uninstall") + MENU_HANDLERS=(action_wireguard_paste_config action_wireguard_uninstall) + else + MENU_LABELS=("Install WireGuard") + MENU_HANDLERS=(action_wireguard_install) + fi +} + +wireguard_menu() { + run_menu "WIREGUARD VPN" wireguard_menu_builder wireguard_status +} + +action_wireguard_install() { + echo + ask_yes_no "Install WireGuard?" "n" || { echo "Cancelled"; return; } + + if ! sudo apt install -y wireguard wireguard-tools; then + log_error "WireGuard installation failed" + return 1 + fi + log_success "WireGuard installed" + + echo + if ask_yes_no "Paste a config now?" "n"; then + action_wireguard_paste_config + fi +} + +action_wireguard_show_config() { + echo + sudo wg show all +} + +# Reads a WireGuard config from stdin until EOF (Ctrl+D on a real +# terminal) - same as the legacy addon. Writes to $WIREGUARD_DIR rather +# than a hardcoded /etc/wireguard, so tests can point it at scratch space +# and verify the written content without touching the real directory. +action_wireguard_paste_config() { + echo + echo "Paste your WireGuard config (Ctrl+D when done):" + local config + config=$(cat) + + if [[ -z "$config" ]]; then + log_error "No config provided" + return 1 + fi + + local wg_name + wg_name=$(ask_text "Config name" "wg0") + + sudo mkdir -p "$WIREGUARD_DIR" + echo "$config" | sudo tee "$WIREGUARD_DIR/${wg_name}.conf" > /dev/null + sudo chmod 600 "$WIREGUARD_DIR/${wg_name}.conf" + + if enable_and_start_units "wg-quick@${wg_name}"; then + log_success "WireGuard configured: $wg_name" + else + log_warning "Config written, but systemctl enable/start failed - check 'systemctl status wg-quick@${wg_name}'" + fi +} + +action_wireguard_uninstall() { + echo + ask_yes_no "Remove WireGuard?" "n" || { echo "Cancelled"; return; } + + sudo systemctl stop 'wg-quick@*' 2>/dev/null || true + sudo systemctl disable 'wg-quick@*' 2>/dev/null || true + sudo apt remove -y wireguard wireguard-tools + log_success "WireGuard removed" +} + +################################################################################ +# Tailscale +################################################################################ + +tailscale_backend_state() { + tailscale status --json 2>/dev/null | jq -r '.BackendState // "unknown"' 2>/dev/null || echo "unknown" +} + +tailscale_status() { + if ! command -v tailscale &>/dev/null; then + echo "Tailscale: not installed" + return + fi + + if [[ "$(tailscale_backend_state)" == "Running" ]]; then + echo "Tailscale: connected" + echo " Hostname: $(tailscale status --json 2>/dev/null | jq -r '.Self.HostName // "unknown"')" + echo " IP: $(tailscale ip -4 2>/dev/null)" + else + echo "Tailscale: installed, not connected" + fi +} + +tailscale_menu_builder() { + if command -v tailscale &>/dev/null; then + MENU_LABELS=("Connect (interactive)" "Connect with auth key" "Show status" "Uninstall") + MENU_HANDLERS=(action_tailscale_connect_interactive action_tailscale_connect_authkey action_tailscale_show_status action_tailscale_uninstall) + else + MENU_LABELS=("Install Tailscale") + MENU_HANDLERS=(action_tailscale_install) + fi +} + +tailscale_menu() { + run_menu "TAILSCALE VPN" tailscale_menu_builder tailscale_status +} + +action_tailscale_install() { + echo + ask_yes_no "Install Tailscale?" "n" || { echo "Cancelled"; return; } + + if ! curl -fsSL https://tailscale.com/install.sh | sh; then + log_error "Tailscale installation failed" + return 1 + fi + log_success "Tailscale installed" + + echo + echo "Options:" + echo " 1. Connect now (interactive)" + echo " 2. Connect with auth key" + echo " 3. Connect later" + local choice + choice=$(ask_integer "Choose" "3" 1 3) + case "$choice" in + 1) action_tailscale_connect_interactive ;; + 2) action_tailscale_connect_authkey ;; + esac +} + +action_tailscale_connect_interactive() { + echo + if sudo tailscale up; then + log_success "Tailscale connected" + else + log_error "Tailscale connection failed" + fi +} + +action_tailscale_connect_authkey() { + echo + echo "Get an auth key from: https://login.tailscale.com/admin/settings/keys" + local authkey + read -r -p "Enter auth key: " authkey + if [[ -z "$authkey" ]]; then + echo "Cancelled" + return + fi + + if sudo tailscale up --authkey="$authkey"; then + log_success "Tailscale connected" + else + log_error "Tailscale connection failed" + fi +} + +action_tailscale_show_status() { + echo + tailscale status +} + +action_tailscale_uninstall() { + echo + ask_yes_no "Remove Tailscale?" "n" || { echo "Cancelled"; return; } + + sudo tailscale down 2>/dev/null || true + sudo apt remove -y tailscale + log_success "Tailscale removed" +} + +################################################################################ +# Netbird +################################################################################ + +netbird_connected() { + [[ "$(netbird status 2>/dev/null | grep "Status:" | awk '{print $2}')" == "Connected" ]] +} + +netbird_status() { + if ! command -v netbird &>/dev/null; then + echo "Netbird: not installed" + return + fi + + if netbird_connected; then + echo "Netbird: connected" + netbird status 2>/dev/null | grep -E "NetBird IP:|Public key:" | sed 's/^/ /' || true + else + echo "Netbird: installed, not connected" + fi +} + +netbird_menu_builder() { + if command -v netbird &>/dev/null; then + MENU_LABELS=("Connect with setup key" "Show status" "Uninstall") + MENU_HANDLERS=(action_netbird_connect action_netbird_show_status action_netbird_uninstall) + else + MENU_LABELS=("Install Netbird") + MENU_HANDLERS=(action_netbird_install) + fi +} + +netbird_menu() { + run_menu "NETBIRD VPN" netbird_menu_builder netbird_status +} + +action_netbird_install() { + echo + ask_yes_no "Install Netbird?" "n" || { echo "Cancelled"; return; } + + if ! curl -fsSL https://pkgs.netbird.io/install.sh | sh; then + log_error "Netbird installation failed" + return 1 + fi + log_success "Netbird installed" + + echo + if ask_yes_no "Connect with a setup key now?" "n"; then + action_netbird_connect + fi +} + +action_netbird_connect() { + echo + echo "Get a setup key from the Netbird dashboard" + local setup_key + read -r -p "Enter setup key: " setup_key + if [[ -z "$setup_key" ]]; then + echo "Cancelled" + return + fi + + if sudo netbird up --setup-key "$setup_key"; then + log_success "Netbird connected" + else + log_error "Netbird connection failed" + fi +} + +action_netbird_show_status() { + echo + netbird status +} + +action_netbird_uninstall() { + echo + ask_yes_no "Remove Netbird?" "n" || { echo "Cancelled"; return; } + + sudo netbird down 2>/dev/null || true + sudo apt remove -y netbird + log_success "Netbird removed" +} diff --git a/menus/power_schedule.sh b/menus/power_schedule.sh index 80832f4..eb9cc03 100644 --- a/menus/power_schedule.sh +++ b/menus/power_schedule.sh @@ -46,17 +46,6 @@ timer_oncalendar() { grep "^OnCalendar=" "$SYSTEMD_DIR/$1" 2>/dev/null | cut -d'=' -f2 | sed 's/\*-\*-\* //' | sed 's/:00$//' } -# enable_and_start_timers TIMER [TIMER...] -# Reloads systemd and enables+starts the given timer units, returning -# non-zero if enable or start fails (e.g. systemd/D-Bus unreachable). -# Always call this from an `if`/`&&`/`||` context: this whole tool runs -# under set -e, so a bare, unguarded call whose last command fails would -# take down the entire session instead of just this one action. -enable_and_start_timers() { - sudo systemctl daemon-reload 2>/dev/null || true - sudo systemctl enable "$@" 2>/dev/null && sudo systemctl start "$@" 2>/dev/null -} - ################################################################################ # Top-level menu ################################################################################ @@ -66,7 +55,7 @@ power_schedule_status() { if timer_exists kiosk-shutdown.timer; then any=true - local t; t=$(timer_oncalendar kiosk-shutdown.timer) + local t; t=$(timer_oncalendar kiosk-shutdown.timer) || true echo "Power: shutdown daily at ${t:-an unknown time}" fi if timer_exists kiosk-display-off.timer; then @@ -205,7 +194,7 @@ EOF log_info "RTC wake cron job created" fi - if enable_and_start_timers kiosk-shutdown.timer; then + if enable_and_start_units kiosk-shutdown.timer; then log_success "Power schedule configured: shutdown at ${shutdown_time}$( [[ -n "$wake_time" ]] && echo ", wake at ${wake_time}")" else log_warning "Schedule files written, but systemctl enable/start failed - check 'systemctl status kiosk-shutdown.timer'" @@ -342,7 +331,7 @@ Persistent=true WantedBy=timers.target EOF - if enable_and_start_timers kiosk-display-off.timer kiosk-display-on.timer; then + if enable_and_start_units kiosk-display-off.timer kiosk-display-on.timer; then log_success "Display schedule configured: off at ${doff}, on at ${don}" else log_warning "Schedule files written, but systemctl enable/start failed - check 'systemctl status kiosk-display-off.timer'" @@ -476,7 +465,7 @@ EOF local mode_label="All audio muted" [[ "$qmode" == "2" ]] && mode_label="Squeezelite stopped" - if enable_and_start_timers kiosk-quiet-start.timer kiosk-quiet-end.timer; then + if enable_and_start_units kiosk-quiet-start.timer kiosk-quiet-end.timer; then log_success "Quiet hours configured: ${qstart} to ${qend} (${mode_label})" else log_warning "Schedule files written, but systemctl enable/start failed - check 'systemctl status kiosk-quiet-start.timer'" @@ -561,7 +550,7 @@ Persistent=true WantedBy=timers.target EOF - if enable_and_start_timers kiosk-electron-reload.timer; then + if enable_and_start_units kiosk-electron-reload.timer; then log_success "Electron reload configured: $description" else log_warning "Schedule files written, but systemctl enable/start failed - check 'systemctl status kiosk-electron-reload.timer'" diff --git a/ubuntu-based-kiosk.sh b/ubuntu-based-kiosk.sh index 35b03fa..51b79a8 100644 --- a/ubuntu-based-kiosk.sh +++ b/ubuntu-based-kiosk.sh @@ -1,8 +1,40 @@ #!/bin/bash ################################################################################ -### Ubuntu Based Kiosk v2.7.0 ### +### Ubuntu Based Kiosk v2.8.0 ### ################################################################################ # +# RELEASE v2.8.0 - Remote Access Migrated (VNC/WireGuard/Tailscale/ +# Netbird); Framework-Level Status-Function Crash Fixed +# - New in ./install.sh: Remote Access (menus/addon_remote_access.sh) - +# VNC (x11vnc), WireGuard, Tailscale, and Netbird, each with its own +# install/connect/status/uninstall flow. The biggest Addon migrated so +# far (4 sub-areas). Tailscale and Netbird install via the vendors' +# own documented `curl -fsSL | sh` method, preserved as-is. +# - New $WIREGUARD_DIR (lib/config.sh), same pattern as $SYSTEMD_DIR +# etc - nothing here hardcodes /etc/wireguard. +# - Promoted power_schedule.sh's enable_and_start_timers() to a shared +# enable_and_start_units() in lib/menu.sh (works for services now too, +# not just timers) - Remote Access needed the identical pattern for +# x11vnc and wg-quick@, so this is now fixed and reusable everywhere +# instead of being duplicated a second time. +# - IMPORTANT framework-level bug found and fixed in lib/menu.sh's +# run_menu(): the *handler* call has been `|| true`-guarded since +# v2.1.0, but the *status function* call was still bare and completely +# unprotected. A status function's job is read-only display, but if +# one contains so much as a pipeline whose grep matches nothing (which +# pipefail turns into a pipeline failure even though the actual last +# command in it succeeds), that bare call would crash the *entire +# session* - not just fail to show status. Found while writing +# wireguard_status()'s `sudo wg show | grep ... | sed ...` and +# confirming its exact failure mode before assuming it was already +# covered. Fixed once in run_menu() itself, protecting every status +# function across every menu, present and future - same "fix once at +# the framework level" pattern as the v2.1.0 handler fix. Also audited +# every existing status function across all menus for the same +# specific shape (a bare `var=$(...)` assignment from a grep-based +# pipeline, not embedded in an echo and not already guarded) and found +# one real instance in power_schedule_status(), now fixed too. +# # RELEASE v2.7.0 - Backported Fix: save_config() No Longer Deletes # Authelia Credentials (or Any Other Untracked Field) # - This script's own save_config() had the exact bug described under @@ -288,7 +320,7 @@ set -euo pipefail ### SECTION 1: CONSTANTS & GLOBALS ################################################################################ -SCRIPT_VERSION="2.7.0" +SCRIPT_VERSION="2.8.0" # Resolve the real path to this script file. # When piped (curl|bash or wget|bash), BASH_SOURCE[0] is a pipe descriptor, From 2bf4efe2b178385aeaf4747e53904b70a5137d43 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 02:11:05 +0000 Subject: [PATCH 11/19] 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 Claude-Session: https://claude.ai/code/session_01VfsFSoRqfbRG7XAg5RoE7e --- Readme.md | 45 +++-- install.sh | 9 +- lib/config.sh | 14 ++ menus/addon_lms_squeezelite.sh | 344 +++++++++++++++++++++++++++++++++ ubuntu-based-kiosk.sh | 47 ++++- 5 files changed, 437 insertions(+), 22 deletions(-) create mode 100644 menus/addon_lms_squeezelite.sh diff --git a/Readme.md b/Readme.md index 20f7274..f0c2338 100644 --- a/Readme.md +++ b/Readme.md @@ -1,6 +1,6 @@ # Ubuntu Based Kiosk -**Current Version:** 2.8.0 (check script header for latest version) +**Current Version:** 2.9.0 (check script header for latest version) **Built with Claude Sonnet 4.6 AI assistance** **License:** GPL v3 - Keep derivatives open source **Repository:** https://github.com/outis1one/ubuntu-based-kiosk/ @@ -1214,6 +1214,13 @@ terminal menu and the web UI, so they can't drift apart). Prompted the `save_config` merge fix above. - `menus/addon_remote_access.sh` — **Remote Access** (Addons): VNC, WireGuard, Tailscale, Netbird. The biggest Addon so far. +- `menus/addon_lms_squeezelite.sh` — **LMS Server / Squeezelite Player** + (Addons): install/reconfigure/uninstall for an LMS (Lyrion/Logitech + Media Server) server the kiosk can host, and a Squeezelite player the + kiosk can run against any LMS server on the LAN. Squeezelite's own + start script and systemd unit go through `$BIN_DIR`/`$SYSTEMD_DIR` + like every other addon; LMS's own apt repo/GPG key/ufw rules stay at + their real fixed system paths, same as CUPS. - `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: @@ -1226,14 +1233,25 @@ terminal menu and the web UI, so they can't drift apart). **Honest status:** this does not yet replace first-time installation, or most of the old installer. `ubuntu-based-kiosk.sh` is still ~12,000 lines and still contains its own unremoved, unmodified copies of every -menu above (plus Upgrade, Reinstall, Uninstall, 2 more Addons, and the -other 8 Advanced items — none of that has moved yet). 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. +menu above (plus Upgrade, Reinstall, Uninstall, 1 more Addon — Easy +Asterisk Intercom — and the other 8 Advanced items — none of that has +moved yet). 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. + +**Resolved (v2.9.0):** `is_service_enabled()` — shared by both scripts +— had a pre-check (`systemctl list-unit-files | grep -q "^${service}\s"`) +that never actually matched, since every call site passes a bare +service name while `list-unit-files` lines start with +`"$service.service"`. The function always fell through to `return 1` +regardless of the real enabled state — under-reporting "enabled but not +currently running" as "not installed" everywhere it's used, including +LMS/Squeezelite's own status detection. Fixed in both `lib/config.sh` +and `ubuntu-based-kiosk.sh` by dropping the dead pre-check — +`systemctl is-enabled` already reports "not found" as a failure on its +own. **Resolved (v2.7.0):** the config-clobbering bug fixed in `lib/config.sh` (v2.6.0 — `save_config` silently deleting fields it doesn't know about, @@ -1248,9 +1266,14 @@ full migration pass. ## Project Status & Future Plans -**Current Version:** 2.8.0 +**Current Version:** 2.9.0 -**Recent Updates (v2.8.0):** +**Recent Updates (v2.9.0):** +- **LMS Server / Squeezelite Player migrated** — install/reconfigure/uninstall for both, in `./install.sh`. 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`/`/etc/systemd/system`; LMS's own apt repo/GPG key/ufw rules stay at their real fixed system paths, same approach as CUPS. +- **Bug fix:** the legacy `install_lms()` enabled/started the detected service via `sudo systemctl enable "$service_name" 2>&1 | tee /tmp/lms-enable.log` — piped through `tee`, the statement's exit status reflected `tee` (always 0), not `systemctl enable`, so a real enable/start failure was silently swallowed instead of falling through to a warning. Now uses the shared `enable_and_start_units()` helper. +- **Bug fix (shared, backported to the legacy script too):** `is_service_enabled()`'s pre-check never matched a bare service name against `list-unit-files`' `"$service.service"` lines, so it always reported "not enabled" regardless of the real state. Dropped the dead pre-check — see "Modular Management" below. + +**Previous (v2.8.0):** - **Remote Access migrated** — VNC, WireGuard, Tailscale, and Netbird, each with its own install/connect/status/uninstall flow. The biggest Addon so far. Tailscale/Netbird install via the vendors' own `curl | sh` method, preserved as-is. - **Important framework-level bug found and fixed:** `run_menu()`'s *handler* call has been crash-guarded since v2.1.0, but its *status function* call was still completely bare. A status function is meant to be read-only display, but a pipeline whose `grep` matches nothing (which `pipefail` turns into a failure even though the actual last command succeeds) would crash the **entire session**, not just fail to show status. Found while building `wireguard_status()` and verifying its exact failure mode rather than assuming it was covered. Fixed once, in the framework, protecting every status function across every menu — present and future. Also audited every existing status function for the same shape and fixed one real instance in `power_schedule_status()`. - Deduplicated: promoted `power_schedule.sh`'s `enable_and_start_timers()` to a shared `enable_and_start_units()` in `lib/menu.sh` (works for services now, not just timers) rather than writing the same helper a second time for VNC/WireGuard. diff --git a/install.sh b/install.sh index 3d38723..73f9a03 100755 --- a/install.sh +++ b/install.sh @@ -18,7 +18,8 @@ # Power/Display/Quiet Hours. # 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). +# Tailscale/Netbird (menus/addon_remote_access.sh), LMS Server / +# Squeezelite Player (menus/addon_lms_squeezelite.sh). # Advanced: Diagnostics (menus/diagnostics.sh - system status/logs/ # audio/network). # @@ -58,6 +59,8 @@ source "$SCRIPT_DIR/menus/addon_cups.sh" source "$SCRIPT_DIR/menus/addon_authelia.sh" # shellcheck source=menus/addon_remote_access.sh source "$SCRIPT_DIR/menus/addon_remote_access.sh" +# shellcheck source=menus/addon_lms_squeezelite.sh +source "$SCRIPT_DIR/menus/addon_lms_squeezelite.sh" ################################################################################ # Preflight @@ -121,8 +124,8 @@ core_settings_menu() { } addons_menu_builder() { - MENU_LABELS=("CUPS Printing" "Authelia Auto-Login" "Remote Access") - MENU_HANDLERS=(addon_cups_menu addon_authelia_menu remote_access_menu) + MENU_LABELS=("CUPS Printing" "Authelia Auto-Login" "Remote Access" "LMS Server / Squeezelite Player") + MENU_HANDLERS=(addon_cups_menu addon_authelia_menu remote_access_menu addon_lms_squeezelite_menu) } addons_menu() { diff --git a/lib/config.sh b/lib/config.sh index e6dea50..28fd411 100644 --- a/lib/config.sh +++ b/lib/config.sh @@ -78,6 +78,20 @@ is_service_active() { systemctl is-active --quiet "$service" 2>/dev/null } +# Whether a service is enabled (would start on boot), regardless of +# whether it's currently running. The legacy script's version of this +# pre-checked `systemctl list-unit-files | grep -q "^${service}\s"` +# before calling is-enabled - but every call site passes a bare service +# name (e.g. "squeezelite"), while list-unit-files lines start with +# "squeezelite.service", so that regex never matched and the legacy +# function always fell through to `return 1` no matter the real state. +# `systemctl is-enabled` already reports "not found" as a failure on its +# own, so the pre-check was both broken and unnecessary - dropped here. +is_service_enabled() { + local service="$1" + systemctl is-enabled --quiet "$service" 2>/dev/null +} + # Load every setting config.json has into the bash globals above. # Safe to call with no existing config file - leaves script defaults in place. load_existing_config() { diff --git a/menus/addon_lms_squeezelite.sh b/menus/addon_lms_squeezelite.sh new file mode 100644 index 0000000..43ef145 --- /dev/null +++ b/menus/addon_lms_squeezelite.sh @@ -0,0 +1,344 @@ +#!/bin/bash +################################################################################ +# menus/addon_lms_squeezelite.sh - "LMS Server / Squeezelite Player" addon. +# +# Two independent pieces sharing one menu, same as the legacy code: an LMS +# (Lyrion/Logitech Media Server) server the kiosk can host, and a +# Squeezelite player the kiosk can run to play music from any LMS server +# (this one or another one on the LAN). LMS itself is a real apt-managed +# subsystem with its own fixed paths (repo file, GPG keyring, ufw rules, +# /etc/squeezeboxserver) - like CUPS, those get full command-level `sudo`/ +# `wget`/`apt` stubbing in tests rather than relocation. Squeezelite's own +# start script and systemd unit are ours to place, so - like power_schedule +# and the other addons - they go through $BIN_DIR/$SYSTEMD_DIR (lib/ +# config.sh) instead of hardcoded /usr/local/bin and /etc/systemd/system, +# so tests can point them at a scratch directory. +# +# LMS ships under two package/service names depending on version - +# "logitechmediaserver" (older) and "lyrionmusicserver" (the project's +# current name after its rename) - so detection and every service call +# has to check both. +# +# Depends on: lib/menu.sh, lib/config.sh being sourced first. +################################################################################ + +lms_service_name() { + if systemctl list-unit-files 2>/dev/null | grep -q "lyrionmusicserver.service"; then + echo "lyrionmusicserver" + elif systemctl list-unit-files 2>/dev/null | grep -q "logitechmediaserver.service"; then + echo "logitechmediaserver" + fi +} + +lms_is_installed() { + is_service_active logitechmediaserver || is_service_enabled logitechmediaserver || \ + is_service_active lyrionmusicserver || is_service_enabled lyrionmusicserver +} + +lms_is_running() { + is_service_active logitechmediaserver || is_service_active lyrionmusicserver +} + +squeezelite_is_installed() { + is_service_active squeezelite || is_service_enabled squeezelite +} + +addon_lms_squeezelite_status() { + if lms_is_installed; then + echo "LMS Server: Installed" + if lms_is_running; then + echo " Status: Running" + else + echo " Status: Stopped" + fi + echo " Web: http://$(get_ip_address):9000" + echo + fi + + if squeezelite_is_installed; then + local player_name="Unknown" + if [[ -f "$BIN_DIR/squeezelite-start.sh" ]]; then + player_name=$(grep '^PLAYER_NAME=' "$BIN_DIR/squeezelite-start.sh" 2>/dev/null | cut -d'=' -f2 | tr -d '"' || echo "Unknown") + fi + echo "Squeezelite Player: Installed" + if is_service_active squeezelite; then + echo " Status: Running" + else + echo " Status: Stopped" + fi + echo " Name: $player_name" + echo + fi + + echo "ℹ A server is needed to stream music. The kiosk can run the" + echo " server (if sufficient resources) or connect to another server." +} + +addon_lms_squeezelite_menu_builder() { + MENU_LABELS=("Install/Configure LMS Server" "Install/Configure Squeezelite Player") + MENU_HANDLERS=(action_install_lms action_install_squeezelite) + + if lms_is_installed; then + MENU_LABELS+=("Uninstall LMS Server") + MENU_HANDLERS+=(action_uninstall_lms) + fi + + if squeezelite_is_installed; then + MENU_LABELS+=("Uninstall Squeezelite Player") + MENU_HANDLERS+=(action_uninstall_squeezelite) + fi +} + +addon_lms_squeezelite_menu() { + run_menu "LMS SERVER / SQUEEZELITE PLAYER" addon_lms_squeezelite_menu_builder addon_lms_squeezelite_status +} + +################################################################################ +# Actions - LMS Server +################################################################################ + +action_install_lms() { + echo + if lms_is_installed; then + echo "LMS is already installed." + if ask_yes_no "Reconfigure port?" "n"; then + local new_port + new_port=$(ask_integer "New HTTP port" 9000 1 65535) + sudo sed -i "s/httpport:.*/httpport: $new_port/" /etc/squeezeboxserver/prefs/server.prefs 2>/dev/null || true + sudo systemctl restart lyrionmusicserver 2>/dev/null || sudo systemctl restart logitechmediaserver 2>/dev/null || true + log_success "LMS reconfigured on port $new_port" + fi + pause + return + fi + + echo "Installing Lyrion Music Server..." + + # Try the repository method first. + if wget -qO - https://debian.slimdevices.com/debian/squeezebox-keyring.gpg | sudo gpg --dearmor -o /usr/share/keyrings/lms-keyring.gpg 2>/dev/null; then + echo "deb [signed-by=/usr/share/keyrings/lms-keyring.gpg] http://debian.slimdevices.com/debian stable main" | sudo tee /etc/apt/sources.list.d/lms.list + # `|| true`: a bare, unguarded `apt update` failing here (bad + # mirror, no network) would otherwise crash the whole session + # under set -e instead of falling through to the direct-download + # fallback below, which is exactly the degrade path this is + # supposed to hit when the repository route doesn't work. + sudo apt update 2>/dev/null || true + if sudo apt install -y logitechmediaserver 2>/dev/null; then + log_success "LMS installed via repository" + else + log_warning "Repository install failed, trying direct download..." + fi + fi + + # Fall back to a direct .deb download if the repository didn't produce + # either possible package. + if ! command -v logitechmediaserver &>/dev/null && ! command -v lyrionmusicserver &>/dev/null; then + local lms_deb="/tmp/lms.deb" + echo "Downloading LMS v9.0.3..." + if wget -q https://downloads.lms-community.org/LyrionMusicServer_v9.0.3/lyrionmusicserver_9.0.3_amd64.deb -O "$lms_deb"; then + echo "Installing LMS package..." + if sudo apt install -y "$lms_deb"; then + log_success "LMS installed via direct download" + else + log_error "Failed to install LMS package" + rm -f "$lms_deb" + pause + return 1 + fi + rm -f "$lms_deb" + else + log_error "Failed to download LMS from lms-community.org" + pause + return 1 + fi + fi + + local service_name + service_name=$(lms_service_name) + + if [[ -z "$service_name" ]]; then + log_warning "Service file not found, checking installed files..." + service_name=$(dpkg -L lyrionmusicserver logitechmediaserver 2>/dev/null | grep -m1 '\.service$' | xargs -r basename | sed 's/\.service$//' || echo "") + fi + + if [[ -z "$service_name" ]]; then + log_error "Could not detect LMS service name" + echo "Manual steps:" + echo " 1. Find service: systemctl list-unit-files | grep -i lms" + echo " 2. Enable: sudo systemctl enable SERVICE_NAME" + echo " 3. Start: sudo systemctl start SERVICE_NAME" + pause + return 1 + fi + + log_info "Using service: $service_name" + # enable_and_start_units, not a bare `sudo systemctl enable ... | tee` + # pipe: the legacy version's `2>&1 | tee /tmp/lms-enable.log` made the + # whole statement's exit status depend on `tee` (always 0) rather than + # `systemctl enable`, so a real enable/start failure was silently + # swallowed instead of falling through to the warning below. + if enable_and_start_units "$service_name"; then + sudo ufw allow 9000/tcp comment 'LMS-HTTP' 2>/dev/null || true + sudo ufw allow 3483/tcp comment 'LMS-SlimProto' 2>/dev/null || true + sudo ufw allow 3483/udp comment 'LMS-Discovery' 2>/dev/null || true + + log_success "LMS installed" + echo " Web interface: http://$(get_ip_address):9000" + else + log_warning "LMS installed, but systemctl enable/start failed - check 'systemctl status $service_name'" + fi + + pause +} + +action_uninstall_lms() { + echo + ask_yes_no "Remove LMS Server?" "n" || { echo "Cancelled"; pause; return; } + + local service_name + service_name=$(lms_service_name) + + if [[ -n "$service_name" ]]; then + echo "Stopping $service_name..." + sudo systemctl stop "$service_name" 2>/dev/null || true + sudo systemctl disable "$service_name" 2>/dev/null || true + fi + + # Try to remove both possible package names - only one will actually + # be installed, the other is a harmless no-op. + sudo apt remove -y lyrionmusicserver 2>/dev/null || true + sudo apt remove -y logitechmediaserver 2>/dev/null || true + + sudo rm -f /etc/apt/sources.list.d/lms.list + sudo rm -f /usr/share/keyrings/lms-keyring.gpg + + if ask_yes_no "Remove LMS data and configuration?" "n"; then + sudo rm -rf /var/lib/squeezeboxserver + sudo rm -rf /etc/squeezeboxserver + log_success "LMS and data removed" + else + log_success "LMS removed (data preserved)" + fi + + pause +} + +################################################################################ +# Actions - Squeezelite Player +################################################################################ + +action_install_squeezelite() { + echo + if squeezelite_is_installed; then + echo "Squeezelite is already installed." + ask_yes_no "Reconfigure?" "n" || { pause; return; } + fi + + if ! command -v squeezelite &>/dev/null; then + if ! sudo apt install -y squeezelite; then + log_error "squeezelite package installation failed" + pause + return 1 + fi + fi + + local player_name + player_name=$(ask_text "Player name" "Kiosk") + + echo + echo "LMS Server Configuration:" + echo " Enter IP:PORT of your LMS server" + echo " Leave blank for auto-discovery on LAN" + echo + local lms_server + lms_server=$(ask_text "LMS Server (e.g., 192.168.1.100:3483)" "") + + sudo tee "$BIN_DIR/squeezelite-start.sh" > /dev/null </dev/null 2>&1 && break + sleep 1 +done + +if ! pactl info >/dev/null 2>&1; then + logger "ERROR: Squeezelite - PipeWire not available" + exit 1 +fi + +if [[ -n "\$LMS_SERVER" ]]; then + exec /usr/bin/squeezelite -n "\$PLAYER_NAME" -s "\$LMS_SERVER" -o pulse -a 80:4:: -b 512:1024 -C 5 +else + exec /usr/bin/squeezelite -n "\$PLAYER_NAME" -o pulse -a 80:4:: -b 512:1024 -C 5 +fi +SQSTART + + sudo chmod +x "$BIN_DIR/squeezelite-start.sh" + + local kiosk_uid + kiosk_uid=$(id -u "$KIOSK_USER") + + sudo tee "$SYSTEMD_DIR/squeezelite.service" > /dev/null </dev/null || true + # Enable only, not start: squeezelite needs the kiosk user's real + # session (PipeWire, XDG_RUNTIME_DIR) up first, which is why a reboot + # is required below rather than starting it immediately. + if ! sudo systemctl enable squeezelite 2>/dev/null; then + log_warning "Squeezelite files written, but 'systemctl enable' failed - check 'systemctl status squeezelite'" + fi + + log_success "Squeezelite installed: $player_name" + if [[ -n "$lms_server" ]]; then + echo " Server: $lms_server" + else + echo " Server: Auto-discovery" + fi + echo + echo "⚠️ IMPORTANT: Squeezelite requires a reboot to work properly" + echo + if ask_yes_no "Reboot now?" "n"; then + echo "Rebooting in 5 seconds..." + sleep 5 + sudo reboot + else + echo "⚠️ Remember to reboot before using Squeezelite" + echo " Command: sudo reboot" + fi + + pause +} + +action_uninstall_squeezelite() { + echo + ask_yes_no "Remove Squeezelite Player?" "n" || { echo "Cancelled"; pause; return; } + + sudo systemctl stop squeezelite 2>/dev/null || true + sudo systemctl disable squeezelite 2>/dev/null || true + sudo rm -f "$SYSTEMD_DIR/squeezelite.service" + sudo rm -f "$BIN_DIR/squeezelite-start.sh" + sudo apt remove -y squeezelite 2>/dev/null || true + log_success "Squeezelite removed" + + pause +} diff --git a/ubuntu-based-kiosk.sh b/ubuntu-based-kiosk.sh index 51b79a8..1f41281 100644 --- a/ubuntu-based-kiosk.sh +++ b/ubuntu-based-kiosk.sh @@ -1,8 +1,37 @@ #!/bin/bash ################################################################################ -### Ubuntu Based Kiosk v2.8.0 ### +### Ubuntu Based Kiosk v2.9.0 ### ################################################################################ # +# RELEASE v2.9.0 - LMS Server / Squeezelite Player Migrated; +# is_service_enabled() Dead Pre-Check Fixed +# - New in ./install.sh: LMS Server / Squeezelite Player +# (menus/addon_lms_squeezelite.sh) - install/reconfigure/uninstall for +# an LMS (Lyrion/Logitech Media Server) server the kiosk can host, and +# a Squeezelite player the kiosk can run against any LMS server on the +# LAN. Squeezelite's own start script and systemd unit now go through +# $BIN_DIR/$SYSTEMD_DIR (lib/config.sh) instead of hardcoded +# /usr/local/bin and /etc/systemd/system, matching every other addon; +# LMS's own apt repo/GPG key/ufw rules stay at their real fixed system +# paths, same as CUPS. +# - Fixed a real unguarded-pipeline bug from the legacy install_lms(): +# `sudo systemctl enable "$service_name" 2>&1 | tee /tmp/lms-enable.log` +# made the whole statement's exit status depend on `tee` (always 0) +# instead of `systemctl enable`, so a real enable/start failure was +# silently swallowed rather than falling through to a warning. Now +# uses the shared enable_and_start_units() helper instead. +# - Fixed is_service_enabled() (shared by both scripts): its pre-check +# `systemctl list-unit-files | grep -q "^${service}\s"` never matched, +# since every call site passes a bare service name (e.g. +# "squeezelite") while list-unit-files lines start with +# "squeezelite.service" - so the function always fell through to +# `return 1` regardless of the real enabled state. `systemctl +# is-enabled` already reports "not found" as a failure on its own, so +# the dead pre-check is simply dropped. Backported here since it's the +# same shared function in both scripts and the fix is low-risk +# (behavior-preserving for every state except the one it was silently +# getting wrong). +# # RELEASE v2.8.0 - Remote Access Migrated (VNC/WireGuard/Tailscale/ # Netbird); Framework-Level Status-Function Crash Fixed # - New in ./install.sh: Remote Access (menus/addon_remote_access.sh) - @@ -320,7 +349,7 @@ set -euo pipefail ### SECTION 1: CONSTANTS & GLOBALS ################################################################################ -SCRIPT_VERSION="2.8.0" +SCRIPT_VERSION="2.9.0" # Resolve the real path to this script file. # When piped (curl|bash or wget|bash), BASH_SOURCE[0] is a pipe descriptor, @@ -578,12 +607,14 @@ is_service_active() { is_service_enabled() { local service="$1" - # Check if service file exists first - if systemctl list-unit-files 2>/dev/null | grep -q "^${service}\s"; then - systemctl is-enabled --quiet "$service" 2>/dev/null - else - return 1 - fi + # `systemctl is-enabled` already reports "not found" as a failure on + # its own - no need for (and no correct way to write, given every + # call site here passes a bare service name while list-unit-files + # lines start with "$service.service") a pre-check via + # list-unit-files. The previous "^${service}\s" pre-check never + # matched, so this function always fell through to `return 1` + # regardless of the real enabled state. + systemctl is-enabled --quiet "$service" 2>/dev/null } get_ip_address() { From 3eadcdb58472abe5cd7cbd0a87b80564b814bb03 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 02:28:05 +0000 Subject: [PATCH 12/19] 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 Claude-Session: https://claude.ai/code/session_01VfsFSoRqfbRG7XAg5RoE7e --- Readme.md | 120 +++++++------ install.sh | 9 +- menus/addon_asterisk_intercom.sh | 279 +++++++++++++++++++++++++++++++ ubuntu-based-kiosk.sh | 34 +++- 4 files changed, 390 insertions(+), 52 deletions(-) create mode 100644 menus/addon_asterisk_intercom.sh diff --git a/Readme.md b/Readme.md index f0c2338..037e68a 100644 --- a/Readme.md +++ b/Readme.md @@ -1,6 +1,6 @@ # Ubuntu Based Kiosk -**Current Version:** 2.9.0 (check script header for latest version) +**Current Version:** 2.10.0 (check script header for latest version) **Built with Claude Sonnet 4.6 AI assistance** **License:** GPL v3 - Keep derivatives open source **Repository:** https://github.com/outis1one/ubuntu-based-kiosk/ @@ -251,12 +251,20 @@ Both can be used at the same time — they serve different purposes: --- ### Communication -- **Easy Asterisk Intercom** - Voice communication and intercom system - - Downloads latest version from Easy Asterisk repository - - Automatic update detection and installation - - Configuration preservation during updates - - Full Asterisk PBX integration - - SIP/PJSIP support for IP phones and softphones +- **Asterisk Intercom** (`./install.sh` → Addons) - connects this kiosk + as a Baresip SIP extension to an Asterisk server you already have + running elsewhere; does not install or manage Asterisk itself + - Manual or auto-answer (intercom) mode + - Optional TLS/SRTP transport + - Uninstall support (with or without removing saved credentials) +- **Legacy Easy Asterisk Intercom** (`./ubuntu-based-kiosk.sh` → Addons, + not yet retired) - the original three-option version: Client Only + (same Baresip client as above), Server Only, or Full, where Server/ + Full download and run a third-party installer from a separate + "Easy Asterisk" repository to stand up a whole Asterisk PBX on this + device. That repository has since gone through a major rework + upstream, so the modular `./install.sh` version above only carries + the client/endpoint piece forward - see "Modular Management" below. ### Audio - **Lyrion Music Server (LMS)** - Formerly Logitech Media Server @@ -594,51 +602,55 @@ smb://WORKGROUP/COMPUTER/PrinterName # 4. Restart Kiosk Display ``` -### Installing Easy Asterisk Intercom +### Installing Asterisk Intercom -The Easy Asterisk Intercom addon provides voice communication capabilities to your kiosk system. +The Asterisk Intercom addon connects this kiosk as a SIP extension to an +Asterisk server you already have running elsewhere (your own PBX, a +Docker container, another box on the network - anywhere). It installs +and configures Baresip as that extension; it does not install or manage +Asterisk itself. **Access the addon menu:** ```bash -./ubuntu-based-kiosk.sh -# Select: 2) Addons -# Then: 4) Easy Asterisk Intercom +git clone https://github.com/outis1one/ubuntu-based-kiosk/ +cd ubuntu-based-kiosk +./install.sh +# Select: 2) Addons → Asterisk Intercom (SIP Extension) ``` -**Features:** -- **Automatic installation** - Downloads and installs the latest version from the Easy Asterisk repository -- **Update detection** - Checks for newer versions and prompts to update -- **Safe re-runs** - Can be run multiple times without breaking existing configurations -- **Config preservation** - Automatically backs up and restores configurations during updates -- **Full Asterisk PBX** - Complete telephony features including SIP, extensions, voicemail +**What you'll be asked for** (must match what's already configured on +the Asterisk server): server IP/hostname, SIP port (default 5060, or +5061 if you enable TLS), extension number, SIP password, and whether to +auto-answer incoming calls (intercom mode) or ring for manual answer. -**Installation behavior:** -- **First install:** Downloads latest version from https://github.com/outis1one/easy-asterisk -- **Already installed (latest):** Prompts to re-run installation (preserves configs) -- **Update available:** Prompts to update and shows version difference -- **All scenarios:** Configuration files in `/etc/asterisk/` and installation settings are preserved - -**Managing Easy Asterisk:** +**Managing the client:** ```bash -# Check installation status -systemctl status asterisk +# Check status (as the kiosk user) +sudo -u kiosk systemctl --user status baresip -# View Asterisk console -asterisk -rvvv +# Restart +sudo -u kiosk systemctl --user restart baresip -# Restart Asterisk -systemctl restart asterisk +# View logs +sudo -u kiosk journalctl --user -u baresip -f -# Configure intercom (rerun installation to update) -./ubuntu-based-kiosk.sh -# Select: 2) Addons → 4) Easy Asterisk Intercom +# Reconfigure or uninstall +./install.sh +# Select: 2) Addons → Asterisk Intercom (SIP Extension) ``` **Installation location:** -- Installation files: `/opt/easy-asterisk/` -- Configuration: `/etc/asterisk/` -- Version tracking: `/opt/easy-asterisk/.version` -- Config backups: `/opt/easy-asterisk/config_backup/` +- Baresip config: `~kiosk/.baresip/` (`accounts`, `config`) +- systemd user unit: `~kiosk/.config/systemd/user/baresip.service` + +**Not covered here:** standing up the Asterisk PBX server itself. The +legacy `ubuntu-based-kiosk.sh` still offers a Server/Full option that +downloads and runs a third-party installer from a separate "Easy +Asterisk" repository - that repository has since gone through a major +rework upstream, so it isn't carried forward into this addon. If you +need a PBX, set one up separately (that same legacy option, a +FreePBX/Issabel image, a Dockerized Asterisk, etc.) and point this +addon at it as a plain SIP extension. ### Updating Electron @@ -1221,6 +1233,12 @@ terminal menu and the web UI, so they can't drift apart). start script and systemd unit go through `$BIN_DIR`/`$SYSTEMD_DIR` like every other addon; LMS's own apt repo/GPG key/ufw rules stay at their real fixed system paths, same as CUPS. +- `menus/addon_asterisk_intercom.sh` — **Asterisk Intercom** (Addons): + installs Baresip and registers this kiosk as a SIP extension against + an Asterisk server you already have running elsewhere. Redesigned + during migration, not a straight port — see "Recent Updates (v2.10.0)" + below for why the legacy Server/Full PBX-install options didn't come + along. - `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: @@ -1233,13 +1251,15 @@ terminal menu and the web UI, so they can't drift apart). **Honest status:** this does not yet replace first-time installation, or most of the old installer. `ubuntu-based-kiosk.sh` is still ~12,000 lines and still contains its own unremoved, unmodified copies of every -menu above (plus Upgrade, Reinstall, Uninstall, 1 more Addon — Easy -Asterisk Intercom — and the other 8 Advanced items — none of that has -moved yet). 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. +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, Reinstall, Uninstall, and the other 8 +Advanced items — none of that has moved yet). 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. **Resolved (v2.9.0):** `is_service_enabled()` — shared by both scripts — had a pre-check (`systemctl list-unit-files | grep -q "^${service}\s"`) @@ -1266,9 +1286,15 @@ full migration pass. ## Project Status & Future Plans -**Current Version:** 2.9.0 +**Current Version:** 2.10.0 -**Recent Updates (v2.9.0):** +**Recent Updates (v2.10.0):** +- **Asterisk Intercom migrated, and redesigned in the process.** The legacy addon offered Client Only (Baresip SIP client), Server Only, and Full (server + client) — the latter two downloaded and ran a third-party installer from a separate "Easy Asterisk" repository to stand up a whole Asterisk PBX. That repository has since gone through a major rework upstream, so the PBX-install path is dropped entirely rather than carrying a dependency on code that's moved on without it. The migrated addon (`menus/addon_asterisk_intercom.sh`) now does only the client/endpoint piece: install Baresip and register this kiosk as one SIP extension against an Asterisk server you already have 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 dependency on the (now-reworked) Easy Asterisk repo's GitHub API for version tracking — reads the real installed `baresip` package version via `dpkg` instead. +- **New capability:** an uninstall option for the Baresip client — the legacy addon never had one. +- **Bug fix:** an unguarded `ver=$(baresip_installed_version)` assignment would have crashed the whole session the first time status was checked before Baresip was installed (`dpkg-query` legitimately fails when the package isn't there). Guarded with `|| true` before it shipped. + +**Previous (v2.9.0):** - **LMS Server / Squeezelite Player migrated** — install/reconfigure/uninstall for both, in `./install.sh`. 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`/`/etc/systemd/system`; LMS's own apt repo/GPG key/ufw rules stay at their real fixed system paths, same approach as CUPS. - **Bug fix:** the legacy `install_lms()` enabled/started the detected service via `sudo systemctl enable "$service_name" 2>&1 | tee /tmp/lms-enable.log` — piped through `tee`, the statement's exit status reflected `tee` (always 0), not `systemctl enable`, so a real enable/start failure was silently swallowed instead of falling through to a warning. Now uses the shared `enable_and_start_units()` helper. - **Bug fix (shared, backported to the legacy script too):** `is_service_enabled()`'s pre-check never matched a bare service name against `list-unit-files`' `"$service.service"` lines, so it always reported "not enabled" regardless of the real state. Dropped the dead pre-check — see "Modular Management" below. diff --git a/install.sh b/install.sh index 73f9a03..38f633c 100755 --- a/install.sh +++ b/install.sh @@ -19,7 +19,8 @@ # Addons: CUPS Printing (menus/addon_cups.sh), Authelia Auto-Login # (menus/addon_authelia.sh), Remote Access - VNC/WireGuard/ # Tailscale/Netbird (menus/addon_remote_access.sh), LMS Server / -# Squeezelite Player (menus/addon_lms_squeezelite.sh). +# Squeezelite Player (menus/addon_lms_squeezelite.sh), Asterisk +# Intercom - SIP extension client (menus/addon_asterisk_intercom.sh). # Advanced: Diagnostics (menus/diagnostics.sh - system status/logs/ # audio/network). # @@ -61,6 +62,8 @@ source "$SCRIPT_DIR/menus/addon_authelia.sh" source "$SCRIPT_DIR/menus/addon_remote_access.sh" # shellcheck source=menus/addon_lms_squeezelite.sh source "$SCRIPT_DIR/menus/addon_lms_squeezelite.sh" +# shellcheck source=menus/addon_asterisk_intercom.sh +source "$SCRIPT_DIR/menus/addon_asterisk_intercom.sh" ################################################################################ # Preflight @@ -124,8 +127,8 @@ core_settings_menu() { } addons_menu_builder() { - MENU_LABELS=("CUPS Printing" "Authelia Auto-Login" "Remote Access" "LMS Server / Squeezelite Player") - MENU_HANDLERS=(addon_cups_menu addon_authelia_menu remote_access_menu addon_lms_squeezelite_menu) + MENU_LABELS=("CUPS Printing" "Authelia Auto-Login" "Remote Access" "LMS Server / Squeezelite Player" "Asterisk Intercom (SIP Extension)") + MENU_HANDLERS=(addon_cups_menu addon_authelia_menu remote_access_menu addon_lms_squeezelite_menu addon_asterisk_intercom_menu) } addons_menu() { diff --git a/menus/addon_asterisk_intercom.sh b/menus/addon_asterisk_intercom.sh new file mode 100644 index 0000000..bb12a6a --- /dev/null +++ b/menus/addon_asterisk_intercom.sh @@ -0,0 +1,279 @@ +#!/bin/bash +################################################################################ +# menus/addon_asterisk_intercom.sh - "Asterisk Intercom" addon: connect the +# kiosk as a SIP extension to an *existing* Asterisk server. +# +# The legacy addon offered three options: Client Only (a Baresip SIP +# client - what this file is), Server Only, and Full (server + client). +# Server/Full downloaded and ran a third-party installer from a separate +# "Easy Asterisk" repository to stand up a whole Asterisk PBX. That +# repository has since gone through a major rework upstream, so wiring a +# full PBX install through it here no longer makes sense to maintain - +# and most kiosk deployments don't need this device to *be* the PBX +# anyway. This addon now does only the client/endpoint piece: install +# Baresip and register it as one extension against an Asterisk server +# the user already has running somewhere else. It never installs or +# manages Asterisk itself. +# +# Two other things fixed while narrowing the scope: +# - The legacy client path tracked its own version by calling out to the +# (now-reworked) Easy Asterisk repo's GitHub API and stamping a +# "-client" string in a side file. That coupling is +# exactly what's being dropped, so version tracking now just reads the +# real installed `baresip` package version via dpkg - one less network +# dependency and one less thing to keep in sync with an external repo. +# - The legacy addon had no uninstall option for the client at all - +# added below. +# +# Real system state: apt package, a per-user config directory under +# $KIOSK_HOME, and a systemd --user unit for $KIOSK_USER (not a system +# service - Baresip needs the desktop session's PulseAudio/PipeWire +# socket). Every write goes through `sudo`/`sudo -u "$KIOSK_USER"`, all +# stubbed at the command level in tests - there's no real D-Bus user +# session to target in a test container regardless. +# +# Depends on: lib/menu.sh, lib/config.sh being sourced first. +################################################################################ + +BARESIP_CONFIG_DIR="${KIOSK_HOME}/.baresip" +BARESIP_USER_SERVICE_DIR="${KIOSK_HOME}/.config/systemd/user" + +# Runs `systemctl --user ...` as $KIOSK_USER with the runtime dir/D-Bus +# address it needs to find that user's session. Always call from an +# `if`/`&&`/`||` context - see run_menu's own comment on why a bare call +# that can legitimately fail must never be a standalone statement. +baresip_systemctl_user() { + local kiosk_uid + kiosk_uid=$(id -u "$KIOSK_USER" 2>/dev/null) || return 1 + sudo -u "$KIOSK_USER" \ + XDG_RUNTIME_DIR="/run/user/${kiosk_uid}" \ + DBUS_SESSION_BUS_ADDRESS="unix:path=/run/user/${kiosk_uid}/bus" \ + systemctl --user "$@" +} + +baresip_installed_version() { + dpkg-query -W -f='${Version}' baresip 2>/dev/null || true +} + +# "Installed" means both the package and a written account - a bare +# `apt install baresip` with no configured extension isn't something +# this menu should call done. +baresip_is_installed() { + command -v baresip &>/dev/null && [[ -f "$BARESIP_CONFIG_DIR/accounts" ]] +} + +baresip_is_running() { + baresip_systemctl_user is-active --quiet baresip.service 2>/dev/null +} + +addon_asterisk_intercom_status() { + if baresip_is_installed; then + local ver + ver=$(baresip_installed_version) + if baresip_is_running; then + echo "Asterisk Intercom: Installed (v${ver:-unknown}) - Running" + else + echo "Asterisk Intercom: Installed (v${ver:-unknown}) - Not running" + fi + if [[ -f "$BARESIP_CONFIG_DIR/accounts" ]]; then + local account + account=$(head -1 "$BARESIP_CONFIG_DIR/accounts" 2>/dev/null) + local extension="${account#/dev/null; then + if ! sudo apt install -y baresip; then + log_error "Failed to install baresip package" + pause + return 1 + fi + fi + sudo apt install -y pulseaudio-utils pipewire-pulse 2>/dev/null || true + + sudo mkdir -p "$BARESIP_CONFIG_DIR" + + sudo tee "$BARESIP_CONFIG_DIR/accounts" > /dev/null <;auth_pass=${password};answermode=${answermode}${media_enc} +EOF + + if [[ ! -f "$BARESIP_CONFIG_DIR/config" ]]; then + sudo tee "$BARESIP_CONFIG_DIR/config" > /dev/null <<'BARESIPCONFIG' +# Baresip configuration for Asterisk Intercom + +# Audio settings +audio_player pulse,default +audio_source pulse,default +audio_alert pulse,default + +# Call settings +call_local_timeout 120 +call_max_calls 4 + +# Network settings +net_interface + +# SIP settings +sip_trans_bsize 128 +sip_verify_server no + +# Module loading +module pulse.so +module account.so +module contact.so +module menu.so +module stdio.so +module uuid.so +module debug_cmd.so +BARESIPCONFIG + fi + + sudo chown -R "${KIOSK_USER}:${KIOSK_USER}" "$BARESIP_CONFIG_DIR" + sudo chmod 600 "$BARESIP_CONFIG_DIR/accounts" + + sudo mkdir -p "$BARESIP_USER_SERVICE_DIR" + sudo tee "$BARESIP_USER_SERVICE_DIR/baresip.service" > /dev/null <<'BARESIPUNIT' +[Unit] +Description=Baresip SIP Client +After=pipewire.service pipewire-pulse.service +Wants=pipewire-pulse.service + +[Service] +Type=simple +ExecStart=/usr/bin/baresip -f %h/.baresip +Restart=always +RestartSec=5 +Environment=PULSE_SERVER=unix:/run/user/%U/pulse/native + +[Install] +WantedBy=default.target +BARESIPUNIT + sudo chown -R "${KIOSK_USER}:${KIOSK_USER}" "${KIOSK_HOME}/.config" + + if baresip_systemctl_user daemon-reload 2>/dev/null && \ + baresip_systemctl_user enable baresip.service 2>/dev/null && \ + baresip_systemctl_user start baresip.service 2>/dev/null; then + log_success "Baresip service enabled and started" + else + log_warning "Baresip files written, but enabling/starting the user service failed - it will start automatically on next login. Check: systemctl --user status baresip" + fi + + echo + log_success "Asterisk Intercom configured" + echo " Config dir: ${BARESIP_CONFIG_DIR}" + echo " Server: ${server_ip}:${server_port}" + echo " Extension: ${extension}" + echo + echo "Management commands (as $KIOSK_USER):" + echo " Check status: systemctl --user status baresip" + echo " Restart: systemctl --user restart baresip" + echo " View logs: journalctl --user -u baresip -f" + + pause +} + +action_uninstall_asterisk_intercom() { + echo + ask_yes_no "Remove Asterisk Intercom (Baresip)?" "n" || { echo "Cancelled"; pause; return; } + + baresip_systemctl_user stop baresip.service 2>/dev/null || true + baresip_systemctl_user disable baresip.service 2>/dev/null || true + sudo rm -f "$BARESIP_USER_SERVICE_DIR/baresip.service" + sudo apt remove -y baresip 2>/dev/null || true + + if ask_yes_no "Remove saved SIP configuration too?" "n"; then + sudo rm -rf "$BARESIP_CONFIG_DIR" + log_success "Asterisk Intercom removed (configuration deleted)" + else + log_success "Asterisk Intercom removed (configuration preserved)" + fi + + pause +} diff --git a/ubuntu-based-kiosk.sh b/ubuntu-based-kiosk.sh index 1f41281..de80e23 100644 --- a/ubuntu-based-kiosk.sh +++ b/ubuntu-based-kiosk.sh @@ -1,8 +1,38 @@ #!/bin/bash ################################################################################ -### Ubuntu Based Kiosk v2.9.0 ### +### Ubuntu Based Kiosk v2.10.0 ### ################################################################################ # +# RELEASE v2.10.0 - Asterisk Intercom Migrated, Redesigned as a SIP +# Extension Client (No More PBX Server Install) +# - New in ./install.sh: Asterisk Intercom (menus/addon_asterisk_intercom.sh). +# The legacy addon offered three options: Client Only (a Baresip SIP +# client), Server Only, and Full (server + client) - the latter two +# downloaded and ran a third-party installer from a separate "Easy +# Asterisk" repository to stand up a whole Asterisk PBX. That +# repository has since gone through a major rework upstream, so this +# migration drops the PBX-install path entirely rather than carrying +# a dependency on code that's moved on without it. The addon now does +# only the client/endpoint piece: install Baresip and register it as +# one SIP extension against an Asterisk server the user already has +# running somewhere else. It never installs or manages Asterisk +# itself. The legacy script's own three-option version is untouched - +# both copies coexist deliberately, same as every other migrated menu. +# - Dropped the legacy client path's dependency on the (now-reworked) +# Easy Asterisk repo's GitHub API for version tracking. It now reads +# the real installed `baresip` package version via dpkg instead - one +# less network dependency and one less thing to keep in sync with an +# external repo. +# - New capability: an uninstall option for the Baresip client, which +# the legacy addon never had at all. +# - Bug fix (found while porting): `baresip_installed_version()`'s +# `dpkg-query` call fails (as expected) when the package isn't +# installed, and the unguarded `ver=$(...)` assignment around it would +# have crashed the whole session under this tool's `set -e` the first +# time status was checked before Baresip was installed. Guarded with +# `|| true` - the same class of bug hunted throughout this migration, +# caught by testing before it shipped. +# # RELEASE v2.9.0 - LMS Server / Squeezelite Player Migrated; # is_service_enabled() Dead Pre-Check Fixed # - New in ./install.sh: LMS Server / Squeezelite Player @@ -349,7 +379,7 @@ set -euo pipefail ### SECTION 1: CONSTANTS & GLOBALS ################################################################################ -SCRIPT_VERSION="2.9.0" +SCRIPT_VERSION="2.10.0" # Resolve the real path to this script file. # When piped (curl|bash or wget|bash), BASH_SOURCE[0] is a pipe descriptor, From a3313aa9b8d3737811d02940720f244f30efc510 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 02:52:43 +0000 Subject: [PATCH 13/19] 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 Claude-Session: https://claude.ai/code/session_01VfsFSoRqfbRG7XAg5RoE7e --- Readme.md | 32 ++- install.sh | 18 +- menus/advanced_electron.sh | 278 +++++++++++++++++++++++++ menus/advanced_emergency_hotspot.sh | 303 ++++++++++++++++++++++++++++ menus/advanced_factory_reset.sh | 46 +++++ menus/advanced_virtual_consoles.sh | 127 ++++++++++++ ubuntu-based-kiosk.sh | 35 +++- 7 files changed, 827 insertions(+), 12 deletions(-) create mode 100644 menus/advanced_electron.sh create mode 100644 menus/advanced_emergency_hotspot.sh create mode 100644 menus/advanced_factory_reset.sh create mode 100644 menus/advanced_virtual_consoles.sh diff --git a/Readme.md b/Readme.md index 037e68a..ef0414a 100644 --- a/Readme.md +++ b/Readme.md @@ -1,6 +1,6 @@ # Ubuntu Based Kiosk -**Current Version:** 2.10.0 (check script header for latest version) +**Current Version:** 2.11.0 (check script header for latest version) **Built with Claude Sonnet 4.6 AI assistance** **License:** GPL v3 - Keep derivatives open source **Repository:** https://github.com/outis1one/ubuntu-based-kiosk/ @@ -1239,6 +1239,18 @@ terminal menu and the web UI, so they can't drift apart). during migration, not a straight port — see "Recent Updates (v2.10.0)" below for why the legacy Server/Full PBX-install options didn't come along. +- `menus/advanced_electron.sh` — **Electron Maintenance** (Advanced): + manual update (with backup + rollback) and "fix blank screen" binary + repair, combined into one submenu since both share the same + binary-verification logic. +- `menus/advanced_factory_reset.sh` — **Factory Reset** (Advanced): + wipes `config.json` back to defaults; addons are untouched. +- `menus/advanced_virtual_consoles.sh` — **Virtual Consoles** (Advanced): + toggles Ctrl+Alt+F1-F8 terminal login access. +- `menus/advanced_emergency_hotspot.sh` — **Emergency Hotspot** + (Advanced): auto-starts a WiFi hotspot if no internet is detected 60 + seconds after boot. Its own runtime script/systemd unit go through + `$BIN_DIR`/`$SYSTEMD_DIR` like every other addon. - `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: @@ -1253,10 +1265,11 @@ most of the old installer. `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, Reinstall, Uninstall, and the other 8 -Advanced items — none of that has moved yet). 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 +option, by design (plus Upgrade, Reinstall, Complete Uninstall, Export/ +Import Settings, and Fix Squeezelite Audio — none of that has moved +yet). 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. @@ -1286,9 +1299,14 @@ full migration pass. ## Project Status & Future Plans -**Current Version:** 2.10.0 +**Current Version:** 2.11.0 -**Recent Updates (v2.10.0):** +**Recent Updates (v2.11.0):** +- **4 more Advanced items migrated**, alongside Diagnostics: **Electron Maintenance** (`menus/advanced_electron.sh` — the legacy "Manual Electron Update" and "Fix Blank Screen" combined into one submenu, since both maintain the same installation and share the binary-repair logic), **Factory Reset** (`menus/advanced_factory_reset.sh` — wipes `config.json` only, addons untouched), **Virtual Consoles** (`menus/advanced_virtual_consoles.sh` — toggles Ctrl+Alt+F1-F8 terminal login), and **Emergency Hotspot** (`menus/advanced_emergency_hotspot.sh` — 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). +- That's 8 of the legacy Advanced menu's 12 entries now covered. Not migrated this round: Export/Import Settings (pending a decision on whether to rebuild it around actual paths instead of a hardcoded per-addon step list, or whether the future web UI replaces the need for it) and Fix Squeezelite Audio (small enough that it may fold into the LMS addon instead of staying standalone — not decided yet). +- Complete Uninstall (the last of the "destructive trio") is next, composed from each addon's own uninstall action plus core teardown rather than rewriting removal logic a second time. Upgrade and Full Reinstall stay in the legacy script for now — both are coupled to its own heredoc self-extraction of main.js/preload.js/etc, which has no modular equivalent yet. + +**Previous (v2.10.0):** - **Asterisk Intercom migrated, and redesigned in the process.** The legacy addon offered Client Only (Baresip SIP client), Server Only, and Full (server + client) — the latter two downloaded and ran a third-party installer from a separate "Easy Asterisk" repository to stand up a whole Asterisk PBX. That repository has since gone through a major rework upstream, so the PBX-install path is dropped entirely rather than carrying a dependency on code that's moved on without it. The migrated addon (`menus/addon_asterisk_intercom.sh`) now does only the client/endpoint piece: install Baresip and register this kiosk as one SIP extension against an Asterisk server you already have 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 dependency on the (now-reworked) Easy Asterisk repo's GitHub API for version tracking — reads the real installed `baresip` package version via `dpkg` instead. - **New capability:** an uninstall option for the Baresip client — the legacy addon never had one. diff --git a/install.sh b/install.sh index 38f633c..b0f1118 100755 --- a/install.sh +++ b/install.sh @@ -22,7 +22,11 @@ # Squeezelite Player (menus/addon_lms_squeezelite.sh), Asterisk # Intercom - SIP extension client (menus/addon_asterisk_intercom.sh). # Advanced: Diagnostics (menus/diagnostics.sh - system status/logs/ -# audio/network). +# audio/network), Electron Maintenance (menus/advanced_electron.sh - +# manual update, fix blank screen), Factory Reset +# (menus/advanced_factory_reset.sh), Virtual Consoles +# (menus/advanced_virtual_consoles.sh), Emergency Hotspot +# (menus/advanced_emergency_hotspot.sh). # # Usage (once the kiosk has already been installed): # git clone @@ -64,6 +68,14 @@ source "$SCRIPT_DIR/menus/addon_remote_access.sh" source "$SCRIPT_DIR/menus/addon_lms_squeezelite.sh" # shellcheck source=menus/addon_asterisk_intercom.sh source "$SCRIPT_DIR/menus/addon_asterisk_intercom.sh" +# shellcheck source=menus/advanced_electron.sh +source "$SCRIPT_DIR/menus/advanced_electron.sh" +# shellcheck source=menus/advanced_factory_reset.sh +source "$SCRIPT_DIR/menus/advanced_factory_reset.sh" +# shellcheck source=menus/advanced_virtual_consoles.sh +source "$SCRIPT_DIR/menus/advanced_virtual_consoles.sh" +# shellcheck source=menus/advanced_emergency_hotspot.sh +source "$SCRIPT_DIR/menus/advanced_emergency_hotspot.sh" ################################################################################ # Preflight @@ -136,8 +148,8 @@ addons_menu() { } advanced_menu_builder() { - MENU_LABELS=("Diagnostics") - MENU_HANDLERS=(diagnostics_menu) + MENU_LABELS=("Diagnostics" "Electron Maintenance" "Factory Reset" "Virtual Consoles" "Emergency Hotspot") + MENU_HANDLERS=(diagnostics_menu advanced_electron_menu advanced_factory_reset_menu advanced_virtual_consoles_menu advanced_emergency_hotspot_menu) } advanced_menu() { diff --git a/menus/advanced_electron.sh b/menus/advanced_electron.sh new file mode 100644 index 0000000..68e38f0 --- /dev/null +++ b/menus/advanced_electron.sh @@ -0,0 +1,278 @@ +#!/bin/bash +################################################################################ +# menus/advanced_electron.sh - "Electron Maintenance" (Advanced): the +# legacy "Manual Electron Update" and "Fix Blank Screen" items, combined +# under one submenu since both maintain the same Electron installation +# and share the binary-repair logic (electron_install_binary). +# +# 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. +################################################################################ + +electron_installed_version() { + local package_json="$KIOSK_DIR/package.json" + if ! sudo test -f "$package_json" 2>/dev/null; then + echo "not installed" + return + fi + + local version + version=$(sudo grep -oP '"electron"\s*:\s*"\^?\K[0-9.]+' "$package_json" 2>/dev/null || true) + if [[ -z "$version" ]]; then + local electron_pkg="$KIOSK_DIR/node_modules/electron/package.json" + if sudo test -f "$electron_pkg" 2>/dev/null; then + version=$(sudo grep -oP '"version"\s*:\s*"\K[0-9.]+' "$electron_pkg" 2>/dev/null || true) + fi + fi + echo "${version:-unknown}" +} + +electron_is_running() { + pgrep -f "electron.*main.js" &>/dev/null || pgrep -f "node.*electron" &>/dev/null +} + +# 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) + echo "Electron: v${ver}" + if electron_is_running; then + echo " Running" + else + echo " Not running" + fi +} + +advanced_electron_menu_builder() { + MENU_LABELS=( + "Check for updates / update Electron" + "Fix blank screen (repair Electron binary + sandbox)" + ) + MENU_HANDLERS=(action_update_electron action_repair_electron) +} + +advanced_electron_menu() { + run_menu "ELECTRON MAINTENANCE" advanced_electron_menu_builder advanced_electron_status +} + +################################################################################ +# Actions +################################################################################ + +action_update_electron() { + echo + if ! sudo test -d "$KIOSK_DIR" 2>/dev/null; then + log_error "Kiosk directory not found: $KIOSK_DIR" + pause + return 1 + fi + + local current_version + current_version=$(electron_installed_version) + log_info "Current Electron version: $current_version" + + if electron_is_running; then + log_success "Electron app is running" + else + log_warning "Electron app does not appear to be running" + fi + echo + + ask_yes_no "Check for latest Electron version?" "y" || { echo "Cancelled"; pause; return; } + + local latest_version + latest_version=$(npm view electron version 2>/dev/null || true) + if [[ -z "$latest_version" ]]; then + latest_version=$(curl -s https://registry.npmjs.org/electron/latest 2>/dev/null | grep -oP '"version"\s*:\s*"\K[0-9.]+' || true) + fi + if [[ -z "$latest_version" ]]; then + latest_version=$(curl -s https://api.github.com/repos/electron/electron/releases/latest 2>/dev/null | grep -oP '"tag_name"\s*:\s*"v\K[0-9.]+' || true) + fi + + if [[ -z "$latest_version" ]]; then + log_error "Could not fetch latest Electron version - check your internet connection" + pause + return 1 + fi + log_success "Latest stable Electron version: $latest_version" + echo + + if [[ "$current_version" == "$latest_version" ]]; then + log_success "Already running the latest version" + ask_yes_no "Reinstall Electron $latest_version anyway?" "n" || { echo "Cancelled"; pause; return; } + fi + + echo "──────────────────────────────────────────────────────────" + echo "UPDATE SUMMARY" + echo "──────────────────────────────────────────────────────────" + echo "Current version: $current_version" + echo "Target version: $latest_version" + echo "Installation: $KIOSK_DIR" + echo + + local current_major="${current_version%%.*}" + local latest_major="${latest_version%%.*}" + log_warning "Review breaking changes before updating:" + echo " https://www.electronjs.org/docs/latest/breaking-changes" + if [[ "$latest_major" != "$current_major" ]]; then + log_warning "MAJOR VERSION CHANGE (v${current_major} -> v${latest_major})" + fi + echo + + ask_yes_no "Reviewed breaking changes and want to proceed?" "n" || { echo "Cancelled"; pause; return; } + + echo + log_info "Creating backup..." + local kiosk_owner + kiosk_owner=$(sudo stat -c '%U' "$KIOSK_DIR" 2>/dev/null || echo "$KIOSK_USER") + local backup_dir="${KIOSK_DIR}/backups/electron_backup_$(date +%Y%m%d_%H%M%S)" + sudo -u "$kiosk_owner" mkdir -p "$backup_dir" + + if sudo test -f "$KIOSK_DIR/package.json" 2>/dev/null; then + sudo -u "$kiosk_owner" cp "$KIOSK_DIR/package.json" "$backup_dir/" + fi + if sudo test -f "$KIOSK_DIR/package-lock.json" 2>/dev/null; then + sudo -u "$kiosk_owner" cp "$KIOSK_DIR/package-lock.json" "$backup_dir/" + fi + echo "$current_version" | sudo -u "$kiosk_owner" tee "$backup_dir/electron_version.txt" > /dev/null + log_success "Backup created at: $backup_dir" + echo + + ask_yes_no "Proceed with Electron update to $latest_version?" "n" || { + log_info "Update cancelled - backup preserved at: $backup_dir" + pause + return + } + + echo + log_info "Stopping kiosk display..." + sudo systemctl stop lightdm 2>/dev/null || true + sleep 2 + + sudo -u "$KIOSK_USER" sed -i "s/\"electron\": \".*\"/\"electron\": \"^${latest_version}\"/" "$KIOSK_DIR/package.json" + + if sudo test -d "$KIOSK_DIR/node_modules/electron" 2>/dev/null; then + sudo -u "$KIOSK_USER" rm -rf "$KIOSK_DIR/node_modules/electron" + fi + + log_info "Installing Electron $latest_version (this may take a few minutes)..." + if sudo -u "$KIOSK_USER" bash -c "cd '$KIOSK_DIR' && npm install electron@'$latest_version'"; then + log_success "Electron updated to $latest_version" + + local sandbox="$KIOSK_DIR/node_modules/electron/dist/chrome-sandbox" + if sudo test -f "$sandbox" 2>/dev/null; then + sudo chown root:root "$sandbox" + sudo chmod 4755 "$sandbox" + fi + + if ask_yes_no "Restart kiosk display now?" "y"; then + sudo systemctl start lightdm + sleep 3 + if systemctl is-active --quiet lightdm; then + log_success "Kiosk display started" + else + log_error "Kiosk display failed to start - check: sudo journalctl -u lightdm -n 50" + fi + else + log_info "Start manually with: sudo systemctl start lightdm" + fi + log_success "Backup preserved at: $backup_dir (delete once confirmed working)" + else + log_error "Electron install failed - restoring from backup..." + if sudo test -f "$backup_dir/package.json" 2>/dev/null; then + sudo -u "$KIOSK_USER" cp "$backup_dir/package.json" "$KIOSK_DIR/" + fi + if sudo -u "$KIOSK_USER" bash -c "cd '$KIOSK_DIR' && npm install"; then + log_success "Restored original Electron installation" + sudo systemctl start lightdm + else + log_error "Failed to restore - manual intervention required" + fi + fi + + pause +} + +action_repair_electron() { + echo + echo "This will:" + echo " 1. Check if the Electron binary is present" + echo " 2. Download it if missing (~120MB)" + echo " 3. Fix chrome-sandbox permissions (setuid root)" + echo " 4. Restart the kiosk display" + echo + ask_yes_no "Continue?" "y" || { echo "Cancelled"; pause; return; } + + sudo systemctl stop lightdm 2>/dev/null || true + sleep 1 + + if ! electron_install_binary; then + log_error "Could not install Electron. Check internet and retry." + pause + return 1 + fi + + log_info "Restarting kiosk display..." + sudo systemctl restart lightdm + sleep 3 + if systemctl is-active --quiet lightdm && pgrep -f "electron.*main.js" &>/dev/null; then + log_success "Kiosk display is running" + else + log_warning "LightDM started but Electron may still be loading." + echo " Check: sudo tail -20 $KIOSK_DIR/../electron.log" + fi + + pause +} diff --git a/menus/advanced_emergency_hotspot.sh b/menus/advanced_emergency_hotspot.sh new file mode 100644 index 0000000..3c72edc --- /dev/null +++ b/menus/advanced_emergency_hotspot.sh @@ -0,0 +1,303 @@ +#!/bin/bash +################################################################################ +# menus/advanced_emergency_hotspot.sh - "Emergency Hotspot" (Advanced): +# auto-starts a WiFi hotspot if no internet is detected 60 seconds after +# boot, so the kiosk can be reached and reconfigured remotely. +# +# Writes a standalone runtime script ($BIN_DIR/kiosk-emergency-hotspot) +# plus a oneshot systemd unit ($SYSTEMD_DIR) that runs it at boot - both +# of those paths are ours to place, so (like power_schedule and every +# other addon) they're parameterized instead of hardcoded. hostapd/ +# dnsmasq/iptables themselves are real apt packages with their own fixed +# config locations, stubbed at the command level in tests like CUPS. +# +# The runtime script itself is a template: everything written with `\$` +# below stays literal and only resolves when the script actually runs at +# boot (on the real machine, not in this tool); only the un-escaped +# $wifi_iface/$hotspot_ssid/$hotspot_pass/$hotspot_ip/$KIOSK_USER/ +# $KIOSK_DIR are substituted once, at configuration time. +# +# Depends on: lib/menu.sh, lib/config.sh being sourced first. +################################################################################ + +EMERGENCY_HOTSPOT_SCRIPT="$BIN_DIR/kiosk-emergency-hotspot" + +emergency_hotspot_is_configured() { + [[ -f "$EMERGENCY_HOTSPOT_SCRIPT" ]] +} + +emergency_hotspot_ssid() { + grep '^HOTSPOT_SSID=' "$EMERGENCY_HOTSPOT_SCRIPT" 2>/dev/null | cut -d'=' -f2 | tr -d '"' || true +} + +advanced_emergency_hotspot_status() { + if emergency_hotspot_is_configured; then + local ssid + ssid=$(emergency_hotspot_ssid) + echo "Emergency Hotspot: Configured (SSID: ${ssid:-unknown})" + else + echo "Emergency Hotspot: Not configured" + fi + echo "ℹ Auto-starts a WiFi hotspot if no internet is detected 60" + echo " seconds after boot, so you can connect and reconfigure remotely." +} + +advanced_emergency_hotspot_menu_builder() { + if emergency_hotspot_is_configured; then + MENU_LABELS=("Reconfigure" "Disable") + MENU_HANDLERS=(action_configure_emergency_hotspot action_disable_emergency_hotspot) + else + MENU_LABELS=("Enable emergency hotspot") + MENU_HANDLERS=(action_configure_emergency_hotspot) + fi +} + +advanced_emergency_hotspot_menu() { + run_menu "EMERGENCY HOTSPOT" advanced_emergency_hotspot_menu_builder advanced_emergency_hotspot_status +} + +################################################################################ +# Actions +################################################################################ + +action_configure_emergency_hotspot() { + echo + if ! sudo apt install -y hostapd dnsmasq iptables; then + log_error "Failed to install hostapd/dnsmasq/iptables" + pause + return 1 + fi + + sudo systemctl stop hostapd dnsmasq 2>/dev/null || true + sudo systemctl disable hostapd dnsmasq 2>/dev/null || true + + local wifi_iface + wifi_iface=$(ls /sys/class/net 2>/dev/null | grep -E "^wl" | head -1 || true) + if [[ -z "$wifi_iface" ]]; then + log_error "No WiFi interface found" + pause + return 1 + fi + echo "WiFi interface: $wifi_iface" + echo + + local hotspot_ssid + hotspot_ssid=$(ask_text "Hotspot SSID" "Kiosk-Emergency") + + local hotspot_pass="" + while [[ ${#hotspot_pass} -lt 8 ]]; do + read -r -s -p "Hotspot password (8+ chars): " hotspot_pass + echo + [[ ${#hotspot_pass} -lt 8 ]] && log_error "Password must be at least 8 characters" + done + + local hotspot_ip="192.168.50.1" + + sudo mkdir -p "$BIN_DIR" + sudo tee "$EMERGENCY_HOTSPOT_SCRIPT" > /dev/null </dev/null 2>&1; then + logger "KIOSK: Internet connected - emergency hotspot not needed" + exit 0 +fi + +logger "KIOSK: No internet detected - starting emergency hotspot" + +# Stop any conflicting services +systemctl stop wpa_supplicant 2>/dev/null || true +ip link set \$WIFI_IFACE down 2>/dev/null || true +sleep 2 + +# Configure static IP for hotspot +ip addr flush dev \$WIFI_IFACE +ip addr add \${HOTSPOT_IP}/24 dev \$WIFI_IFACE +ip link set \$WIFI_IFACE up + +# Configure dnsmasq +cat > /tmp/dnsmasq-hotspot.conf < /tmp/hostapd-hotspot.conf < /proc/sys/net/ipv4/ip_forward 2>/dev/null || true + +# Show notification on kiosk display +sudo -u \$KIOSK_USER DISPLAY=:0 DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/\$(id -u \$KIOSK_USER)/bus \\ + notify-send -u critical -t 0 "Emergency Hotspot Active" \\ + "SSID: \$HOTSPOT_SSID\\nPassword: \$HOTSPOT_PASS\\nConnect to: http://\$HOTSPOT_IP" 2>/dev/null || true + +logger "KIOSK: Emergency hotspot started - SSID: \$HOTSPOT_SSID, IP: \$HOTSPOT_IP" + +# Create on-screen notification HTML +sudo -u \$KIOSK_USER tee /tmp/hotspot-notification.html > /dev/null <<'NOTIFY' + + + + + + +
+
📡
+

Emergency Hotspot Active

+
No internet connection detected
Hotspot created for remote access
+
SSID: \$HOTSPOT_SSID
+
Password: \$HOTSPOT_PASS
+
Connect to: http://\$HOTSPOT_IP
+ +
+ + + +NOTIFY + +# Show notification window if Electron is running +if pgrep -f "electron.*main.js" >/dev/null 2>&1; then + sudo -u \$KIOSK_USER DISPLAY=:0 \\ + "$KIOSK_DIR/node_modules/electron/dist/electron" \\ + /tmp/hotspot-notification.html & +fi + +exit 0 +EOF + + sudo chmod +x "$EMERGENCY_HOTSPOT_SCRIPT" + + sudo mkdir -p "$SYSTEMD_DIR" + sudo tee "$SYSTEMD_DIR/kiosk-emergency-hotspot.service" > /dev/null </dev/null || true + # Enable only, not start: this is a boot-time oneshot that waits 60s + # and checks connectivity - starting it right now would just run that + # wait/check immediately, which isn't what "configure" means here. + if ! sudo systemctl enable kiosk-emergency-hotspot.service 2>/dev/null; then + log_warning "Hotspot files written, but 'systemctl enable' failed - check 'systemctl status kiosk-emergency-hotspot.service'" + fi + + echo + log_success "Emergency hotspot configured" + echo " SSID: $hotspot_ssid" + echo " Password: $hotspot_pass" + echo " IP: $hotspot_ip" + echo + echo "Hotspot auto-starts if no internet is detected 60 seconds after boot." + + pause +} + +action_disable_emergency_hotspot() { + echo + ask_yes_no "Disable emergency hotspot?" "n" || { echo "Cancelled"; pause; return; } + + sudo systemctl stop kiosk-emergency-hotspot.service 2>/dev/null || true + sudo systemctl disable kiosk-emergency-hotspot.service 2>/dev/null || true + sudo rm -f "$SYSTEMD_DIR/kiosk-emergency-hotspot.service" + sudo rm -f "$EMERGENCY_HOTSPOT_SCRIPT" + sudo systemctl daemon-reload 2>/dev/null || true + log_success "Emergency hotspot disabled" + + pause +} diff --git a/menus/advanced_factory_reset.sh b/menus/advanced_factory_reset.sh new file mode 100644 index 0000000..e3285ed --- /dev/null +++ b/menus/advanced_factory_reset.sh @@ -0,0 +1,46 @@ +#!/bin/bash +################################################################################ +# menus/advanced_factory_reset.sh - "Factory Reset" (Advanced): wipe +# config.json back to script defaults without touching anything else. +# +# Deliberately narrow - this only removes $CONFIG_PATH. Installed addons +# (CUPS, LMS, VPNs, etc.), the kiosk user, and the system itself are left +# alone; that's what Complete Uninstall is for. +# +# Depends on: lib/menu.sh, lib/config.sh being sourced first. +################################################################################ + +advanced_factory_reset_status() { + if sudo -u "$KIOSK_USER" test -f "$CONFIG_PATH" 2>/dev/null; then + echo "Config: $CONFIG_PATH exists" + else + echo "Config: not found (already at defaults)" + fi +} + +advanced_factory_reset_menu_builder() { + MENU_LABELS=("Reset configuration to defaults") + MENU_HANDLERS=(action_factory_reset) +} + +advanced_factory_reset_menu() { + run_menu "FACTORY RESET" advanced_factory_reset_menu_builder advanced_factory_reset_status +} + +################################################################################ +# Actions +################################################################################ + +action_factory_reset() { + echo + echo "This resets $CONFIG_PATH to defaults - sites, schedules," + echo "password protection, and every other setting stored there are" + echo "cleared. Installed addons (CUPS, LMS, VPNs, etc.) are not touched." + echo + ask_yes_no "Continue?" "n" || { echo "Cancelled"; pause; return; } + + sudo -u "$KIOSK_USER" rm -f "$CONFIG_PATH" + log_success "Configuration reset - reconfigure via Core Settings" + + pause +} diff --git a/menus/advanced_virtual_consoles.sh b/menus/advanced_virtual_consoles.sh new file mode 100644 index 0000000..1d9261c --- /dev/null +++ b/menus/advanced_virtual_consoles.sh @@ -0,0 +1,127 @@ +#!/bin/bash +################################################################################ +# menus/advanced_virtual_consoles.sh - "Virtual Consoles" (Advanced): toggle +# Ctrl+Alt+F1-F8 terminal login access for troubleshooting. +# +# Real system state: masks/unmasks the getty@ttyN systemd units and writes +# a fixed-path X11 server-flags file. Neither is relocatable (X11 only +# reads /etc/X11/xorg.conf.d/, and getty units are always system units), +# so tests use full command-level `sudo` stubbing, same approach as CUPS. +# +# Depends on: lib/menu.sh, lib/config.sh being sourced first. +################################################################################ + +vconsoles_are_disabled() { + local getty_masked=false + local vt_switch_disabled=false + + if systemctl is-masked --quiet getty@tty1.service 2>/dev/null; then + getty_masked=true + fi + + if [[ -f /etc/X11/xorg.conf.d/10-serverflags.conf ]] && \ + grep -q 'Option.*"DontVTSwitch".*"true"' /etc/X11/xorg.conf.d/10-serverflags.conf 2>/dev/null; then + vt_switch_disabled=true + fi + + [[ "$getty_masked" == "true" || "$vt_switch_disabled" == "true" ]] +} + +advanced_virtual_consoles_status() { + if vconsoles_are_disabled; then + echo "Virtual consoles: Disabled" + else + echo "Virtual consoles: Enabled" + fi +} + +advanced_virtual_consoles_menu_builder() { + if vconsoles_are_disabled; then + MENU_LABELS=("Enable virtual consoles (Ctrl+Alt+F1-F8 for manual login)") + MENU_HANDLERS=(action_enable_virtual_consoles) + else + MENU_LABELS=("Disable virtual consoles (more secure, kiosk only)") + MENU_HANDLERS=(action_disable_virtual_consoles) + fi +} + +advanced_virtual_consoles_menu() { + run_menu "VIRTUAL CONSOLES" advanced_virtual_consoles_menu_builder advanced_virtual_consoles_status +} + +################################################################################ +# Actions +################################################################################ + +action_enable_virtual_consoles() { + echo + echo "Enabling virtual consoles..." + + for i in {1..8}; do + sudo systemctl unmask "getty@tty${i}.service" 2>/dev/null || true + done + sudo systemctl daemon-reload 2>/dev/null || true + + sudo mkdir -p /etc/X11/xorg.conf.d + sudo tee /etc/X11/xorg.conf.d/10-serverflags.conf > /dev/null <<'EOF' +Section "ServerFlags" + # Disable Ctrl+Alt+Backspace (X server kill) + Option "DontZap" "true" + + # ALLOW VT switching (Ctrl+Alt+F1-F12) + Option "DontVTSwitch" "false" + + # Don't allow clients to disconnect on exit + Option "AllowClosedownGrabs" "false" +EndSection +EOF + + log_success "Virtual consoles enabled" + echo " Access with Ctrl+Alt+F1 through Ctrl+Alt+F8" + echo " (Ctrl+Alt+F7 typically returns to the kiosk)" + echo + if ask_yes_no "Restart kiosk display now to apply?" "n"; then + sudo systemctl restart lightdm + else + log_warning "Remember to restart: sudo systemctl restart lightdm" + fi + + pause +} + +action_disable_virtual_consoles() { + echo + ask_yes_no "Disable all virtual consoles?" "n" || { echo "Cancelled"; pause; return; } + + echo "Disabling virtual consoles..." + + for i in {1..8}; do + sudo systemctl mask "getty@tty${i}.service" 2>/dev/null || true + done + sudo systemctl daemon-reload 2>/dev/null || true + + sudo mkdir -p /etc/X11/xorg.conf.d + sudo tee /etc/X11/xorg.conf.d/10-serverflags.conf > /dev/null <<'EOF' +Section "ServerFlags" + # Disable Ctrl+Alt+Backspace (X server kill) + Option "DontZap" "true" + + # DISABLE VT switching (Ctrl+Alt+F1-F12) + Option "DontVTSwitch" "true" + + # Don't allow clients to disconnect on exit + Option "AllowClosedownGrabs" "false" +EndSection +EOF + + log_success "Virtual consoles disabled" + echo " You can re-enable them from this menu at any time." + echo + if ask_yes_no "Restart kiosk display now to apply?" "n"; then + sudo systemctl restart lightdm + else + log_warning "Remember to restart: sudo systemctl restart lightdm" + fi + + pause +} diff --git a/ubuntu-based-kiosk.sh b/ubuntu-based-kiosk.sh index de80e23..829f3f3 100644 --- a/ubuntu-based-kiosk.sh +++ b/ubuntu-based-kiosk.sh @@ -1,8 +1,39 @@ #!/bin/bash ################################################################################ -### Ubuntu Based Kiosk v2.10.0 ### +### Ubuntu Based Kiosk v2.11.0 ### ################################################################################ # +# RELEASE v2.11.0 - 4 More Advanced Items Migrated (Electron Maintenance, +# Factory Reset, Virtual Consoles, Emergency Hotspot) +# - New in ./install.sh's Advanced menu, alongside Diagnostics: +# - menus/advanced_electron.sh - "Electron Maintenance": the legacy +# "Manual Electron Update" and "Fix Blank Screen" combined under one +# submenu, since both maintain the same installation and share the +# binary-repair logic (electron_install_binary). +# - menus/advanced_factory_reset.sh - "Factory Reset": wipes +# config.json back to defaults only - addons are untouched. +# - menus/advanced_virtual_consoles.sh - "Virtual Consoles": toggles +# Ctrl+Alt+F1-F8 terminal login access. +# - menus/advanced_emergency_hotspot.sh - "Emergency Hotspot": auto- +# starts a WiFi hotspot if no internet is detected 60 seconds after +# boot. Its own runtime script and systemd unit now go through +# $BIN_DIR/$SYSTEMD_DIR like every other addon's own files, instead +# of the legacy's hardcoded /usr/local/bin and /etc/systemd/system. +# - That leaves Diagnostics' original 4 items plus these 4 covering 8 of +# the legacy Advanced menu's 12 entries. Not migrated this round: +# Export/Import Settings (kept in the legacy script pending a decision +# on whether it's worth rebuilding around actual paths instead of a +# hardcoded per-addon step list, or whether the future web UI replaces +# the need for it) and Fix Squeezelite Audio (small and specific +# enough that it may fold into menus/addon_lms_squeezelite.sh instead +# of staying a standalone Advanced entry - not decided yet). +# - Complete Uninstall (the last of the "destructive trio") is next, +# composed from each addon's own uninstall action plus core teardown +# rather than rewriting removal logic a second time. Upgrade and Full +# Reinstall stay in this script for now: both are fundamentally +# coupled to this file's own heredoc self-extraction of main.js/ +# preload.js/etc, which has no equivalent yet in the modular system. +# # RELEASE v2.10.0 - Asterisk Intercom Migrated, Redesigned as a SIP # Extension Client (No More PBX Server Install) # - New in ./install.sh: Asterisk Intercom (menus/addon_asterisk_intercom.sh). @@ -379,7 +410,7 @@ set -euo pipefail ### SECTION 1: CONSTANTS & GLOBALS ################################################################################ -SCRIPT_VERSION="2.10.0" +SCRIPT_VERSION="2.11.0" # Resolve the real path to this script file. # When piped (curl|bash or wget|bash), BASH_SOURCE[0] is a pipe descriptor, From b0b558f0153b8c572f60e5a7fd112272a58aab75 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 03:03:50 +0000 Subject: [PATCH 14/19] 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 Claude-Session: https://claude.ai/code/session_01VfsFSoRqfbRG7XAg5RoE7e --- Readme.md | 34 +++++-- install.sh | 12 ++- menus/addon_asterisk_intercom.sh | 22 +++- menus/addon_cups.sh | 9 +- menus/addon_lms_squeezelite.sh | 28 +++++- menus/addon_remote_access.sh | 24 ++++- menus/advanced_emergency_hotspot.sh | 7 +- menus/complete_uninstall.sh | 151 ++++++++++++++++++++++++++++ menus/power_schedule.sh | 5 + ubuntu-based-kiosk.sh | 44 +++++++- 10 files changed, 306 insertions(+), 30 deletions(-) create mode 100644 menus/complete_uninstall.sh diff --git a/Readme.md b/Readme.md index ef0414a..a0fc3c4 100644 --- a/Readme.md +++ b/Readme.md @@ -1,6 +1,6 @@ # Ubuntu Based Kiosk -**Current Version:** 2.11.0 (check script header for latest version) +**Current Version:** 2.12.0 (check script header for latest version) **Built with Claude Sonnet 4.6 AI assistance** **License:** GPL v3 - Keep derivatives open source **Repository:** https://github.com/outis1one/ubuntu-based-kiosk/ @@ -1251,6 +1251,10 @@ terminal menu and the web UI, so they can't drift apart). (Advanced): auto-starts a WiFi hotspot if no internet is detected 60 seconds after boot. Its own runtime script/systemd unit go through `$BIN_DIR`/`$SYSTEMD_DIR` like every other addon. +- `menus/complete_uninstall.sh` — **Complete Uninstall** (Core + Settings): the last of the "destructive trio." Composed from every + addon's own `*_do_uninstall` helper instead of re-implementing + removal a second time — see "Recent Updates (v2.12.0)" below. - `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: @@ -1265,14 +1269,16 @@ most of the old installer. `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, Reinstall, Complete Uninstall, Export/ -Import Settings, and Fix Squeezelite Audio — none of that has moved -yet). 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. +option, by design (plus Upgrade, Full Reinstall, Export/Import +Settings, 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). +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. **Resolved (v2.9.0):** `is_service_enabled()` — shared by both scripts — had a pre-check (`systemctl list-unit-files | grep -q "^${service}\s"`) @@ -1299,9 +1305,15 @@ full migration pass. ## Project Status & Future Plans -**Current Version:** 2.11.0 +**Current Version:** 2.12.0 -**Recent Updates (v2.11.0):** +**Recent Updates (v2.12.0):** +- **Complete Uninstall migrated** — the last of the "destructive trio." Rather than re-implementing every addon's teardown a second time (the legacy shape — CUPS/VNC/WireGuard/Tailscale/Netbird/LMS/Squeezelite removal all inlined again, independently of each addon's own uninstall action), `menus/complete_uninstall.sh` composes the `*_do_uninstall` helpers each addon already has. Every addon menu with an uninstall action was split into a confirm-and-call wrapper (unchanged from the user's perspective) plus a silent removal helper that both the wrapper and Complete Uninstall call — no duplicated logic anywhere, and if an addon's removal logic changes later, Complete Uninstall picks it up automatically. +- **Important bug found and fixed 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. 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 partway through — e.g. the kiosk user might never get removed because an already-uninstalled VPN client's `apt remove` failed first. Guarded all of them with `|| true`. +- 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 — same as the legacy script. +- Upgrade and Full Reinstall remain 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. + +**Previous (v2.11.0):** - **4 more Advanced items migrated**, alongside Diagnostics: **Electron Maintenance** (`menus/advanced_electron.sh` — the legacy "Manual Electron Update" and "Fix Blank Screen" combined into one submenu, since both maintain the same installation and share the binary-repair logic), **Factory Reset** (`menus/advanced_factory_reset.sh` — wipes `config.json` only, addons untouched), **Virtual Consoles** (`menus/advanced_virtual_consoles.sh` — toggles Ctrl+Alt+F1-F8 terminal login), and **Emergency Hotspot** (`menus/advanced_emergency_hotspot.sh` — 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). - That's 8 of the legacy Advanced menu's 12 entries now covered. Not migrated this round: Export/Import Settings (pending a decision on whether to rebuild it around actual paths instead of a hardcoded per-addon step list, or whether the future web UI replaces the need for it) and Fix Squeezelite Audio (small enough that it may fold into the LMS addon instead of staying standalone — not decided yet). - Complete Uninstall (the last of the "destructive trio") is next, composed from each addon's own uninstall action plus core teardown rather than rewriting removal logic a second time. Upgrade and Full Reinstall stay in the legacy script for now — both are coupled to its own heredoc self-extraction of main.js/preload.js/etc, which has no modular equivalent yet. diff --git a/install.sh b/install.sh index b0f1118..26a9ccb 100755 --- a/install.sh +++ b/install.sh @@ -15,7 +15,11 @@ # 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. +# 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). # 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 / @@ -76,6 +80,10 @@ source "$SCRIPT_DIR/menus/advanced_factory_reset.sh" source "$SCRIPT_DIR/menus/advanced_virtual_consoles.sh" # shellcheck source=menus/advanced_emergency_hotspot.sh source "$SCRIPT_DIR/menus/advanced_emergency_hotspot.sh" +# shellcheck source=menus/complete_uninstall.sh +# Sourced last: composes the *_do_uninstall/*_do_remove_all/*_do_disable +# helpers defined in every file above it. +source "$SCRIPT_DIR/menus/complete_uninstall.sh" ################################################################################ # Preflight @@ -122,6 +130,7 @@ core_settings_menu_builder() { "Password Protection & Lockout" "WiFi" "Power/Display/Quiet Hours" + "Complete Uninstall" ) MENU_HANDLERS=( sites_menu @@ -131,6 +140,7 @@ core_settings_menu_builder() { lockout_menu wifi_menu power_schedule_menu + complete_uninstall_menu ) } diff --git a/menus/addon_asterisk_intercom.sh b/menus/addon_asterisk_intercom.sh index bb12a6a..7a54274 100644 --- a/menus/addon_asterisk_intercom.sh +++ b/menus/addon_asterisk_intercom.sh @@ -262,18 +262,34 @@ BARESIPUNIT action_uninstall_asterisk_intercom() { echo ask_yes_no "Remove Asterisk Intercom (Baresip)?" "n" || { echo "Cancelled"; pause; return; } + asterisk_intercom_do_uninstall ask + pause +} + +# The actual removal, no confirmation prompt - shared with Complete +# Uninstall so that operation doesn't need to re-implement this teardown +# a second time. $1: "ask" to prompt about config removal interactively +# (the normal case), "purge" to remove config without asking (Complete +# Uninstall). +asterisk_intercom_do_uninstall() { + local data_choice="${1:-ask}" baresip_systemctl_user stop baresip.service 2>/dev/null || true baresip_systemctl_user disable baresip.service 2>/dev/null || true sudo rm -f "$BARESIP_USER_SERVICE_DIR/baresip.service" sudo apt remove -y baresip 2>/dev/null || true - if ask_yes_no "Remove saved SIP configuration too?" "n"; then + local purge_config=false + if [[ "$data_choice" == "purge" ]]; then + purge_config=true + elif [[ "$data_choice" == "ask" ]] && ask_yes_no "Remove saved SIP configuration too?" "n"; then + purge_config=true + fi + + if $purge_config; then sudo rm -rf "$BARESIP_CONFIG_DIR" log_success "Asterisk Intercom removed (configuration deleted)" else log_success "Asterisk Intercom removed (configuration preserved)" fi - - pause } diff --git a/menus/addon_cups.sh b/menus/addon_cups.sh index c3a7db1..54f5b3a 100644 --- a/menus/addon_cups.sh +++ b/menus/addon_cups.sh @@ -132,7 +132,12 @@ EOF action_cups_uninstall() { echo ask_yes_no "Completely remove CUPS, including all queues and settings (purge)?" "n" || { echo "Cancelled"; return; } + cups_do_uninstall +} +# The actual removal, no prompt - shared with Complete Uninstall so that +# operation doesn't need to re-implement CUPS teardown a second time. +cups_do_uninstall() { echo "Performing complete CUPS uninstall..." sudo systemctl stop cups cups-browsed 2>/dev/null || true @@ -149,8 +154,8 @@ action_cups_uninstall() { sudo rm -rf /etc/cups /var/cache/cups /var/spool/cups /var/log/cups /usr/share/cups sudo rm -f "$POLKIT_DIR/kiosk-printing.pkla" - sudo apt autoremove -y - sudo apt clean + sudo apt autoremove -y 2>/dev/null || true + sudo apt clean 2>/dev/null || true log_success "CUPS completely removed" } diff --git a/menus/addon_lms_squeezelite.sh b/menus/addon_lms_squeezelite.sh index 43ef145..ac779a3 100644 --- a/menus/addon_lms_squeezelite.sh +++ b/menus/addon_lms_squeezelite.sh @@ -194,6 +194,16 @@ action_install_lms() { action_uninstall_lms() { echo ask_yes_no "Remove LMS Server?" "n" || { echo "Cancelled"; pause; return; } + lms_do_uninstall ask + pause +} + +# The actual removal, no confirmation prompt - shared with Complete +# Uninstall so that operation doesn't need to re-implement LMS teardown a +# second time. $1: "ask" to prompt about data removal interactively (the +# normal case), "purge" to remove data without asking (Complete Uninstall). +lms_do_uninstall() { + local data_choice="${1:-ask}" local service_name service_name=$(lms_service_name) @@ -212,15 +222,20 @@ action_uninstall_lms() { sudo rm -f /etc/apt/sources.list.d/lms.list sudo rm -f /usr/share/keyrings/lms-keyring.gpg - if ask_yes_no "Remove LMS data and configuration?" "n"; then + local purge_data=false + if [[ "$data_choice" == "purge" ]]; then + purge_data=true + elif [[ "$data_choice" == "ask" ]] && ask_yes_no "Remove LMS data and configuration?" "n"; then + purge_data=true + fi + + if $purge_data; then sudo rm -rf /var/lib/squeezeboxserver sudo rm -rf /etc/squeezeboxserver log_success "LMS and data removed" else log_success "LMS removed (data preserved)" fi - - pause } ################################################################################ @@ -332,13 +347,16 @@ EOF action_uninstall_squeezelite() { echo ask_yes_no "Remove Squeezelite Player?" "n" || { echo "Cancelled"; pause; return; } + squeezelite_do_uninstall + pause +} +# Shared with Complete Uninstall - same reasoning as lms_do_uninstall. +squeezelite_do_uninstall() { sudo systemctl stop squeezelite 2>/dev/null || true sudo systemctl disable squeezelite 2>/dev/null || true sudo rm -f "$SYSTEMD_DIR/squeezelite.service" sudo rm -f "$BIN_DIR/squeezelite-start.sh" sudo apt remove -y squeezelite 2>/dev/null || true log_success "Squeezelite removed" - - pause } diff --git a/menus/addon_remote_access.sh b/menus/addon_remote_access.sh index 1355e5b..d535b95 100644 --- a/menus/addon_remote_access.sh +++ b/menus/addon_remote_access.sh @@ -130,11 +130,15 @@ action_vnc_change_password() { action_vnc_uninstall() { echo ask_yes_no "Remove VNC?" "n" || { echo "Cancelled"; return; } + vnc_do_uninstall +} +# Shared with Complete Uninstall - same reasoning as cups_do_uninstall. +vnc_do_uninstall() { sudo systemctl stop x11vnc 2>/dev/null || true sudo systemctl disable x11vnc 2>/dev/null || true sudo rm -f "$SYSTEMD_DIR/x11vnc.service" - sudo apt remove -y x11vnc + sudo apt remove -y x11vnc 2>/dev/null || true log_success "VNC removed" } @@ -227,10 +231,14 @@ action_wireguard_paste_config() { action_wireguard_uninstall() { echo ask_yes_no "Remove WireGuard?" "n" || { echo "Cancelled"; return; } + wireguard_do_uninstall +} +# Shared with Complete Uninstall - same reasoning as cups_do_uninstall. +wireguard_do_uninstall() { sudo systemctl stop 'wg-quick@*' 2>/dev/null || true sudo systemctl disable 'wg-quick@*' 2>/dev/null || true - sudo apt remove -y wireguard wireguard-tools + sudo apt remove -y wireguard wireguard-tools 2>/dev/null || true log_success "WireGuard removed" } @@ -328,9 +336,13 @@ action_tailscale_show_status() { action_tailscale_uninstall() { echo ask_yes_no "Remove Tailscale?" "n" || { echo "Cancelled"; return; } + tailscale_do_uninstall +} +# Shared with Complete Uninstall - same reasoning as cups_do_uninstall. +tailscale_do_uninstall() { sudo tailscale down 2>/dev/null || true - sudo apt remove -y tailscale + sudo apt remove -y tailscale 2>/dev/null || true log_success "Tailscale removed" } @@ -411,8 +423,12 @@ action_netbird_show_status() { action_netbird_uninstall() { echo ask_yes_no "Remove Netbird?" "n" || { echo "Cancelled"; return; } + netbird_do_uninstall +} +# Shared with Complete Uninstall - same reasoning as cups_do_uninstall. +netbird_do_uninstall() { sudo netbird down 2>/dev/null || true - sudo apt remove -y netbird + sudo apt remove -y netbird 2>/dev/null || true log_success "Netbird removed" } diff --git a/menus/advanced_emergency_hotspot.sh b/menus/advanced_emergency_hotspot.sh index 3c72edc..315cf0c 100644 --- a/menus/advanced_emergency_hotspot.sh +++ b/menus/advanced_emergency_hotspot.sh @@ -291,13 +291,16 @@ UNITEOF action_disable_emergency_hotspot() { echo ask_yes_no "Disable emergency hotspot?" "n" || { echo "Cancelled"; pause; return; } + emergency_hotspot_do_disable + pause +} +# Shared with Complete Uninstall - same reasoning as cups_do_uninstall. +emergency_hotspot_do_disable() { sudo systemctl stop kiosk-emergency-hotspot.service 2>/dev/null || true sudo systemctl disable kiosk-emergency-hotspot.service 2>/dev/null || true sudo rm -f "$SYSTEMD_DIR/kiosk-emergency-hotspot.service" sudo rm -f "$EMERGENCY_HOTSPOT_SCRIPT" sudo systemctl daemon-reload 2>/dev/null || true log_success "Emergency hotspot disabled" - - pause } diff --git a/menus/complete_uninstall.sh b/menus/complete_uninstall.sh new file mode 100644 index 0000000..c013062 --- /dev/null +++ b/menus/complete_uninstall.sh @@ -0,0 +1,151 @@ +#!/bin/bash +################################################################################ +# menus/complete_uninstall.sh - "Complete Uninstall" (Core Settings): full +# teardown, returning the machine to its pre-kiosk state. +# +# Composed from every other addon's own silent uninstall helper +# (cups_do_uninstall, vnc_do_uninstall, wireguard_do_uninstall, +# tailscale_do_uninstall, netbird_do_uninstall, lms_do_uninstall, +# squeezelite_do_uninstall, asterisk_intercom_do_uninstall, +# power_schedule_do_remove_all, emergency_hotspot_do_disable) instead of +# re-implementing removal logic for each addon a second time here - if an +# addon's uninstall logic changes, this picks it up automatically. Only +# the pieces no single addon owns - the kiosk user/files, Node.js/ +# LightDM/Openbox, polkit rules, leftover systemd units - are handled +# directly below, same as the legacy script. +# +# Ordering matters: every addon teardown runs before the kiosk user is +# removed, because asterisk_intercom_do_uninstall still needs +# `id -u "$KIOSK_USER"` to resolve that user's systemd --user session. +# +# After this runs, the kiosk user (and therefore is_kiosk_installed) is +# gone - install.sh itself will refuse to start against this machine +# again until a fresh install re-provisions it. That's intentional: +# there is nothing left here for this tool to manage. +# +# Depends on: lib/menu.sh, lib/config.sh, and every menus/addon_*.sh / +# menus/power_schedule.sh / menus/advanced_emergency_hotspot.sh being +# sourced first (for the *_do_uninstall helpers above). +################################################################################ + +complete_uninstall_status() { + echo "⚠ Removes the kiosk user, every addon, and returns this machine" + echo " to its pre-kiosk state. Cannot be undone." +} + +complete_uninstall_menu_builder() { + MENU_LABELS=("Completely uninstall the kiosk") + MENU_HANDLERS=(action_complete_uninstall) +} + +complete_uninstall_menu() { + run_menu "COMPLETE UNINSTALL" complete_uninstall_menu_builder complete_uninstall_status +} + +################################################################################ +# Actions +################################################################################ + +action_complete_uninstall() { + echo + echo "⚠️ This will COMPLETELY REMOVE:" + echo " • Kiosk user and all data" + echo " • All kiosk configuration and sites" + echo " • All Electron/Node.js installations" + echo " • All browser caches and data" + echo " • CUPS printer system" + echo " • Squeezelite and LMS (Lyrion Music Server)" + echo " • Remote access (VNC, WireGuard, Tailscale, Netbird)" + echo " • Asterisk Intercom (Baresip)" + echo " • LightDM and Openbox" + echo " • All kiosk schedules and services" + echo " • Emergency hotspot configuration" + echo + echo "⚠️ This CANNOT be undone!" + echo + local confirm + confirm=$(ask_text "Type UNINSTALL to confirm" "") + if [[ "$confirm" != "UNINSTALL" ]]; then + echo "Cancelled" + pause + return + fi + + echo + echo "Beginning complete uninstall..." + + echo "[1/12] Stopping kiosk display..." + sudo systemctl stop lightdm 2>/dev/null || true + + echo "[2/12] Removing addons..." + cups_do_uninstall + vnc_do_uninstall + wireguard_do_uninstall + tailscale_do_uninstall + netbird_do_uninstall + lms_do_uninstall purge + squeezelite_do_uninstall + asterisk_intercom_do_uninstall purge + + echo "[3/12] Removing schedules and emergency hotspot..." + power_schedule_do_remove_all + emergency_hotspot_do_disable + + # Must come after every addon teardown above - Asterisk Intercom's + # helper still needs this user to resolve its systemd --user session. + echo "[4/12] Removing kiosk user..." + if id "$KIOSK_USER" &>/dev/null; then + sudo pkill -u "$KIOSK_USER" 2>/dev/null || true + sudo userdel -r "$KIOSK_USER" 2>/dev/null || true + log_success "Kiosk user removed" + fi + + echo "[5/12] Removing kiosk files..." + sudo rm -rf "$KIOSK_DIR" + sudo rm -rf "$KIOSK_HOME" + + echo "[6/12] Removing remaining systemd units..." + sudo rm -f "$SYSTEMD_DIR"/kiosk-*.service + sudo rm -f "$SYSTEMD_DIR"/kiosk-*.timer + sudo systemctl daemon-reload 2>/dev/null || true + + echo "[7/12] Removing remaining scripts..." + sudo rm -f "$BIN_DIR"/kiosk-* + sudo rm -f /etc/udev/rules.d/99-kiosk-hotplug.rules + sudo udevadm control --reload-rules 2>/dev/null || true + + echo "[8/12] Removing Node.js..." + sudo apt-get purge -y nodejs npm 2>/dev/null || true + sudo rm -rf /usr/local/lib/node_modules + sudo rm -rf /usr/local/bin/node + sudo rm -rf /usr/local/bin/npm + + echo "[9/12] Removing LightDM and Openbox..." + sudo systemctl disable lightdm 2>/dev/null || true + sudo apt-get purge -y lightdm openbox 2>/dev/null || true + + echo "[10/12] Removing polkit rules..." + sudo rm -f "$POLKIT_DIR/kiosk-power.pkla" + sudo rm -f "$POLKIT_DIR/kiosk-printing.pkla" + + echo "[11/12] Re-enabling virtual consoles..." + for i in {1..8}; do + sudo systemctl unmask "getty@tty${i}.service" 2>/dev/null || true + done + sudo systemctl daemon-reload 2>/dev/null || true + + echo "[12/12] Cleaning up packages..." + sudo apt-get autoremove -y 2>/dev/null || true + sudo apt-get autoclean 2>/dev/null || true + + echo + log_success "Kiosk completely uninstalled" + echo "The system has been returned to its pre-kiosk state." + echo "You may want to reboot to ensure all changes take effect." + echo + if ask_yes_no "Reboot now?" "n"; then + echo "Rebooting in 3 seconds..." + sleep 3 + sudo reboot + fi +} diff --git a/menus/power_schedule.sh b/menus/power_schedule.sh index eb9cc03..c2aad74 100644 --- a/menus/power_schedule.sh +++ b/menus/power_schedule.sh @@ -628,7 +628,12 @@ action_disable_electron_reload() { action_remove_all_schedules() { echo ask_yes_no "Remove ALL power/display/quiet/reload schedules?" "n" || { echo "Cancelled"; return; } + power_schedule_do_remove_all +} +# The actual removal, no prompt - shared with Complete Uninstall so that +# operation doesn't need to re-implement schedule teardown a second time. +power_schedule_do_remove_all() { for timer in kiosk-shutdown kiosk-display-off kiosk-display-on kiosk-quiet-start kiosk-quiet-end kiosk-electron-reload; do sudo systemctl stop "${timer}.timer" 2>/dev/null || true sudo systemctl disable "${timer}.timer" 2>/dev/null || true diff --git a/ubuntu-based-kiosk.sh b/ubuntu-based-kiosk.sh index 829f3f3..accce64 100644 --- a/ubuntu-based-kiosk.sh +++ b/ubuntu-based-kiosk.sh @@ -1,8 +1,48 @@ #!/bin/bash ################################################################################ -### Ubuntu Based Kiosk v2.11.0 ### +### Ubuntu Based Kiosk v2.12.0 ### ################################################################################ # +# RELEASE v2.12.0 - Complete Uninstall Migrated (Last of the +# "Destructive Trio"); Composed, Not Re-Implemented +# - New in ./install.sh's Core Settings menu: Complete Uninstall +# (menus/complete_uninstall.sh). Rather than re-implementing every +# addon's teardown a second time (the shape this function had in the +# legacy script - CUPS/VNC/WireGuard/Tailscale/Netbird/LMS/Squeezelite +# removal logic all inlined again, independently of the same logic in +# each addon's own uninstall action), it composes the *_do_uninstall +# helpers each addon already has. If an addon's removal logic changes, +# Complete Uninstall picks it up automatically instead of silently +# drifting out of sync. +# - Every addon menu that had an uninstall action (CUPS, VNC, WireGuard, +# Tailscale, Netbird, LMS, Squeezelite, Asterisk Intercom) plus +# power_schedule's "remove all schedules" and the Emergency Hotspot +# disable action were each split into a confirm-and-call wrapper (the +# existing interactive action, unchanged from the user's perspective) +# and a silent do-the-removal helper that both the wrapper and +# Complete Uninstall call - no duplicated removal logic anywhere. +# - IMPORTANT bug found and fixed while composing these: several +# *_do_uninstall helpers (CUPS's `apt autoremove`/`apt clean`, and +# VNC/WireGuard/Tailscale/Netbird's `apt remove`) had a bare, unguarded +# `apt` call as their second-to-last statement. Previously this only +# risked aborting that one menu action if the package was already +# gone (silently caught by run_menu's own guard) - a minor UX +# blemish. Composed together as bare sequential calls inside Complete +# Uninstall, the same failure would have silently truncated the +# *entire* uninstall sequence partway through - e.g. the kiosk user +# might never get removed because an already-uninstalled VPN client's +# `apt remove` failed first. Guarded all of them with `|| true`, +# fixing the risk in both the standalone action and the composition. +# - Non-addon teardown (kiosk user/files, Node.js, LightDM/Openbox, +# remaining systemd units/scripts, polkit rules, re-enabling virtual +# consoles, final package cleanup) stays inline in +# menus/complete_uninstall.sh, same as the legacy script, since no +# single addon owns those paths. +# - Upgrade and Full Reinstall remain in ubuntu-based-kiosk.sh only - +# both are fundamentally coupled to this file's own heredoc self- +# extraction of main.js/preload.js/etc, which has no equivalent in the +# modular system yet. This closes out the "destructive trio." +# # RELEASE v2.11.0 - 4 More Advanced Items Migrated (Electron Maintenance, # Factory Reset, Virtual Consoles, Emergency Hotspot) # - New in ./install.sh's Advanced menu, alongside Diagnostics: @@ -410,7 +450,7 @@ set -euo pipefail ### SECTION 1: CONSTANTS & GLOBALS ################################################################################ -SCRIPT_VERSION="2.11.0" +SCRIPT_VERSION="2.12.0" # Resolve the real path to this script file. # When piped (curl|bash or wget|bash), BASH_SOURCE[0] is a pipe descriptor, From 5d56c667da47f136c0e2ecc160f2810ca8e5c912 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 03:45:36 +0000 Subject: [PATCH 15/19] 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 Claude-Session: https://claude.ai/code/session_01VfsFSoRqfbRG7XAg5RoE7e --- Readme.md | 29 +++-- install.sh | 12 +- menus/fleet_profile.sh | 258 +++++++++++++++++++++++++++++++++++++++++ ubuntu-based-kiosk.sh | 28 ++++- 4 files changed, 314 insertions(+), 13 deletions(-) create mode 100644 menus/fleet_profile.sh diff --git a/Readme.md b/Readme.md index a0fc3c4..d9cfe56 100644 --- a/Readme.md +++ b/Readme.md @@ -1,6 +1,6 @@ # Ubuntu Based Kiosk -**Current Version:** 2.12.0 (check script header for latest version) +**Current Version:** 2.13.0 (check script header for latest version) **Built with Claude Sonnet 4.6 AI assistance** **License:** GPL v3 - Keep derivatives open source **Repository:** https://github.com/outis1one/ubuntu-based-kiosk/ @@ -1255,6 +1255,11 @@ terminal menu and the web UI, so they can't drift apart). Settings): the last of the "destructive trio." Composed from every addon's own `*_do_uninstall` helper instead of re-implementing removal a second time — see "Recent Updates (v2.12.0)" below. +- `menus/fleet_profile.sh` — **Fleet Profile** (Advanced): export/apply + the portable parts of `config.json` across several kiosks that should + 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. - `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: @@ -1269,11 +1274,14 @@ most of the old installer. `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, Export/Import -Settings, 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). +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; Fleet Profile 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 @@ -1305,9 +1313,14 @@ full migration pass. ## Project Status & Future Plans -**Current Version:** 2.12.0 +**Current Version:** 2.13.0 -**Recent Updates (v2.12.0):** +**Recent Updates (v2.13.0):** +- **New: Fleet Profile** (Advanced → Fleet Profile) — 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. + +**Previous (v2.12.0):** - **Complete Uninstall migrated** — the last of the "destructive trio." Rather than re-implementing every addon's teardown a second time (the legacy shape — CUPS/VNC/WireGuard/Tailscale/Netbird/LMS/Squeezelite removal all inlined again, independently of each addon's own uninstall action), `menus/complete_uninstall.sh` composes the `*_do_uninstall` helpers each addon already has. Every addon menu with an uninstall action was split into a confirm-and-call wrapper (unchanged from the user's perspective) plus a silent removal helper that both the wrapper and Complete Uninstall call — no duplicated logic anywhere, and if an addon's removal logic changes later, Complete Uninstall picks it up automatically. - **Important bug found and fixed 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. 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 partway through — e.g. the kiosk user might never get removed because an already-uninstalled VPN client's `apt remove` failed first. Guarded all of them with `|| true`. - 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 — same as the legacy script. diff --git a/install.sh b/install.sh index 26a9ccb..2d40946 100755 --- a/install.sh +++ b/install.sh @@ -30,7 +30,10 @@ # manual update, fix blank screen), Factory Reset # (menus/advanced_factory_reset.sh), Virtual Consoles # (menus/advanced_virtual_consoles.sh), Emergency Hotspot -# (menus/advanced_emergency_hotspot.sh). +# (menus/advanced_emergency_hotspot.sh), Fleet Profile +# (menus/fleet_profile.sh - export/apply portable settings across +# several kiosks; deliberately excludes machine-bound credentials +# like Authelia/WireGuard/Asterisk Intercom - see the file header). # # Usage (once the kiosk has already been installed): # git clone @@ -84,6 +87,9 @@ source "$SCRIPT_DIR/menus/advanced_emergency_hotspot.sh" # Sourced last: 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/fleet_profile.sh +# Also composes detection helpers (*_is_installed) from every addon above. +source "$SCRIPT_DIR/menus/fleet_profile.sh" ################################################################################ # Preflight @@ -158,8 +164,8 @@ addons_menu() { } advanced_menu_builder() { - MENU_LABELS=("Diagnostics" "Electron Maintenance" "Factory Reset" "Virtual Consoles" "Emergency Hotspot") - MENU_HANDLERS=(diagnostics_menu advanced_electron_menu advanced_factory_reset_menu advanced_virtual_consoles_menu advanced_emergency_hotspot_menu) + MENU_LABELS=("Diagnostics" "Electron Maintenance" "Factory Reset" "Virtual Consoles" "Emergency Hotspot" "Fleet Profile") + MENU_HANDLERS=(diagnostics_menu advanced_electron_menu advanced_factory_reset_menu advanced_virtual_consoles_menu advanced_emergency_hotspot_menu fleet_profile_menu) } advanced_menu() { diff --git a/menus/fleet_profile.sh b/menus/fleet_profile.sh new file mode 100644 index 0000000..776000c --- /dev/null +++ b/menus/fleet_profile.sh @@ -0,0 +1,258 @@ +#!/bin/bash +################################################################################ +# menus/fleet_profile.sh - "Fleet Profile" (Advanced): export the portable +# parts of this kiosk's configuration to a JSON file, and apply that file +# to other already-installed kiosks - for standing up several kiosks that +# should share the same sites/settings. +# +# This is deliberately an MVP, not the legacy Export/Import Settings +# redesigned 1:1. It moves only what's actually safe to copy between +# machines automatically: +# - config.json's portable fields (sites, display/touch, navigation, +# lockout, password protection) - plain data, no machine binding. +# - Which addons were present at export time, as an informational +# checklist on apply - NOT automated installation. That's a +# deliberately separate, bigger follow-up (each addon would need a +# non-interactive install variant, mirroring the *_do_uninstall +# helpers Complete Uninstall already composes). +# +# Explicitly NOT exported, because copying them would be actively wrong, +# not just incomplete: +# - Authelia credentials: encrypted with a key derived from this +# machine's /etc/machine-id (addon_authelia.sh) - decrypts to +# garbage on any other machine. +# - WireGuard/VPN identity: a private key is that device's identity; +# reusing one across machines is a peer conflict, not a saving. +# - Asterisk Intercom's SIP extension: most PBXes reject two +# simultaneous registrations to the same extension. +# Apply prints all three as an explicit "needs a human" checklist rather +# than silently skipping them. +# +# Depends on: lib/menu.sh, lib/config.sh, and every menus/addon_*.sh +# being sourced first (for the *_is_installed detection helpers). +################################################################################ + +fleet_profile_status() { + echo "Exports/applies sites, display, navigation, and lockout settings" + echo "between already-installed kiosks. Addon credentials that are" + echo "bound to one machine (Authelia, WireGuard, Asterisk Intercom)" + echo "are never copied - see the checklist after applying a profile." +} + +fleet_profile_menu_builder() { + MENU_LABELS=("Export fleet profile" "Apply fleet profile") + MENU_HANDLERS=(action_export_fleet_profile action_apply_fleet_profile) +} + +fleet_profile_menu() { + run_menu "FLEET PROFILE" fleet_profile_menu_builder fleet_profile_status +} + +################################################################################ +# Helpers +################################################################################ + +# JSON array of addon identifiers currently present on this machine. +fleet_detect_addons() { + local addons=() + + cups_is_installed 2>/dev/null && addons+=("cups") + lms_is_installed 2>/dev/null && addons+=("lms") + squeezelite_is_installed 2>/dev/null && addons+=("squeezelite") + is_service_active x11vnc 2>/dev/null && addons+=("vnc") + command -v wg &>/dev/null && addons+=("wireguard") + command -v tailscale &>/dev/null && addons+=("tailscale") + command -v netbird &>/dev/null && addons+=("netbird") + baresip_is_installed 2>/dev/null && addons+=("asterisk_intercom") + + if sudo -u "$KIOSK_USER" test -f "$CONFIG_PATH" 2>/dev/null; then + local authelia_url + authelia_url=$(sudo -u "$KIOSK_USER" jq -r '.autheliaURL // ""' "$CONFIG_PATH" 2>/dev/null || true) + [[ -n "$authelia_url" ]] && addons+=("authelia") + fi + + if [[ "${#addons[@]}" -eq 0 ]]; then + echo "[]" + else + printf '%s\n' "${addons[@]}" | jq -R . | jq -s . || echo "[]" + fi +} + +################################################################################ +# Actions +################################################################################ + +action_export_fleet_profile() { + echo + if ! sudo -u "$KIOSK_USER" test -f "$CONFIG_PATH" 2>/dev/null; then + log_error "config.json not found at $CONFIG_PATH - configure sites/settings first" + pause + return 1 + fi + + local out_path + out_path=$(ask_text "Export profile to" "$HOME/kiosk-fleet-profile.json") + + local raw_config + raw_config=$(sudo -u "$KIOSK_USER" cat "$CONFIG_PATH" 2>/dev/null) + if ! echo "$raw_config" | jq empty 2>/dev/null; then + log_error "config.json is not valid JSON - cannot export" + pause + return 1 + fi + + local settings + settings=$(echo "$raw_config" | jq 'del(.autheliaURL, .autheliaUsername, .autheliaEncryptedPassword)') + + local addons_present + addons_present=$(fleet_detect_addons) + + jq -n \ + --argjson settings "$settings" \ + --argjson addons "$addons_present" \ + --arg script_version "${SCRIPT_VERSION:-unknown}" \ + '{profile_version: 1, script_version: $script_version, settings: $settings, addons_present: $addons}' \ + > "$out_path" + + log_success "Fleet profile exported to $out_path" + echo + echo "Included: sites, display/touch/navigation settings, lockout," + echo "password protection (SHA-256 hash only)." + echo + echo "NOT included (needs fresh setup on each new device):" + echo " - Authelia credentials (encrypted per-machine, won't decrypt elsewhere)" + echo " - WireGuard/VPN keys (each device needs its own identity)" + echo " - Asterisk Intercom extension (most PBXes reject duplicate registrations)" + + pause +} + +action_apply_fleet_profile() { + echo + local in_path + in_path=$(ask_text "Profile file to apply" "") + if [[ -z "$in_path" ]]; then + echo "Cancelled" + pause + return + fi + if [[ ! -f "$in_path" ]]; then + log_error "File not found: $in_path" + pause + return 1 + fi + if ! jq empty "$in_path" 2>/dev/null; then + log_error "Not valid JSON: $in_path" + pause + return 1 + fi + + local settings addons_present + settings=$(jq -c '.settings // {}' "$in_path" || echo '{}') + addons_present=$(jq -r '.addons_present[]? // empty' "$in_path" || true) + + echo "Profile summary:" + echo " Sites: $(echo "$settings" | jq '.tabs | length // 0')" + echo " Addons expected: $(echo "$addons_present" | tr '\n' ' ')" + echo + ask_yes_no "Apply this profile? This overwrites current sites/display/lockout settings." "n" || { echo "Cancelled"; pause; return; } + + sudo mkdir -p "$KIOSK_DIR" + sudo chown -R "$KIOSK_USER:$KIOSK_USER" "$KIOSK_DIR" + + local existing="{}" + if sudo -u "$KIOSK_USER" test -f "$CONFIG_PATH" 2>/dev/null; then + existing=$(sudo -u "$KIOSK_USER" cat "$CONFIG_PATH" 2>/dev/null) + echo "$existing" | jq empty 2>/dev/null || existing="{}" + fi + + # Merge, not replace - same reasoning as save_config: this machine's + # own Authelia fields (never in the exported settings blob) must + # survive an apply untouched. Guarded: $settings came from an + # external file - a corrupted/hand-edited profile whose "settings" + # key isn't a JSON object must not be allowed to crash the session. + local merged + if ! merged=$(echo "$existing" | jq --argjson s "$settings" '. + $s' 2>/dev/null); then + log_error "Profile's settings are not a valid JSON object - nothing was changed" + pause + return 1 + fi + + local tmp + tmp=$(mktemp) + echo "$merged" > "$tmp" + sudo -u "$KIOSK_USER" bash -c "cat > '$CONFIG_PATH'" < "$tmp" + sudo -u "$KIOSK_USER" chmod 644 "$CONFIG_PATH" + rm -f "$tmp" + + log_success "Settings applied" + + echo + echo "Addon checklist (from the exported profile):" + local missing_any=false + if [[ -z "$addons_present" ]]; then + echo " (profile recorded no addons)" + fi + while IFS= read -r addon; do + [[ -z "$addon" ]] && continue + case "$addon" in + cups) + if cups_is_installed; then + echo " [x] CUPS Printing - already installed" + else + echo " [ ] CUPS Printing - not installed, install via Addons" + missing_any=true + fi ;; + lms) + if lms_is_installed; then + echo " [x] LMS Server - already installed" + else + echo " [ ] LMS Server - not installed, install via Addons" + missing_any=true + fi ;; + squeezelite) + if squeezelite_is_installed; then + echo " [x] Squeezelite Player - already installed" + else + echo " [ ] Squeezelite Player - not installed, install via Addons" + missing_any=true + fi ;; + vnc) + if is_service_active x11vnc; then + echo " [x] VNC - already installed" + else + echo " [ ] VNC - not installed, install via Addons" + missing_any=true + fi ;; + wireguard) + echo " [!] WireGuard - needs a NEW keypair/peer on this device, never clone the key" ;; + tailscale) + if command -v tailscale &>/dev/null; then + echo " [x] Tailscale - installed (connect with a reusable auth key if not yet connected)" + else + echo " [ ] Tailscale - not installed, install via Addons" + missing_any=true + fi ;; + netbird) + if command -v netbird &>/dev/null; then + echo " [x] Netbird - installed (connect with a reusable setup key if not yet connected)" + else + echo " [ ] Netbird - not installed, install via Addons" + missing_any=true + fi ;; + asterisk_intercom) + echo " [!] Asterisk Intercom - needs its own SIP extension on this device" ;; + authelia) + echo " [!] Authelia - needs a fresh login on this device" ;; + *) + echo " [?] $addon - unrecognized entry in profile" ;; + esac + done <<< "$addons_present" + + if $missing_any; then + echo + echo "Install anything marked \"not installed\" above via the Addons menu." + fi + + pause +} diff --git a/ubuntu-based-kiosk.sh b/ubuntu-based-kiosk.sh index accce64..59e6c13 100644 --- a/ubuntu-based-kiosk.sh +++ b/ubuntu-based-kiosk.sh @@ -1,8 +1,32 @@ #!/bin/bash ################################################################################ -### Ubuntu Based Kiosk v2.12.0 ### +### Ubuntu Based Kiosk v2.13.0 ### ################################################################################ # +# RELEASE v2.13.0 - Fleet Profile: New MVP for Standing Up Several +# Kiosks with the Same Settings +# - New in ./install.sh's Advanced menu: Fleet Profile +# (menus/fleet_profile.sh). Not a port of the legacy Export/Import +# Settings - a narrower, deliberately-scoped feature for the "set up +# one kiosk, then stamp out a dozen more like it" use case: export the +# portable parts of config.json (sites, display/touch/navigation, +# lockout, password protection) to a JSON file, apply that file to any +# other already-installed kiosk. +# - Explicitly does NOT copy machine-bound credentials, because copying +# them would be actively wrong, not just incomplete: Authelia's +# encrypted password is keyed off /etc/machine-id and decrypts to +# garbage elsewhere; a WireGuard private key is a device identity and +# reusing one across machines is a peer conflict; most Asterisk PBXes +# reject two simultaneous registrations to the same extension. Apply +# prints these as an explicit "needs a human" checklist instead of +# silently skipping them or (worse) cloning them. +# - Does not install missing addons - only records which addons were +# present at export time and reports which of those are/aren't +# present on the machine being applied to. Non-interactive addon +# installation (so applying a profile needs zero prompts, scriptable +# over SSH to a whole fleet) is deliberately left as a follow-up, not +# bundled into this MVP. +# # RELEASE v2.12.0 - Complete Uninstall Migrated (Last of the # "Destructive Trio"); Composed, Not Re-Implemented # - New in ./install.sh's Core Settings menu: Complete Uninstall @@ -450,7 +474,7 @@ set -euo pipefail ### SECTION 1: CONSTANTS & GLOBALS ################################################################################ -SCRIPT_VERSION="2.12.0" +SCRIPT_VERSION="2.13.0" # Resolve the real path to this script file. # When piped (curl|bash or wget|bash), BASH_SOURCE[0] is a pipe descriptor, From 1c9447bc5d5b1da5fe9dc0a0fa0768a3eb87e3d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 04:29:13 +0000 Subject: [PATCH 16/19] 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 Claude-Session: https://claude.ai/code/session_01VfsFSoRqfbRG7XAg5RoE7e --- Readme.md | 6 ++--- install.sh | 12 ++++----- menus/{fleet_profile.sh => clone_settings.sh} | 26 +++++++++---------- ubuntu-based-kiosk.sh | 6 ++--- 4 files changed, 25 insertions(+), 25 deletions(-) rename menus/{fleet_profile.sh => clone_settings.sh} (93%) diff --git a/Readme.md b/Readme.md index d9cfe56..dbe9e81 100644 --- a/Readme.md +++ b/Readme.md @@ -1255,7 +1255,7 @@ terminal menu and the web UI, so they can't drift apart). Settings): the last of the "destructive trio." Composed from every addon's own `*_do_uninstall` helper instead of re-implementing removal a second time — see "Recent Updates (v2.12.0)" below. -- `menus/fleet_profile.sh` — **Fleet Profile** (Advanced): export/apply +- `menus/clone_settings.sh` — **Clone Settings** (Advanced): export/apply the portable parts of `config.json` across several kiosks that should share the same settings. New, not a legacy port — deliberately never copies machine-bound credentials (Authelia, WireGuard, Asterisk @@ -1279,7 +1279,7 @@ 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; Fleet Profile is a new, +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 @@ -1316,7 +1316,7 @@ full migration pass. **Current Version:** 2.13.0 **Recent Updates (v2.13.0):** -- **New: Fleet Profile** (Advanced → Fleet Profile) — 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. +- **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 2d40946..279673b 100755 --- a/install.sh +++ b/install.sh @@ -30,8 +30,8 @@ # manual update, fix blank screen), Factory Reset # (menus/advanced_factory_reset.sh), Virtual Consoles # (menus/advanced_virtual_consoles.sh), Emergency Hotspot -# (menus/advanced_emergency_hotspot.sh), Fleet Profile -# (menus/fleet_profile.sh - export/apply portable settings across +# (menus/advanced_emergency_hotspot.sh), Clone Settings +# (menus/clone_settings.sh - export/apply portable settings across # several kiosks; deliberately excludes machine-bound credentials # like Authelia/WireGuard/Asterisk Intercom - see the file header). # @@ -87,9 +87,9 @@ source "$SCRIPT_DIR/menus/advanced_emergency_hotspot.sh" # Sourced last: 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/fleet_profile.sh +# shellcheck source=menus/clone_settings.sh # Also composes detection helpers (*_is_installed) from every addon above. -source "$SCRIPT_DIR/menus/fleet_profile.sh" +source "$SCRIPT_DIR/menus/clone_settings.sh" ################################################################################ # Preflight @@ -164,8 +164,8 @@ addons_menu() { } advanced_menu_builder() { - MENU_LABELS=("Diagnostics" "Electron Maintenance" "Factory Reset" "Virtual Consoles" "Emergency Hotspot" "Fleet Profile") - MENU_HANDLERS=(diagnostics_menu advanced_electron_menu advanced_factory_reset_menu advanced_virtual_consoles_menu advanced_emergency_hotspot_menu fleet_profile_menu) + MENU_LABELS=("Diagnostics" "Electron Maintenance" "Factory Reset" "Virtual Consoles" "Emergency Hotspot" "Clone Settings") + MENU_HANDLERS=(diagnostics_menu advanced_electron_menu advanced_factory_reset_menu advanced_virtual_consoles_menu advanced_emergency_hotspot_menu clone_settings_menu) } advanced_menu() { diff --git a/menus/fleet_profile.sh b/menus/clone_settings.sh similarity index 93% rename from menus/fleet_profile.sh rename to menus/clone_settings.sh index 776000c..b61c0b1 100644 --- a/menus/fleet_profile.sh +++ b/menus/clone_settings.sh @@ -1,6 +1,6 @@ #!/bin/bash ################################################################################ -# menus/fleet_profile.sh - "Fleet Profile" (Advanced): export the portable +# menus/clone_settings.sh - "Clone Settings" (Advanced): export the portable # parts of this kiosk's configuration to a JSON file, and apply that file # to other already-installed kiosks - for standing up several kiosks that # should share the same sites/settings. @@ -32,20 +32,20 @@ # being sourced first (for the *_is_installed detection helpers). ################################################################################ -fleet_profile_status() { +clone_settings_status() { echo "Exports/applies sites, display, navigation, and lockout settings" echo "between already-installed kiosks. Addon credentials that are" echo "bound to one machine (Authelia, WireGuard, Asterisk Intercom)" echo "are never copied - see the checklist after applying a profile." } -fleet_profile_menu_builder() { - MENU_LABELS=("Export fleet profile" "Apply fleet profile") - MENU_HANDLERS=(action_export_fleet_profile action_apply_fleet_profile) +clone_settings_menu_builder() { + MENU_LABELS=("Export settings" "Apply settings (clone)") + MENU_HANDLERS=(action_export_clone_settings action_apply_clone_settings) } -fleet_profile_menu() { - run_menu "FLEET PROFILE" fleet_profile_menu_builder fleet_profile_status +clone_settings_menu() { + run_menu "CLONE SETTINGS" clone_settings_menu_builder clone_settings_status } ################################################################################ @@ -53,7 +53,7 @@ fleet_profile_menu() { ################################################################################ # JSON array of addon identifiers currently present on this machine. -fleet_detect_addons() { +clone_detect_addons() { local addons=() cups_is_installed 2>/dev/null && addons+=("cups") @@ -82,7 +82,7 @@ fleet_detect_addons() { # Actions ################################################################################ -action_export_fleet_profile() { +action_export_clone_settings() { echo if ! sudo -u "$KIOSK_USER" test -f "$CONFIG_PATH" 2>/dev/null; then log_error "config.json not found at $CONFIG_PATH - configure sites/settings first" @@ -91,7 +91,7 @@ action_export_fleet_profile() { fi local out_path - out_path=$(ask_text "Export profile to" "$HOME/kiosk-fleet-profile.json") + out_path=$(ask_text "Export profile to" "$HOME/kiosk-clone-settings.json") local raw_config raw_config=$(sudo -u "$KIOSK_USER" cat "$CONFIG_PATH" 2>/dev/null) @@ -105,7 +105,7 @@ action_export_fleet_profile() { settings=$(echo "$raw_config" | jq 'del(.autheliaURL, .autheliaUsername, .autheliaEncryptedPassword)') local addons_present - addons_present=$(fleet_detect_addons) + addons_present=$(clone_detect_addons) jq -n \ --argjson settings "$settings" \ @@ -114,7 +114,7 @@ action_export_fleet_profile() { '{profile_version: 1, script_version: $script_version, settings: $settings, addons_present: $addons}' \ > "$out_path" - log_success "Fleet profile exported to $out_path" + log_success "Settings exported to $out_path" echo echo "Included: sites, display/touch/navigation settings, lockout," echo "password protection (SHA-256 hash only)." @@ -127,7 +127,7 @@ action_export_fleet_profile() { pause } -action_apply_fleet_profile() { +action_apply_clone_settings() { echo local in_path in_path=$(ask_text "Profile file to apply" "") diff --git a/ubuntu-based-kiosk.sh b/ubuntu-based-kiosk.sh index 59e6c13..9ebae3a 100644 --- a/ubuntu-based-kiosk.sh +++ b/ubuntu-based-kiosk.sh @@ -3,10 +3,10 @@ ### Ubuntu Based Kiosk v2.13.0 ### ################################################################################ # -# RELEASE v2.13.0 - Fleet Profile: New MVP for Standing Up Several +# RELEASE v2.13.0 - Clone Settings: New MVP for Standing Up Several # Kiosks with the Same Settings -# - New in ./install.sh's Advanced menu: Fleet Profile -# (menus/fleet_profile.sh). Not a port of the legacy Export/Import +# - New in ./install.sh's Advanced menu: Clone Settings +# (menus/clone_settings.sh). Not a port of the legacy Export/Import # Settings - a narrower, deliberately-scoped feature for the "set up # one kiosk, then stamp out a dozen more like it" use case: export the # portable parts of config.json (sites, display/touch/navigation, From 037a62e008ec622cf35c4ac7a3a35fe5085b50c9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 05:24:32 +0000 Subject: [PATCH 17/19] 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, From d411b05cae1a81ba3224b1e85d3dc8ce8f10af8e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 13:50:25 +0000 Subject: [PATCH 18/19] 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 Claude-Session: https://claude.ai/code/session_01VfsFSoRqfbRG7XAg5RoE7e --- Readme.md | 47 ++++++++----- install.sh | 25 ++++--- menus/advanced_upgrade.sh | 144 ++++++++++++++++++++++++++++++++++++++ ubuntu-based-kiosk.sh | 24 ++++++- 4 files changed, 212 insertions(+), 28 deletions(-) create mode 100644 menus/advanced_upgrade.sh diff --git a/Readme.md b/Readme.md index 2c4fec7..bb952c0 100644 --- a/Readme.md +++ b/Readme.md @@ -55,10 +55,10 @@ 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. +> provision a kiosk from scratch now, and has its own Upgrade, 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 +> Full Reinstall of an existing install. --- @@ -1280,13 +1280,18 @@ terminal menu and the web UI, so they can't drift apart). 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. + directory during provisioning and re-copied during 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`. +- `menus/advanced_upgrade.sh` — **Upgrade** (Advanced): `git pull` (only + as a clean fast-forward) plus re-running the same + packages/kiosk-app/display/firewall/power-management provisioning + steps, so any code or hardware-config change picked up by the pull + actually takes effect. Also offers an on-demand Electron version + check. See "Recent Updates (v2.15.0)" below. - `install.sh` — entry point for the modular tool, now grouped **Core Settings / Addons / Advanced** like the legacy menu. On a machine with no kiosk installed yet, it provisions one first (see @@ -1298,16 +1303,17 @@ terminal menu and the web UI, so they can't drift apart). ./install.sh ``` -**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. 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 +**Honest status:** first-time installation and Upgrade are now covered — +`install.sh` provisions a kiosk from a bare Ubuntu Server box, not just +an already-installed one, and can pull/apply its own updates — 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. One piece +remains legacy-only: Full Reinstall, 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 @@ -1341,9 +1347,14 @@ full migration pass. ## Project Status & Future Plans -**Current Version:** 2.14.0 +**Current Version:** 2.15.0 -**Recent Updates (v2.14.0):** +**Recent Updates (v2.15.0):** +- **New: Upgrade** (Advanced → Upgrade) — not a port of the legacy Upgrade, which re-extracted `main.js`/`preload.js`/etc from its own heredocs on every run. `kiosk-app/` and `provision/files/` are real files in this git checkout now, so the modular Upgrade is `git pull` (after confirming a clean working tree, 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. Skips the interactive first-run settings wizard and the "reboot now" prompt. +- Also offers an on-demand Electron version check regardless of whether there was code to pull (Electron isn't versioned by this repo) — reuses the existing, already-tested `action_update_electron` as-is. +- Requires a real git checkout (not the no-git ZIP download option) and a clean working tree; a diverged local history fails the pull cleanly with a clear message rather than attempting an automatic merge. + +**Previous (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. diff --git a/install.sh b/install.sh index a94ec96..65f7f73 100755 --- a/install.sh +++ b/install.sh @@ -11,11 +11,13 @@ # # 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. +# new kiosk. Upgrade (Advanced -> Upgrade) is now here too, but not a +# port of the legacy version - that one re-extracted heredocs on every +# run; kiosk-app/ and provision/files/ are real files in this git +# checkout, so the modular Upgrade is `git pull` + re-running the same +# provisioning steps, reused rather than reimplemented (see +# menus/advanced_upgrade.sh). Full Reinstall remains legacy-only - it +# has no equivalent here yet. # # Migrated so far, grouped the same way the legacy menu groups them: # Core Settings: Sites & Page Timing, Display & Interaction, Timezone, @@ -36,7 +38,9 @@ # (menus/advanced_emergency_hotspot.sh), Clone Settings # (menus/clone_settings.sh - export/apply portable settings across # several kiosks; deliberately excludes machine-bound credentials -# like Authelia/WireGuard/Asterisk Intercom - see the file header). +# like Authelia/WireGuard/Asterisk Intercom - see the file header), +# Upgrade (menus/advanced_upgrade.sh - git pull + re-provision, plus +# an on-demand Electron version check). # # Usage (works whether or not a kiosk is already installed): # git clone @@ -82,6 +86,11 @@ source "$SCRIPT_DIR/menus/addon_lms_squeezelite.sh" source "$SCRIPT_DIR/menus/addon_asterisk_intercom.sh" # shellcheck source=menus/advanced_electron.sh source "$SCRIPT_DIR/menus/advanced_electron.sh" +# shellcheck source=menus/advanced_upgrade.sh +# Depends on action_update_electron above and the provision_* functions +# sourced later (lib/provision.sh) - safe either way, bash resolves +# function calls at run time, not source time. +source "$SCRIPT_DIR/menus/advanced_upgrade.sh" # shellcheck source=menus/advanced_factory_reset.sh source "$SCRIPT_DIR/menus/advanced_factory_reset.sh" # shellcheck source=menus/advanced_virtual_consoles.sh @@ -164,8 +173,8 @@ addons_menu() { } advanced_menu_builder() { - MENU_LABELS=("Diagnostics" "Electron Maintenance" "Factory Reset" "Virtual Consoles" "Emergency Hotspot" "Clone Settings") - MENU_HANDLERS=(diagnostics_menu advanced_electron_menu advanced_factory_reset_menu advanced_virtual_consoles_menu advanced_emergency_hotspot_menu clone_settings_menu) + MENU_LABELS=("Diagnostics" "Electron Maintenance" "Factory Reset" "Virtual Consoles" "Emergency Hotspot" "Clone Settings" "Upgrade") + MENU_HANDLERS=(diagnostics_menu advanced_electron_menu advanced_factory_reset_menu advanced_virtual_consoles_menu advanced_emergency_hotspot_menu clone_settings_menu advanced_upgrade_menu) } advanced_menu() { diff --git a/menus/advanced_upgrade.sh b/menus/advanced_upgrade.sh new file mode 100644 index 0000000..96502fa --- /dev/null +++ b/menus/advanced_upgrade.sh @@ -0,0 +1,144 @@ +#!/bin/bash +################################################################################ +# menus/advanced_upgrade.sh - "Upgrade" (Advanced): pull the latest code +# from git and re-apply provisioning, plus an on-demand Electron update. +# +# ubuntu-based-kiosk.sh's Upgrade extracted fresh copies of main.js/ +# preload.js/etc from its own heredocs on every run - the modular tool +# has no heredocs to extract from. kiosk-app/ and provision/files/ are +# real files in this git checkout, so "get whatever's new" is just +# `git pull` followed by re-running the same steps lib/provision.sh +# already has for a fresh install - reused here, not reimplemented, +# minus the interactive first-run settings wizard and the "reboot now" +# prompt (upgrading shouldn't re-ask sites/hotspot/vconsoles or reboot +# the whole machine). Electron itself isn't versioned by this repo, so +# checking for a newer Electron is a separate step: the existing, +# already-tested action_update_electron (menus/advanced_electron.sh), +# reused as-is rather than duplicated. +# +# Depends on: lib/menu.sh, lib/config.sh, lib/electron.sh, +# lib/provision.sh, menus/advanced_electron.sh being sourced first. +################################################################################ + +advanced_upgrade_status() { + if git -C "$SCRIPT_DIR" rev-parse --is-inside-work-tree &>/dev/null; then + local branch rev + branch=$(git -C "$SCRIPT_DIR" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown") + rev=$(git -C "$SCRIPT_DIR" rev-parse --short HEAD 2>/dev/null || echo "unknown") + echo "Installed from git: $branch @ $rev" + else + echo "Installed from git: not a git checkout (upgrade unavailable)" + fi +} + +advanced_upgrade_menu_builder() { + MENU_LABELS=("Check for and apply updates") + MENU_HANDLERS=(action_upgrade) +} + +advanced_upgrade_menu() { + run_menu "UPGRADE" advanced_upgrade_menu_builder advanced_upgrade_status +} + +################################################################################ +# Actions +################################################################################ + +action_upgrade() { + echo + if ! command -v git &>/dev/null; then + log_error "git is not installed - can't check for updates" + pause + return 1 + fi + + if ! git -C "$SCRIPT_DIR" rev-parse --is-inside-work-tree &>/dev/null; then + log_error "$SCRIPT_DIR is not a git checkout - re-clone the repo to get this feature" + pause + return 1 + fi + + if [[ -n "$(git -C "$SCRIPT_DIR" status --porcelain)" ]]; then + log_error "Local changes in $SCRIPT_DIR - commit or discard them first, then retry" + pause + return 1 + fi + + log_info "Checking for updates..." + if ! git -C "$SCRIPT_DIR" fetch origin; then + log_error "Could not reach GitHub - check your internet connection" + pause + return 1 + fi + + local branch local_rev remote_rev + branch=$(git -C "$SCRIPT_DIR" rev-parse --abbrev-ref HEAD) + local_rev=$(git -C "$SCRIPT_DIR" rev-parse HEAD) + remote_rev=$(git -C "$SCRIPT_DIR" rev-parse "origin/$branch" 2>/dev/null || true) + + if [[ -z "$remote_rev" ]]; then + log_error "Could not find origin/$branch - is this checkout tracking a real branch?" + pause + return 1 + fi + + if [[ "$local_rev" == "$remote_rev" ]]; then + log_success "Already up to date ($branch @ ${local_rev:0:8})" + else + echo + echo "Changes available:" + git -C "$SCRIPT_DIR" --no-pager log --oneline "${local_rev}..${remote_rev}" + echo + if ask_yes_no "Pull these changes and re-apply setup (packages, app files, hardware config)?" "y"; then + if ! git -C "$SCRIPT_DIR" pull --ff-only origin "$branch"; then + log_error "Pull failed (not a fast-forward) - resolve manually in $SCRIPT_DIR" + pause + return 1 + fi + log_success "Pulled latest code ($branch @ $(git -C "$SCRIPT_DIR" rev-parse --short HEAD))" + + echo + log_info "Re-applying setup with the updated code..." + provision_install_packages + provision_create_kiosk_user + provision_install_nodejs + + # Bare call, not `if ! provision_install_app; then`: testing a + # multi-statement function as an if-condition exempts + # everything inside it from set -e for the duration (see + # lib/provision.sh's own call to this same function). $? is + # captured right after instead - accurate either way, and + # doesn't add a new exemption on top of the one this action + # already has from being invoked through run_menu's dispatch. + provision_install_app + local app_rc=$? + if [[ $app_rc -ne 0 ]]; then + log_error "Upgrade stopped - app reinstall failed, see above" + pause + return 1 + fi + + provision_configure_display + provision_configure_firewall + provision_configure_power_management + log_success "Setup refreshed" + + echo + if ask_yes_no "Restart kiosk display now to apply changes?" "y"; then + sudo systemctl restart lightdm + else + log_info "Restart later with: sudo systemctl restart lightdm" + fi + else + echo "Cancelled" + fi + fi + + echo + if ask_yes_no "Check for and install the latest Electron version too?" "y"; then + action_update_electron + return + fi + + pause +} diff --git a/ubuntu-based-kiosk.sh b/ubuntu-based-kiosk.sh index 709a231..d41c7aa 100644 --- a/ubuntu-based-kiosk.sh +++ b/ubuntu-based-kiosk.sh @@ -1,8 +1,28 @@ #!/bin/bash ################################################################################ -### Ubuntu Based Kiosk v2.14.0 ### +### Ubuntu Based Kiosk v2.15.0 ### ################################################################################ # +# RELEASE v2.15.0 - Upgrade Migrated to install.sh (Advanced -> Upgrade) +# - New in ./install.sh's Advanced menu: Upgrade. Not a port of this +# script's Upgrade - that one re-extracted main.js/preload.js/etc from +# its own heredocs on every run, a mechanism that has no equivalent +# here now that kiosk-app/ and provision/files/ are real files in the +# git checkout. The modular Upgrade is `git pull` (only after +# confirming the working tree is clean and the pull is a fast-forward +# - never an automatic merge) followed by re-running the same +# packages/kiosk-app/display/firewall/power-management steps +# lib/provision.sh already has for a fresh install, reused rather than +# reimplemented. Skips the interactive first-run settings wizard and +# the "reboot now" prompt - those don't belong in a routine upgrade. +# - Also offers an on-demand Electron version check/update regardless of +# whether there was any code to pull, since Electron isn't versioned +# by this repo - reuses the existing, already-tested +# action_update_electron (menus/advanced_electron.sh) as-is. +# - Requires a git checkout (not the no-git ZIP download option) and a +# clean working tree; a diverged local history fails the pull cleanly +# with a clear message instead of attempting an automatic merge. +# # 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 @@ -521,7 +541,7 @@ set -euo pipefail ### SECTION 1: CONSTANTS & GLOBALS ################################################################################ -SCRIPT_VERSION="2.14.0" +SCRIPT_VERSION="2.15.0" # Resolve the real path to this script file. # When piped (curl|bash or wget|bash), BASH_SOURCE[0] is a pipe descriptor, From d0b76dc6cf100241bbd23e512a0b0fbffda58e17 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 14:38:17 +0000 Subject: [PATCH 19/19] 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 Claude-Session: https://claude.ai/code/session_01VfsFSoRqfbRG7XAg5RoE7e --- Readme.md | 20 ++++++++++---------- install.sh | 7 +++++-- ubuntu-based-kiosk.sh | 5 +++++ 3 files changed, 20 insertions(+), 12 deletions(-) diff --git a/Readme.md b/Readme.md index bb952c0..bd6c017 100644 --- a/Readme.md +++ b/Readme.md @@ -57,8 +57,7 @@ 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, and has its own Upgrade, 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 -> Full Reinstall of an existing install. +> remains the more battle-tested path. --- @@ -1309,14 +1308,14 @@ an already-installed one, and can pull/apply its own updates — 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. One piece -remains legacy-only: Full Reinstall, 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 +modular version only replaces the Client option, by design. Full +Reinstall is deliberately not being carried forward — 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. 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, @@ -1353,6 +1352,7 @@ full migration pass. - **New: Upgrade** (Advanced → Upgrade) — not a port of the legacy Upgrade, which re-extracted `main.js`/`preload.js`/etc from its own heredocs on every run. `kiosk-app/` and `provision/files/` are real files in this git checkout now, so the modular Upgrade is `git pull` (after confirming a clean working tree, 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. Skips the interactive first-run settings wizard and the "reboot now" prompt. - Also offers an on-demand Electron version check regardless of whether there was code to pull (Electron isn't versioned by this repo) — reuses the existing, already-tested `action_update_electron` as-is. - Requires a real git checkout (not the no-git ZIP download option) and a clean working tree; a diverged local history fails the pull cleanly with a clear message rather than attempting an automatic merge. +- **Full Reinstall dropped, not carried forward.** 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 need for a dedicated combined action. **Previous (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. diff --git a/install.sh b/install.sh index 65f7f73..b2aef81 100755 --- a/install.sh +++ b/install.sh @@ -16,8 +16,11 @@ # run; kiosk-app/ and provision/files/ are real files in this git # checkout, so the modular Upgrade is `git pull` + re-running the same # provisioning steps, reused rather than reimplemented (see -# menus/advanced_upgrade.sh). Full Reinstall remains legacy-only - it -# has no equivalent here yet. +# menus/advanced_upgrade.sh). Full Reinstall is deliberately not +# carried forward - it never worked reliably in the legacy script, and +# the same outcome is already available here, more reliably, as two +# already-tested pieces run back to back: Complete Uninstall (Core +# Settings), then ./install.sh again to provision fresh. # # Migrated so far, grouped the same way the legacy menu groups them: # Core Settings: Sites & Page Timing, Display & Interaction, Timezone, diff --git a/ubuntu-based-kiosk.sh b/ubuntu-based-kiosk.sh index d41c7aa..a9716bd 100644 --- a/ubuntu-based-kiosk.sh +++ b/ubuntu-based-kiosk.sh @@ -22,6 +22,11 @@ # - Requires a git checkout (not the no-git ZIP download option) and a # clean working tree; a diverged local history fails the pull cleanly # with a clear message instead of attempting an automatic merge. +# - Full Reinstall dropped, not carried forward - it never worked +# reliably in this script either, and the modular tool already covers +# the same outcome more reliably as two already-tested pieces run back +# to back: Complete Uninstall (Core Settings), then ./install.sh again +# to provision fresh. No dedicated combined action needed. # # RELEASE v2.14.0 - install.sh Now Provisions a Kiosk From Scratch, # Not Just Manages an Existing One