diff --git a/Readme.md b/Readme.md index 20f7274..f0c2338 100644 --- a/Readme.md +++ b/Readme.md @@ -1,6 +1,6 @@ # Ubuntu Based Kiosk -**Current Version:** 2.8.0 (check script header for latest version) +**Current Version:** 2.9.0 (check script header for latest version) **Built with Claude Sonnet 4.6 AI assistance** **License:** GPL v3 - Keep derivatives open source **Repository:** https://github.com/outis1one/ubuntu-based-kiosk/ @@ -1214,6 +1214,13 @@ terminal menu and the web UI, so they can't drift apart). Prompted the `save_config` merge fix above. - `menus/addon_remote_access.sh` — **Remote Access** (Addons): VNC, WireGuard, Tailscale, Netbird. The biggest Addon so far. +- `menus/addon_lms_squeezelite.sh` — **LMS Server / Squeezelite Player** + (Addons): install/reconfigure/uninstall for an LMS (Lyrion/Logitech + Media Server) server the kiosk can host, and a Squeezelite player the + kiosk can run against any LMS server on the LAN. Squeezelite's own + start script and systemd unit go through `$BIN_DIR`/`$SYSTEMD_DIR` + like every other addon; LMS's own apt repo/GPG key/ufw rules stay at + their real fixed system paths, same as CUPS. - `install.sh` — entry point for the modular tool, now grouped **Core Settings / Addons / Advanced** like the legacy menu. Run it against an *already-installed* kiosk: @@ -1226,14 +1233,25 @@ terminal menu and the web UI, so they can't drift apart). **Honest status:** this does not yet replace first-time installation, or most of the old installer. `ubuntu-based-kiosk.sh` is still ~12,000 lines and still contains its own unremoved, unmodified copies of every -menu above (plus Upgrade, Reinstall, Uninstall, 2 more Addons, and the -other 8 Advanced items — none of that has moved yet). Both copies -coexist deliberately: the old ones stay until enough -of Core Settings/Addons/Advanced is migrated to -retire them in one pass, rather than leaving the legacy menu half-wired. -Migration continues one `menus/*.sh` file at a time; first-time -installation itself is the last and largest piece to move, if it moves -at all. +menu above (plus Upgrade, Reinstall, Uninstall, 1 more Addon — Easy +Asterisk Intercom — and the other 8 Advanced items — none of that has +moved yet). Both copies coexist deliberately: the old ones stay until +enough of Core Settings/Addons/Advanced is migrated to retire them in +one pass, rather than leaving the legacy menu half-wired. Migration +continues one `menus/*.sh` file at a time; first-time installation +itself is the last and largest piece to move, if it moves at all. + +**Resolved (v2.9.0):** `is_service_enabled()` — shared by both scripts +— had a pre-check (`systemctl list-unit-files | grep -q "^${service}\s"`) +that never actually matched, since every call site passes a bare +service name while `list-unit-files` lines start with +`"$service.service"`. The function always fell through to `return 1` +regardless of the real enabled state — under-reporting "enabled but not +currently running" as "not installed" everywhere it's used, including +LMS/Squeezelite's own status detection. Fixed in both `lib/config.sh` +and `ubuntu-based-kiosk.sh` by dropping the dead pre-check — +`systemctl is-enabled` already reports "not found" as a failure on its +own. **Resolved (v2.7.0):** the config-clobbering bug fixed in `lib/config.sh` (v2.6.0 — `save_config` silently deleting fields it doesn't know about, @@ -1248,9 +1266,14 @@ full migration pass. ## Project Status & Future Plans -**Current Version:** 2.8.0 +**Current Version:** 2.9.0 -**Recent Updates (v2.8.0):** +**Recent Updates (v2.9.0):** +- **LMS Server / Squeezelite Player migrated** — install/reconfigure/uninstall for both, in `./install.sh`. Squeezelite's own start script and systemd unit now go through `$BIN_DIR`/`$SYSTEMD_DIR` like every other addon instead of hardcoded `/usr/local/bin`/`/etc/systemd/system`; LMS's own apt repo/GPG key/ufw rules stay at their real fixed system paths, same approach as CUPS. +- **Bug fix:** the legacy `install_lms()` enabled/started the detected service via `sudo systemctl enable "$service_name" 2>&1 | tee /tmp/lms-enable.log` — piped through `tee`, the statement's exit status reflected `tee` (always 0), not `systemctl enable`, so a real enable/start failure was silently swallowed instead of falling through to a warning. Now uses the shared `enable_and_start_units()` helper. +- **Bug fix (shared, backported to the legacy script too):** `is_service_enabled()`'s pre-check never matched a bare service name against `list-unit-files`' `"$service.service"` lines, so it always reported "not enabled" regardless of the real state. Dropped the dead pre-check — see "Modular Management" below. + +**Previous (v2.8.0):** - **Remote Access migrated** — VNC, WireGuard, Tailscale, and Netbird, each with its own install/connect/status/uninstall flow. The biggest Addon so far. Tailscale/Netbird install via the vendors' own `curl | sh` method, preserved as-is. - **Important framework-level bug found and fixed:** `run_menu()`'s *handler* call has been crash-guarded since v2.1.0, but its *status function* call was still completely bare. A status function is meant to be read-only display, but a pipeline whose `grep` matches nothing (which `pipefail` turns into a failure even though the actual last command succeeds) would crash the **entire session**, not just fail to show status. Found while building `wireguard_status()` and verifying its exact failure mode rather than assuming it was covered. Fixed once, in the framework, protecting every status function across every menu — present and future. Also audited every existing status function for the same shape and fixed one real instance in `power_schedule_status()`. - Deduplicated: promoted `power_schedule.sh`'s `enable_and_start_timers()` to a shared `enable_and_start_units()` in `lib/menu.sh` (works for services now, not just timers) rather than writing the same helper a second time for VNC/WireGuard. diff --git a/install.sh b/install.sh index 3d38723..73f9a03 100755 --- a/install.sh +++ b/install.sh @@ -18,7 +18,8 @@ # Power/Display/Quiet Hours. # Addons: CUPS Printing (menus/addon_cups.sh), Authelia Auto-Login # (menus/addon_authelia.sh), Remote Access - VNC/WireGuard/ -# Tailscale/Netbird (menus/addon_remote_access.sh). +# Tailscale/Netbird (menus/addon_remote_access.sh), LMS Server / +# Squeezelite Player (menus/addon_lms_squeezelite.sh). # Advanced: Diagnostics (menus/diagnostics.sh - system status/logs/ # audio/network). # @@ -58,6 +59,8 @@ source "$SCRIPT_DIR/menus/addon_cups.sh" source "$SCRIPT_DIR/menus/addon_authelia.sh" # shellcheck source=menus/addon_remote_access.sh source "$SCRIPT_DIR/menus/addon_remote_access.sh" +# shellcheck source=menus/addon_lms_squeezelite.sh +source "$SCRIPT_DIR/menus/addon_lms_squeezelite.sh" ################################################################################ # Preflight @@ -121,8 +124,8 @@ core_settings_menu() { } addons_menu_builder() { - MENU_LABELS=("CUPS Printing" "Authelia Auto-Login" "Remote Access") - MENU_HANDLERS=(addon_cups_menu addon_authelia_menu remote_access_menu) + MENU_LABELS=("CUPS Printing" "Authelia Auto-Login" "Remote Access" "LMS Server / Squeezelite Player") + MENU_HANDLERS=(addon_cups_menu addon_authelia_menu remote_access_menu addon_lms_squeezelite_menu) } addons_menu() { diff --git a/lib/config.sh b/lib/config.sh index e6dea50..28fd411 100644 --- a/lib/config.sh +++ b/lib/config.sh @@ -78,6 +78,20 @@ is_service_active() { 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() { diff --git a/menus/addon_lms_squeezelite.sh b/menus/addon_lms_squeezelite.sh new file mode 100644 index 0000000..43ef145 --- /dev/null +++ b/menus/addon_lms_squeezelite.sh @@ -0,0 +1,344 @@ +#!/bin/bash +################################################################################ +# menus/addon_lms_squeezelite.sh - "LMS Server / Squeezelite Player" addon. +# +# Two independent pieces sharing one menu, same as the legacy code: an LMS +# (Lyrion/Logitech Media Server) server the kiosk can host, and a +# Squeezelite player the kiosk can run to play music from any LMS server +# (this one or another one on the LAN). LMS itself is a real apt-managed +# subsystem with its own fixed paths (repo file, GPG keyring, ufw rules, +# /etc/squeezeboxserver) - like CUPS, those get full command-level `sudo`/ +# `wget`/`apt` stubbing in tests rather than relocation. Squeezelite's own +# start script and systemd unit are ours to place, so - like power_schedule +# and the other addons - they go through $BIN_DIR/$SYSTEMD_DIR (lib/ +# config.sh) instead of hardcoded /usr/local/bin and /etc/systemd/system, +# so tests can point them at a scratch directory. +# +# LMS ships under two package/service names depending on version - +# "logitechmediaserver" (older) and "lyrionmusicserver" (the project's +# current name after its rename) - so detection and every service call +# has to check both. +# +# Depends on: lib/menu.sh, lib/config.sh being sourced first. +################################################################################ + +lms_service_name() { + if systemctl list-unit-files 2>/dev/null | grep -q "lyrionmusicserver.service"; then + echo "lyrionmusicserver" + elif systemctl list-unit-files 2>/dev/null | grep -q "logitechmediaserver.service"; then + echo "logitechmediaserver" + fi +} + +lms_is_installed() { + is_service_active logitechmediaserver || is_service_enabled logitechmediaserver || \ + is_service_active lyrionmusicserver || is_service_enabled lyrionmusicserver +} + +lms_is_running() { + is_service_active logitechmediaserver || is_service_active lyrionmusicserver +} + +squeezelite_is_installed() { + is_service_active squeezelite || is_service_enabled squeezelite +} + +addon_lms_squeezelite_status() { + if lms_is_installed; then + echo "LMS Server: Installed" + if lms_is_running; then + echo " Status: Running" + else + echo " Status: Stopped" + fi + echo " Web: http://$(get_ip_address):9000" + echo + fi + + if squeezelite_is_installed; then + local player_name="Unknown" + if [[ -f "$BIN_DIR/squeezelite-start.sh" ]]; then + player_name=$(grep '^PLAYER_NAME=' "$BIN_DIR/squeezelite-start.sh" 2>/dev/null | cut -d'=' -f2 | tr -d '"' || echo "Unknown") + fi + echo "Squeezelite Player: Installed" + if is_service_active squeezelite; then + echo " Status: Running" + else + echo " Status: Stopped" + fi + echo " Name: $player_name" + echo + fi + + echo "ℹ A server is needed to stream music. The kiosk can run the" + echo " server (if sufficient resources) or connect to another server." +} + +addon_lms_squeezelite_menu_builder() { + MENU_LABELS=("Install/Configure LMS Server" "Install/Configure Squeezelite Player") + MENU_HANDLERS=(action_install_lms action_install_squeezelite) + + if lms_is_installed; then + MENU_LABELS+=("Uninstall LMS Server") + MENU_HANDLERS+=(action_uninstall_lms) + fi + + if squeezelite_is_installed; then + MENU_LABELS+=("Uninstall Squeezelite Player") + MENU_HANDLERS+=(action_uninstall_squeezelite) + fi +} + +addon_lms_squeezelite_menu() { + run_menu "LMS SERVER / SQUEEZELITE PLAYER" addon_lms_squeezelite_menu_builder addon_lms_squeezelite_status +} + +################################################################################ +# Actions - LMS Server +################################################################################ + +action_install_lms() { + echo + if lms_is_installed; then + echo "LMS is already installed." + if ask_yes_no "Reconfigure port?" "n"; then + local new_port + new_port=$(ask_integer "New HTTP port" 9000 1 65535) + sudo sed -i "s/httpport:.*/httpport: $new_port/" /etc/squeezeboxserver/prefs/server.prefs 2>/dev/null || true + sudo systemctl restart lyrionmusicserver 2>/dev/null || sudo systemctl restart logitechmediaserver 2>/dev/null || true + log_success "LMS reconfigured on port $new_port" + fi + pause + return + fi + + echo "Installing Lyrion Music Server..." + + # Try the repository method first. + if wget -qO - https://debian.slimdevices.com/debian/squeezebox-keyring.gpg | sudo gpg --dearmor -o /usr/share/keyrings/lms-keyring.gpg 2>/dev/null; then + echo "deb [signed-by=/usr/share/keyrings/lms-keyring.gpg] http://debian.slimdevices.com/debian stable main" | sudo tee /etc/apt/sources.list.d/lms.list + # `|| true`: a bare, unguarded `apt update` failing here (bad + # mirror, no network) would otherwise crash the whole session + # under set -e instead of falling through to the direct-download + # fallback below, which is exactly the degrade path this is + # supposed to hit when the repository route doesn't work. + sudo apt update 2>/dev/null || true + if sudo apt install -y logitechmediaserver 2>/dev/null; then + log_success "LMS installed via repository" + else + log_warning "Repository install failed, trying direct download..." + fi + fi + + # Fall back to a direct .deb download if the repository didn't produce + # either possible package. + if ! command -v logitechmediaserver &>/dev/null && ! command -v lyrionmusicserver &>/dev/null; then + local lms_deb="/tmp/lms.deb" + echo "Downloading LMS v9.0.3..." + if wget -q https://downloads.lms-community.org/LyrionMusicServer_v9.0.3/lyrionmusicserver_9.0.3_amd64.deb -O "$lms_deb"; then + echo "Installing LMS package..." + if sudo apt install -y "$lms_deb"; then + log_success "LMS installed via direct download" + else + log_error "Failed to install LMS package" + rm -f "$lms_deb" + pause + return 1 + fi + rm -f "$lms_deb" + else + log_error "Failed to download LMS from lms-community.org" + pause + return 1 + fi + fi + + local service_name + service_name=$(lms_service_name) + + if [[ -z "$service_name" ]]; then + log_warning "Service file not found, checking installed files..." + service_name=$(dpkg -L lyrionmusicserver logitechmediaserver 2>/dev/null | grep -m1 '\.service$' | xargs -r basename | sed 's/\.service$//' || echo "") + fi + + if [[ -z "$service_name" ]]; then + log_error "Could not detect LMS service name" + echo "Manual steps:" + echo " 1. Find service: systemctl list-unit-files | grep -i lms" + echo " 2. Enable: sudo systemctl enable SERVICE_NAME" + echo " 3. Start: sudo systemctl start SERVICE_NAME" + pause + return 1 + fi + + log_info "Using service: $service_name" + # enable_and_start_units, not a bare `sudo systemctl enable ... | tee` + # pipe: the legacy version's `2>&1 | tee /tmp/lms-enable.log` made the + # whole statement's exit status depend on `tee` (always 0) rather than + # `systemctl enable`, so a real enable/start failure was silently + # swallowed instead of falling through to the warning below. + if enable_and_start_units "$service_name"; then + sudo ufw allow 9000/tcp comment 'LMS-HTTP' 2>/dev/null || true + sudo ufw allow 3483/tcp comment 'LMS-SlimProto' 2>/dev/null || true + sudo ufw allow 3483/udp comment 'LMS-Discovery' 2>/dev/null || true + + log_success "LMS installed" + echo " Web interface: http://$(get_ip_address):9000" + else + log_warning "LMS installed, but systemctl enable/start failed - check 'systemctl status $service_name'" + fi + + pause +} + +action_uninstall_lms() { + echo + ask_yes_no "Remove LMS Server?" "n" || { echo "Cancelled"; pause; return; } + + local service_name + service_name=$(lms_service_name) + + if [[ -n "$service_name" ]]; then + echo "Stopping $service_name..." + sudo systemctl stop "$service_name" 2>/dev/null || true + sudo systemctl disable "$service_name" 2>/dev/null || true + fi + + # Try to remove both possible package names - only one will actually + # be installed, the other is a harmless no-op. + sudo apt remove -y lyrionmusicserver 2>/dev/null || true + sudo apt remove -y logitechmediaserver 2>/dev/null || true + + sudo rm -f /etc/apt/sources.list.d/lms.list + sudo rm -f /usr/share/keyrings/lms-keyring.gpg + + if ask_yes_no "Remove LMS data and configuration?" "n"; then + sudo rm -rf /var/lib/squeezeboxserver + sudo rm -rf /etc/squeezeboxserver + log_success "LMS and data removed" + else + log_success "LMS removed (data preserved)" + fi + + pause +} + +################################################################################ +# Actions - Squeezelite Player +################################################################################ + +action_install_squeezelite() { + echo + if squeezelite_is_installed; then + echo "Squeezelite is already installed." + ask_yes_no "Reconfigure?" "n" || { pause; return; } + fi + + if ! command -v squeezelite &>/dev/null; then + if ! sudo apt install -y squeezelite; then + log_error "squeezelite package installation failed" + pause + return 1 + fi + fi + + local player_name + player_name=$(ask_text "Player name" "Kiosk") + + echo + echo "LMS Server Configuration:" + echo " Enter IP:PORT of your LMS server" + echo " Leave blank for auto-discovery on LAN" + echo + local lms_server + lms_server=$(ask_text "LMS Server (e.g., 192.168.1.100:3483)" "") + + sudo tee "$BIN_DIR/squeezelite-start.sh" > /dev/null </dev/null 2>&1 && break + sleep 1 +done + +if ! pactl info >/dev/null 2>&1; then + logger "ERROR: Squeezelite - PipeWire not available" + exit 1 +fi + +if [[ -n "\$LMS_SERVER" ]]; then + exec /usr/bin/squeezelite -n "\$PLAYER_NAME" -s "\$LMS_SERVER" -o pulse -a 80:4:: -b 512:1024 -C 5 +else + exec /usr/bin/squeezelite -n "\$PLAYER_NAME" -o pulse -a 80:4:: -b 512:1024 -C 5 +fi +SQSTART + + sudo chmod +x "$BIN_DIR/squeezelite-start.sh" + + local kiosk_uid + kiosk_uid=$(id -u "$KIOSK_USER") + + sudo tee "$SYSTEMD_DIR/squeezelite.service" > /dev/null </dev/null || true + # Enable only, not start: squeezelite needs the kiosk user's real + # session (PipeWire, XDG_RUNTIME_DIR) up first, which is why a reboot + # is required below rather than starting it immediately. + if ! sudo systemctl enable squeezelite 2>/dev/null; then + log_warning "Squeezelite files written, but 'systemctl enable' failed - check 'systemctl status squeezelite'" + fi + + log_success "Squeezelite installed: $player_name" + if [[ -n "$lms_server" ]]; then + echo " Server: $lms_server" + else + echo " Server: Auto-discovery" + fi + echo + echo "⚠️ IMPORTANT: Squeezelite requires a reboot to work properly" + echo + if ask_yes_no "Reboot now?" "n"; then + echo "Rebooting in 5 seconds..." + sleep 5 + sudo reboot + else + echo "⚠️ Remember to reboot before using Squeezelite" + echo " Command: sudo reboot" + fi + + pause +} + +action_uninstall_squeezelite() { + echo + ask_yes_no "Remove Squeezelite Player?" "n" || { echo "Cancelled"; pause; return; } + + sudo systemctl stop squeezelite 2>/dev/null || true + sudo systemctl disable squeezelite 2>/dev/null || true + sudo rm -f "$SYSTEMD_DIR/squeezelite.service" + sudo rm -f "$BIN_DIR/squeezelite-start.sh" + sudo apt remove -y squeezelite 2>/dev/null || true + log_success "Squeezelite removed" + + pause +} diff --git a/ubuntu-based-kiosk.sh b/ubuntu-based-kiosk.sh index 51b79a8..1f41281 100644 --- a/ubuntu-based-kiosk.sh +++ b/ubuntu-based-kiosk.sh @@ -1,8 +1,37 @@ #!/bin/bash ################################################################################ -### Ubuntu Based Kiosk v2.8.0 ### +### Ubuntu Based Kiosk v2.9.0 ### ################################################################################ # +# RELEASE v2.9.0 - LMS Server / Squeezelite Player Migrated; +# is_service_enabled() Dead Pre-Check Fixed +# - New in ./install.sh: LMS Server / Squeezelite Player +# (menus/addon_lms_squeezelite.sh) - install/reconfigure/uninstall for +# an LMS (Lyrion/Logitech Media Server) server the kiosk can host, and +# a Squeezelite player the kiosk can run against any LMS server on the +# LAN. Squeezelite's own start script and systemd unit now go through +# $BIN_DIR/$SYSTEMD_DIR (lib/config.sh) instead of hardcoded +# /usr/local/bin and /etc/systemd/system, matching every other addon; +# LMS's own apt repo/GPG key/ufw rules stay at their real fixed system +# paths, same as CUPS. +# - Fixed a real unguarded-pipeline bug from the legacy install_lms(): +# `sudo systemctl enable "$service_name" 2>&1 | tee /tmp/lms-enable.log` +# made the whole statement's exit status depend on `tee` (always 0) +# instead of `systemctl enable`, so a real enable/start failure was +# silently swallowed rather than falling through to a warning. Now +# uses the shared enable_and_start_units() helper instead. +# - Fixed is_service_enabled() (shared by both scripts): its pre-check +# `systemctl list-unit-files | grep -q "^${service}\s"` never matched, +# since every call site passes a bare service name (e.g. +# "squeezelite") while list-unit-files lines start with +# "squeezelite.service" - so the function always fell through to +# `return 1` regardless of the real enabled state. `systemctl +# is-enabled` already reports "not found" as a failure on its own, so +# the dead pre-check is simply dropped. Backported here since it's the +# same shared function in both scripts and the fix is low-risk +# (behavior-preserving for every state except the one it was silently +# getting wrong). +# # RELEASE v2.8.0 - Remote Access Migrated (VNC/WireGuard/Tailscale/ # Netbird); Framework-Level Status-Function Crash Fixed # - New in ./install.sh: Remote Access (menus/addon_remote_access.sh) - @@ -320,7 +349,7 @@ set -euo pipefail ### SECTION 1: CONSTANTS & GLOBALS ################################################################################ -SCRIPT_VERSION="2.8.0" +SCRIPT_VERSION="2.9.0" # Resolve the real path to this script file. # When piped (curl|bash or wget|bash), BASH_SOURCE[0] is a pipe descriptor, @@ -578,12 +607,14 @@ is_service_active() { is_service_enabled() { local service="$1" - # Check if service file exists first - if systemctl list-unit-files 2>/dev/null | grep -q "^${service}\s"; then - systemctl is-enabled --quiet "$service" 2>/dev/null - else - return 1 - fi + # `systemctl is-enabled` already reports "not found" as a failure on + # its own - no need for (and no correct way to write, given every + # call site here passes a bare service name while list-unit-files + # lines start with "$service.service") a pre-check via + # list-unit-files. The previous "^${service}\s" pre-check never + # matched, so this function always fell through to `return 1` + # regardless of the real enabled state. + systemctl is-enabled --quiet "$service" 2>/dev/null } get_ip_address() {