From 21a0768c8ca0299ca3d6f1f64056047e6098feb0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 15:26:16 +0000 Subject: [PATCH] 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 +}