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.
This commit is contained in:
Claude
2026-08-18 15:26:16 +00:00
parent ccf73d41bd
commit 21a0768c8c
5 changed files with 916 additions and 0 deletions
+202
View File
@@ -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
}
+240
View File
@@ -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
}