Files
ubuntu-based-kiosk/lib/menu.sh
T
Claude 074b2ec2e3 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).
2026-08-18 16:33:12 +00:00

252 lines
6.8 KiB
Bash

#!/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.
#
# 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"
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
# `|| 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
}