Offline bundle now carries every addon that works on a network without internet: LMS (Lyrion's own .deb, from the addon's LMS_DEB_URL) + Squeezelite, Asterisk Intercom (baresip), VNC, WireGuard and the Emergency Hotspot packages - each list in a variable next to the code that installs it (SQUEEZELITE_APT_PACKAGES, ASTERISK_INTERCOM_APT_PACKAGES, VNC_APT_PACKAGES, WIREGUARD_APT_PACKAGES, EMERGENCY_HOTSPOT_APT_PACKAGES), read by build-bundle.sh. Each install goes through run_with_offline_fallback like CUPS, so they install offline from the normal menus / Web UI. LMS installs as a bundle package when offline (lms_offline_install_wanted); its online repo/direct-download paths are unchanged. Tailscale/Netbird deliberately not bundled (vendor scripts, vendor servers). Bundle: 524 MB (+87 MB). Emergency hotspot: the boot check pinged only 8.8.8.8, so on a LAN with no internet the hotspot started on every boot. Now chosen at setup: "network" (default - router or internet must answer; a router that ignores ping still counts if it answered ARP), "internet" (old check), or "host" (a specific address). Three tries 10s apart. Existing scripts keep the old check until Reconfigure. New Core Settings -> Time Server (NTP) (menus/time_sync.sh): point systemd-timesyncd at LAN time server(s) via a timesyncd.conf.d drop-in, back to Ubuntu's defaults, or set the clock by hand (and the RTC) for a network with no time server. Complete Uninstall resets it. Also: lms_service_name used `systemctl list-unit-files | grep -q`, the same pipefail/SIGPIPE pattern as the earlier cups_is_installed bug; now `systemctl cat`. Tested in a network-less chroot of the 26.04.1 server filesystem: kiosk base, then action_wireguard_install and action_install_lms (real functions) and the Squeezelite/Intercom/VNC/hotspot package sets all installed from the bundle, apt left on normal sources after each. Hotspot script generated for all three modes (plus an invalid address, which falls back to "network") and its check run against simulated networks: LAN-only with router answering ping or only ARP -> no hotspot in "network" mode; nothing reachable -> hotspot. Time Server actions driven with timedatectl stubbed: drop-in contents, input validation, manual set-time sequence, reset removes the drop-in. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cjz8a58eB5P9sUweqd3NHm
335 lines
16 KiB
Bash
335 lines
16 KiB
Bash
#!/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}"
|
|
|
|
# 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}"
|
|
: "${TIMESYNCD_CONF_DIR:=/etc/systemd/timesyncd.conf.d}"
|
|
: "${POLKIT_DIR:=/etc/polkit-1/localauthority/50-local.d}"
|
|
: "${WIREGUARD_DIR:=/etc/wireguard}"
|
|
: "${WEBUI_DIR:=/opt/kiosk-webui}"
|
|
: "${WEBUI_ENV_DIR:=/etc/kiosk-webui}"
|
|
: "${SUDOERS_D_DIR:=/etc/sudoers.d}"
|
|
# The one root-owned script the Web UI's addon-install/Update actions are
|
|
# allowed to invoke via passwordless sudo (menus/addon_webui.sh writes it
|
|
# and the matching /etc/sudoers.d/kiosk-webui rule - see that file's
|
|
# header). Under $BIN_DIR since that's already the convention for every
|
|
# other addon's own scripts.
|
|
: "${WEBUI_HELPER_PATH:=$BIN_DIR/kiosk-webui-helper}"
|
|
|
|
# Offline install bundle - only present on systems installed from an ISO
|
|
# built with `iso/build-iso.sh --offline`: a local apt repository (every
|
|
# package provisioning and the CUPS addon install) plus prebuilt
|
|
# node_modules for the kiosk app and Web UI. Offline mode is switched on
|
|
# by /usr/local/sbin/kiosk-offline-apt (which writes $KIOSK_OFFLINE_APT_CONF
|
|
# pointing apt at that repository only) - the first-boot service does that
|
|
# automatically when there's no internet; by hand: `sudo kiosk-offline-apt on`.
|
|
: "${KIOSK_OFFLINE_DIR:=/opt/kiosk-offline}"
|
|
: "${KIOSK_OFFLINE_APT_CONF:=/etc/apt/apt.conf.d/90-kiosk-offline}"
|
|
|
|
# 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=()
|
|
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"
|
|
AUTHELIA_URL=""
|
|
AUTHELIA_USERNAME=""
|
|
AUTHELIA_ENCRYPTED_PASSWORD=""
|
|
|
|
offline_mode_active() {
|
|
[[ -f "$KIOSK_OFFLINE_APT_CONF" && -d "$KIOSK_OFFLINE_DIR/apt" ]]
|
|
}
|
|
|
|
# offline_install_node_modules NAME DEST_DIR
|
|
# Unpacks the bundle's prebuilt node_modules for NAME (kiosk-app, webui)
|
|
# into DEST_DIR as $KIOSK_USER, in place of `npm install` - which can't
|
|
# reach the npm registry (or Electron's GitHub download) offline.
|
|
offline_install_node_modules() {
|
|
local name="$1" dest="$2"
|
|
local tarball="$KIOSK_OFFLINE_DIR/npm/${name}-node_modules.tar.gz"
|
|
if [[ ! -f "$tarball" ]]; then
|
|
log_error "Offline bundle has no prebuilt modules for $name ($tarball)"
|
|
return 1
|
|
fi
|
|
echo "Offline mode: unpacking prebuilt $name modules from $KIOSK_OFFLINE_DIR..."
|
|
sudo -u "$KIOSK_USER" rm -rf "$dest/node_modules"
|
|
sudo -u "$KIOSK_USER" tar -xzf "$tarball" -C "$dest"
|
|
}
|
|
|
|
# Quick reachability check for the package mirrors install.sh needs.
|
|
have_internet() {
|
|
curl -fsS --max-time 8 -o /dev/null http://archive.ubuntu.com/ubuntu/ 2>/dev/null \
|
|
|| curl -fsS --max-time 8 -o /dev/null https://deb.nodesource.com/ 2>/dev/null
|
|
}
|
|
|
|
# run_with_offline_fallback CMD [ARGS...]
|
|
# Runs CMD normally - but on a kiosk installed from an --offline ISO with
|
|
# no internet right now, first points apt at the bundle on this disk
|
|
# (kiosk-offline-apt on) and switches it back afterwards, whatever CMD
|
|
# returns. Lets apt-based installs (e.g. the CUPS addon) work from the
|
|
# normal menus and the Web UI with no extra steps. Everywhere else
|
|
# (manual installs, or online) it just runs CMD.
|
|
run_with_offline_fallback() {
|
|
local restore=0 rc=0
|
|
if ! offline_mode_active && [[ -f "$KIOSK_OFFLINE_DIR/apt/Packages" ]] && ! have_internet; then
|
|
log_info "No internet - installing from the offline bundle on this disk ($(sed -n 's/^Built: *//p' "$KIOSK_OFFLINE_DIR/bundle-info.txt" 2>/dev/null))"
|
|
if sudo kiosk-offline-apt on >/dev/null; then
|
|
restore=1
|
|
else
|
|
log_warning "Couldn't switch apt to the offline bundle - trying normally"
|
|
fi
|
|
fi
|
|
"$@" || rc=$?
|
|
if (( restore )); then
|
|
sudo kiosk-offline-apt off >/dev/null || true
|
|
fi
|
|
return "$rc"
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
# 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() {
|
|
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"
|
|
|
|
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
|
|
# 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"
|
|
|
|
# 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" \
|
|
--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" \
|
|
--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
|
|
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
|
|
}
|