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).
295 lines
7.8 KiB
Bash
295 lines
7.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 "⚠ $*"
|
|
}
|
|
|
|
# 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"
|
|
}
|
|
|
|
# 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..."
|
|
}
|
|
|
|
################################################################################
|
|
# 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_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
|
|
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
|
|
}
|