#!/bin/bash ################################################################################ ### Ubuntu Based Kiosk (UBK) v0.9.11 ### ################################################################################ # # RELEASE v0.9.11 - Media Playback Clarifications # # What's in v0.9.11: # - Clarified media playback behavior in comments # * While media plays: NO rotation, NO prompts, NOTHING interrupts # * Media only pauses for: media ends, user pauses, session lockout, display schedule # * After media stops: 30-second grace period ALWAYS applies # * After grace period: Normal inactivity and rotation rules resume # - Inactivity timeout is user-configurable # * Default: 2 minutes (120 seconds) # * Can be changed via Sites menu when setting home site # * Value stored in seconds in config, converted to milliseconds in app # # What's in v0.9.10: # - Fixed rotation logic to properly handle user interaction # * Rotation now pauses when user interacts with any URL (touch, scroll, type) # * Manual navigation mode prevents auto-rotation until "Return to Rotation" is clicked # * User interaction with content sets manual navigation mode = true # * "Return to Rotation" button resets manual navigation mode = false # - Fixed return to rotation with no rotation URLs # * If home URL exists but all URLs have 0 time (no rotation) # * "Return to Rotation" still returns to home URL properly # * Inactivity prompt will fire on the home URL in this scenario # - Time extensions continue to be honored # * When user selects time extension, rotation pauses for that duration # * User can still interact with content during extension period # * Extension expires naturally or user can return to rotation manually # - Return to rotation popup only appears on interacted URL # * Popup shown only on sites where user has touched/interacted # * Already working correctly from v0.9.9 # - Password/lock session takes precedence # * On boot: password required before any interaction # * After schedule time: password required # * After lockout timeout: password required # * All other functionality blocked until password entered # # What's in v0.9.9: # - Moved Session Lockout configuration from Sites menu to Core Settings menu # * Now accessible as option 7 in Core Settings # * Makes password protection easier to find and configure # - Password protection now triggers on bootup, resume, and lockout timeout # * Password prompt appears immediately on boot if lockout enabled # * Password prompt appears after system suspend/resume # * Password prompt appears when screen is locked # * This ensures kiosk is always protected when password feature is enabled # - Fixed inactivity popup not appearing on rotation sites # * Popup now appears on ALL sites where user has interacted (not just manual sites) # * Inactivity check now happens BEFORE rotation check # * Auto-rotation preserves user interaction flag (critical fix!) # * Prevents rotation from interrupting the inactivity prompt # * Rotation pauses while inactivity prompt is displayed # - Fixed session lockout being interrupted by rotation # * Rotation now pauses completely when session is locked # * Inactivity prompts won't appear while session is locked # * Password lockout screen stays visible until correct password entered # - Fixed lockout timeout not firing independently # * Lockout timer now separate from inactivity timer (uses lastLockoutCheck) # * Responding to inactivity prompts no longer resets lockout timer # * Only actual user interaction with content resets lockout timer # * Ensures lockout fires even if user keeps responding to inactivity prompts # - Fixed script bailing on manual electron update # * Changed from hard exit to graceful error handling # - Changed "Nuclear reinstall" message to "Reinstall complete! System is fresh." # # Previous features (v0.9.8): # - Added password-protected session lockout # * Configurable lockout password (separate from sudo user) # * Auto-lock after configurable timeout (default 30 minutes) # * Black lockout screen with password prompt # * Session unlocks with correct password # - Renamed "Return to Home" to "Return to Rotation" # * Button changed from "🏠 Return to home now" to "🔄 Return to rotation" # * If not on home page: returns to home page, then starts rotation # * If on home page: just starts the rotation # - Consistent inactivity prompt UX across ALL sites # * Prompt appears on ANY site where user has interacted (tap, swipe, scroll) # * Auto-rotation to recipe → user taps → prompt appears after timeout ✅ # * User swiping through photos → keeps resetting timer, no prompt ✅ # * Auto-rotation with no interaction → no prompt, keeps rotating ✅ # * Works on home page, timed sites, and manual sites consistently # - Fixed site edit menu not returning to configuration menu # * Edit site URL flow now completes properly # - Time extension features work correctly # - HTML on-screen keyboard (non-interfering) # # Previous features (v0.9.7 and earlier): # * Screen blanking disabled # * Jitsi audio keep-alive # * PTT spacebar hardcoded # * Squeezelite name displays correctly # * Schedule times display correctly # * LMS installs reliably # * WiFi config works # * CUPS detection fixed # * VPN setup keys work # * Keyboard never auto-pops up # * Blue keyboard icon (⌨️) appears bottom-right when you tap a text field # * Fixed media playback detection # * Fixed schedule time display # * Added emergency hotspot # * Fixed chromebook consol shortcuts # # Built with Claude Sonnet 4/.5 AI assistance # License: GPL v3 - Keep derivatives open sour # Repository: https://github.com/[YOUR-USERNAME]/ubuntu-based-kiosk # # TARGET SYSTEMS: # - Ubuntu 24.04+ Server (minimal install recommended) # - Raspberry Pi 4+ (with or without touchscreen) # - Laptops, desktops, all-in-ones, 2-in-1s # - Touch support optional (works with keyboard/mouse) # # SECURITY NOTICE: # This is NOT suitable for secure locations or public kiosks. # Do NOT use as a replacement for hardened kiosk solutions. # Use entirely at your own risk. # # PURPOSE: # Home/office kiosk for reusing old hardware, displaying: # - Self-hosted services (Immich, MagicMirror2, Home Assistant) # - Web dashboards, digital signage # - Photo slideshows, family calendars # - Any web-based content # ################################################################################ set -euo pipefail ################################################################################ ### SECTION 1: CONSTANTS & GLOBALS ################################################################################ SCRIPT_VERSION="0.9.10" KIOSK_USER="kiosk" BUILD_USER="${SUDO_USER:-$(whoami)}" KIOSK_HOME="/home/${KIOSK_USER}" KIOSK_DIR="${KIOSK_HOME}/kiosk-app" CONFIG_PATH="${KIOSK_DIR}/config.json" # DEFAULT VALUES AUTOSWITCH="true" SWIPE_MODE="dual" ALLOW_NAVIGATION="same-origin" declare -a URLS=() declare -a DURS=() declare -a USERS=() declare -a PASSES=() HOME_TAB_INDEX=-1 INACTIVITY_TIMEOUT=60 LOCKOUT_ENABLED="false" LOCKOUT_PASSWORD="" LOCKOUT_TIMEOUT=1800 # 30 minutes in seconds ################################################################################ ### SECTION 2: HELPER/UTILITY FUNCTIONS ################################################################################ log_info() { echo "[INFO] $*" } log_error() { echo "[ERROR] $*" >&2 } log_success() { echo "✓ $*" } log_warning() { echo "⚠ $*" } # INPUT VALIDATION HELPER ask_yes_no() { local prompt="$1" local default="${2:-n}" while true; do read -r -p "$prompt (y/n) [$default]: " answer answer="${answer:-$default}" # Convert to lowercase and check case "${answer,,}" in y|yes) return 0 ;; n|no) return 1 ;; *) echo "❌ Invalid input. Please enter 'y' or 'n' (yes/no)" echo ;; esac done } # Enhanced validation that accepts more formats 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 } # Dynamic menu counter helpers # Makes menu numbering fully automatic - add/remove items without changing prompts # # SIMPLE MENUS (no conditionals): # start_menu # add_option "First option" # add_option "Second option" # add_option "Third option" # show_menu_prompt # # COMPLEX MENUS (with conditionals): # start_menu # add_option "Always shown" # [[ $condition ]] && add_option "Conditional option" # show_menu_prompt # # The prompt will automatically show [0-N] where N = number of items added start_menu() { menu_count=0 } add_option() { menu_count=$((menu_count + 1)) echo " $menu_count. $1" } show_menu_prompt() { echo " 0. Return" echo read -r -p "Choose [0-$menu_count]: " choice } # Ask yes/no with better error messages ask_yes_no() { local prompt="$1" local default="${2:-n}" 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 } pause() { read -r -p "Press Enter to continue..." } is_service_active() { local service="$1" systemctl is-active --quiet "$service" 2>/dev/null } 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 } get_ip_address() { hostname -I | awk '{print $1}' || echo "No IP" } get_vpn_ips() { local vpn_info="" if command -v wg &>/dev/null && sudo wg show 2>/dev/null | grep -q interface; then local wg_ip=$(sudo wg show all | grep "allowed ips" | head -1 | awk '{print $3}' | cut -d'/' -f1) [[ -n "$wg_ip" ]] && vpn_info="${vpn_info}WireGuard: $wg_ip | " fi if command -v tailscale &>/dev/null; then local ts_ip=$(tailscale ip -4 2>/dev/null) [[ -n "$ts_ip" ]] && vpn_info="${vpn_info}Tailscale: $ts_ip | " fi if command -v netbird &>/dev/null; then local nb_ip=$(netbird status 2>/dev/null | grep "NetBird IP:" | awk '{print $3}') [[ -n "$nb_ip" ]] && vpn_info="${vpn_info}Netbird: $nb_ip | " fi vpn_info="${vpn_info% | }" [[ -n "$vpn_info" ]] && echo "$vpn_info" || echo "None" } kiosk_user_exists() { id "$KIOSK_USER" &>/dev/null 2>&1 } is_kiosk_installed() { kiosk_user_exists && sudo -u "$KIOSK_USER" test -f "$KIOSK_DIR/main.js" 2>/dev/null } backup_file() { local file="$1" if [[ -f "$file" ]]; then local backup="${file}.backup-$(date +%Y%m%d-%H%M%S)" sudo cp "$file" "$backup" log_success "Backup: $backup" echo "$backup" fi } get_battery_status() { if [[ -d /sys/class/power_supply/BAT0 ]] || [[ -d /sys/class/power_supply/BAT1 ]]; then local bat_path=$(ls -d /sys/class/power_supply/BAT* 2>/dev/null | head -1) if [[ -n "$bat_path" ]]; then local capacity=$(cat "$bat_path/capacity" 2>/dev/null || echo "N/A") local status=$(cat "$bat_path/status" 2>/dev/null || echo "Unknown") echo "${capacity}% (${status})" return 0 fi fi echo "No battery" return 1 } get_cpu_temp() { if [[ -f /sys/class/thermal/thermal_zone0/temp ]]; then local temp=$(cat /sys/class/thermal/thermal_zone0/temp) local temp_c=$((temp / 1000)) local temp_f=$((temp_c * 9 / 5 + 32)) echo "${temp_c}°C / ${temp_f}°F" elif command -v sensors &>/dev/null; then sensors 2>/dev/null | grep -i "core 0" | awk '{print $3}' | head -1 || echo "N/A" else echo "N/A" fi } get_cpu_info() { local cpu_model=$(lscpu | grep "Model name" | cut -d':' -f2 | xargs) if [[ -z "$cpu_model" ]]; then cpu_model=$(grep "model name" /proc/cpuinfo | head -1 | cut -d':' -f2 | xargs) fi local cores=$(nproc) local threads=$(lscpu | grep "^CPU(s):" | awk '{print $2}') echo "${cpu_model} (${cores} cores, ${threads} threads)" } ################################################################################ ### SECTION 2.5: STATUS DISPLAY FUNCTIONS ################################################################################ show_system_status() { echo " ══ SYSTEM STATUS ══" echo if is_kiosk_installed; then echo "Core System: ✓ Installed (v${SCRIPT_VERSION})" is_service_active lightdm && echo " LightDM: ✓ Running" || echo " LightDM: ✗ Stopped" else echo "Core System: ✗ Not installed" fi echo echo " ══ SYSTEM RESOURCES ══" local ip=$(get_ip_address) echo "IP Address: $ip" local vpn_ips=$(get_vpn_ips) if [[ "$vpn_ips" != "None" ]]; then echo "VPN IPs: $vpn_ips" fi local disk_info=$(df -h / | tail -1) local disk_used=$(echo "$disk_info" | awk '{print $3}') local disk_total=$(echo "$disk_info" | awk '{print $2}') local disk_avail=$(echo "$disk_info" | awk '{print $4}') local disk_pct=$(echo "$disk_info" | awk '{print $5}') echo "Disk: $disk_used used / $disk_total total ($disk_avail free) [$disk_pct]" local mem_info=$(free -h | grep "Mem:") local mem_total=$(echo "$mem_info" | awk '{print $2}') local mem_used=$(echo "$mem_info" | awk '{print $3}') local mem_avail=$(echo "$mem_info" | awk '{print $7}') local mem_pct=$(free | grep Mem | awk '{printf "%.0f", $3/$2 * 100}') echo "RAM: $mem_used used / $mem_total total ($mem_avail free) [${mem_pct}%]" local cpu=$(get_cpu_info) echo "CPU: $cpu" local temp=$(get_cpu_temp) echo "Temperature: $temp" local battery=$(get_battery_status) echo "Battery: $battery" local uptime=$(uptime -p | sed 's/up //') echo "Uptime: $uptime" echo } show_addon_status() { echo " == INSTALLED ADDONS ==" echo local any_addon=false # LMS/Squeezelite - FAST CHECK (just check if files exist) local lms_active=false local sq_active=false if [[ -f /lib/systemd/system/logitechmediaserver.service ]] || \ [[ -f /lib/systemd/system/lyrionmusicserver.service ]]; then lms_active=true any_addon=true fi if [[ -f /etc/systemd/system/squeezelite.service ]]; then sq_active=true any_addon=true fi if $lms_active || $sq_active; then local status_text="LMS/Squeezelite: " if $lms_active && $sq_active; then status_text="${status_text}✓ Server+Player" elif $lms_active; then status_text="${status_text}✓ Server only" else status_text="${status_text}✓ Player only" fi if $lms_active; then local lms_ip=$(get_ip_address) status_text="${status_text} (Server: http://${lms_ip}:9000)" fi echo "$status_text" # Squeezelite player name - only if service file exists if $sq_active && [[ -f /usr/local/bin/squeezelite-start.sh ]]; then local player_name=$(grep '^PLAYER_NAME=' /usr/local/bin/squeezelite-start.sh 2>/dev/null | cut -d'=' -f2 | tr -d '"' || echo "Unknown") if [[ "$player_name" != "Unknown" ]]; then echo " Player: $player_name" fi fi fi # Jitsi Intercom - FAST CHECK if sudo -u "$KIOSK_USER" test -f "$CONFIG_PATH" 2>/dev/null; then if sudo -u "$KIOSK_USER" grep -q "meet.jit.si" "$CONFIG_PATH" 2>/dev/null; then any_addon=true echo "Jitsi Intercom: ✓ Configured" if [[ -f /etc/systemd/system/jitsi-ptt.service ]]; then echo " PTT: ✓ Installed (Spacebar)" fi fi fi # talkkonnect/Murmur Intercom - FAST CHECK local murmur_installed=false local talkkonnect_installed=false [[ -f /lib/systemd/system/mumble-server.service ]] && murmur_installed=true [[ -f "$HOME/go/bin/talkkonnect" ]] && talkkonnect_installed=true if $murmur_installed || $talkkonnect_installed; then any_addon=true local tk_status="talkkonnect: " if $murmur_installed && $talkkonnect_installed; then tk_status="${tk_status}✓ Server+Client" elif $murmur_installed; then tk_status="${tk_status}✓ Server only" else tk_status="${tk_status}✓ Client only" fi if $murmur_installed; then local tk_ip=$(get_ip_address) tk_status="${tk_status} (${tk_ip}:64738)" fi echo "$tk_status" fi # CUPS - FAST CHECK if dpkg -l 2>/dev/null | grep -q "^ii\s\+cups\s"; then any_addon=true local cups_ip=$(get_ip_address) echo "CUPS Printing: ✓ Installed (http://${cups_ip}:631)" fi # VNC - FAST CHECK if [[ -f /etc/systemd/system/x11vnc.service ]]; then any_addon=true local vnc_ip=$(get_ip_address) echo "VNC: ✓ Installed (${vnc_ip}:5900)" fi # VPNs - FAST CHECK [[ -x /usr/bin/wg ]] && { any_addon=true; echo "WireGuard: ✓ Installed"; } [[ -x /usr/bin/tailscale ]] && { any_addon=true; echo "Tailscale: ✓ Installed"; } [[ -x /usr/bin/netbird ]] && { any_addon=true; echo "Netbird: ✓ Installed"; } if ! $any_addon; then echo "No addons installed" fi echo } show_schedule_status() { echo " ══ SCHEDULED TASKS ══" echo local any_schedule=false # FAST CHECK - just look for timer files, read them directly if [[ -f /etc/systemd/system/kiosk-shutdown.timer ]]; then any_schedule=true local ptime=$(grep "^OnCalendar=" /etc/systemd/system/kiosk-shutdown.timer 2>/dev/null | cut -d'=' -f2 | sed 's/\*-\*-\* //' | sed 's/:00$//') [[ -n "$ptime" ]] && echo "Power: Shutdown daily at $ptime" || echo "Power: Shutdown enabled" fi if [[ -f /etc/systemd/system/kiosk-display-off.timer ]]; then any_schedule=true local doff_time=$(grep "^OnCalendar=" /etc/systemd/system/kiosk-display-off.timer 2>/dev/null | cut -d'=' -f2 | sed 's/\*-\*-\* //' | sed 's/:00$//') local don_time=$(grep "^OnCalendar=" /etc/systemd/system/kiosk-display-on.timer 2>/dev/null | cut -d'=' -f2 | sed 's/\*-\*-\* //' | sed 's/:00$//') [[ -n "$doff_time" && -n "$don_time" ]] && echo "Display: Off at $doff_time, On at $don_time" || echo "Display: Enabled" fi if [[ -f /etc/systemd/system/kiosk-quiet-start.timer ]]; then any_schedule=true local qstart_time=$(grep "^OnCalendar=" /etc/systemd/system/kiosk-quiet-start.timer 2>/dev/null | cut -d'=' -f2 | sed 's/\*-\*-\* //' | sed 's/:00$//') local qend_time=$(grep "^OnCalendar=" /etc/systemd/system/kiosk-quiet-end.timer 2>/dev/null | cut -d'=' -f2 | sed 's/\*-\*-\* //' | sed 's/:00$//') [[ -n "$qstart_time" && -n "$qend_time" ]] && echo "Quiet: $qstart_time to $qend_time" || echo "Quiet: Enabled" fi if [[ -f /etc/systemd/system/kiosk-electron-reload.timer ]]; then any_schedule=true echo "Electron Reload: Enabled" fi if ! $any_schedule; then echo "No schedules configured" fi echo } ################################################################################ ### SECTION 3: CORE CONFIGURATION FUNCTIONS ################################################################################ show_current_config() { if sudo -u "$KIOSK_USER" test -f "$CONFIG_PATH" 2>/dev/null; then echo " ═══ CURRENT CONFIGURATION ═══" echo local autoswitch=$(sudo -u "$KIOSK_USER" jq -r '.autoswitch' "$CONFIG_PATH" 2>/dev/null) local swipe_mode=$(sudo -u "$KIOSK_USER" jq -r '.swipeMode' "$CONFIG_PATH" 2>/dev/null) local allow_nav=$(sudo -u "$KIOSK_USER" jq -r '.allowNavigation' "$CONFIG_PATH" 2>/dev/null) local tab_count=$(sudo -u "$KIOSK_USER" jq -r '.tabs | length' "$CONFIG_PATH" 2>/dev/null || echo "0") echo "Auto-rotation: $autoswitch" echo "Touch control: $swipe_mode" echo "Navigation: $allow_nav" echo "Sites configured: $tab_count" if [[ "$tab_count" -gt 0 ]]; then echo echo "Sites:" local has_rotation=false for ((i=0; i 0" fi fi echo else log_warning "No configuration found" echo fi } configure_timezone() { echo " ═══ TIMEZONE CONFIGURATION ═══" echo local current_tz=$(timedatectl show -p Timezone --value) echo "Current timezone: $current_tz" echo echo "Select timezone:" echo echo "Common Timezones:" echo " 1. America/New_York (US Eastern)" echo " 2. America/Chicago (US Central)" echo " 3. America/Denver (US Mountain)" echo " 4. America/Los_Angeles (US Pacific)" echo " 5. America/Phoenix (US Arizona)" echo " 6. America/Anchorage (US Alaska)" echo " 7. Pacific/Honolulu (US Hawaii)" echo " 8. Europe/London (UK)" echo " 9. Europe/Paris (Central Europe)" echo " 10. Europe/Berlin (Germany)" echo " 11. Europe/Rome (Italy)" echo " 12. Asia/Tokyo (Japan)" echo " 13. Asia/Shanghai (China)" echo " 14. Asia/Dubai (UAE)" echo " 15. Australia/Sydney (Australia East)" echo " 16. Pacific/Auckland (New Zealand)" echo " 17. Search for timezone" echo " 18. Enter timezone manually" echo " 0. Keep current ($current_tz)" echo read -r -p "Choose [0-18]: " tz_choice local new_tz="" case "$tz_choice" in 1) new_tz="America/New_York" ;; 2) new_tz="America/Chicago" ;; 3) new_tz="America/Denver" ;; 4) new_tz="America/Los_Angeles" ;; 5) new_tz="America/Phoenix" ;; 6) new_tz="America/Anchorage" ;; 7) new_tz="Pacific/Honolulu" ;; 8) new_tz="Europe/London" ;; 9) new_tz="Europe/Paris" ;; 10) new_tz="Europe/Berlin" ;; 11) new_tz="Europe/Rome" ;; 12) new_tz="Asia/Tokyo" ;; 13) new_tz="Asia/Shanghai" ;; 14) new_tz="Asia/Dubai" ;; 15) new_tz="Australia/Sydney" ;; 16) new_tz="Pacific/Auckland" ;; 17) echo echo "Available regions:" local regions=($(timedatectl list-timezones | cut -d'/' -f1 | sort -u)) for i in "${!regions[@]}"; do printf " %2d) %s\n" $((i+1)) "${regions[$i]}" done echo read -r -p "Select region number [1-${#regions[@]}]: " region_num if [[ "$region_num" =~ ^[0-9]+$ ]] && [[ "$region_num" -ge 1 ]] && [[ "$region_num" -le "${#regions[@]}" ]]; then local selected_region="${regions[$((region_num-1))]}" echo echo "Timezones in $selected_region:" local timezones=($(timedatectl list-timezones | grep "^${selected_region}/")) for i in "${!timezones[@]}"; do printf " %3d) %s\n" $((i+1)) "${timezones[$i]}" done echo read -r -p "Select timezone number [1-${#timezones[@]}]: " tz_num if [[ "$tz_num" =~ ^[0-9]+$ ]] && [[ "$tz_num" -ge 1 ]] && [[ "$tz_num" -le "${#timezones[@]}" ]]; then new_tz="${timezones[$((tz_num-1))]}" else log_error "Invalid timezone selection" return fi else log_error "Invalid region selection" return fi ;; 18) echo read -r -p "Enter timezone (e.g., America/New_York): " new_tz ;; 0|"") echo "Keeping current timezone" return ;; *) log_error "Invalid choice" return ;; esac case "$new_tz" in "US/Eastern") new_tz="America/New_York" ;; "US/Central") new_tz="America/Chicago" ;; "US/Mountain") new_tz="America/Denver" ;; "US/Pacific") new_tz="America/Los_Angeles" ;; "US/Alaska") new_tz="America/Anchorage" ;; "US/Hawaii") new_tz="Pacific/Honolulu" ;; "US/Arizona") new_tz="America/Phoenix" ;; esac if [[ -n "$new_tz" ]]; then if timedatectl list-timezones | grep -q "^${new_tz}$"; then sudo timedatectl set-timezone "$new_tz" && log_success "Timezone updated to $new_tz" else log_error "Invalid timezone: $new_tz" fi fi } configure_touch_controls() { echo " ═══ TOUCH CONTROLS CONFIGURATION ═══" echo echo "Touch control modes:" echo echo " DUAL-DIRECTION (recommended for touchscreens):" echo " • Two-finger swipe left/right = Switch between sites" echo " • One-finger swipe left/right = Navigate within page (arrow keys)" echo " Allows both site switching AND page navigation" echo echo " STANDARD (simpler):" echo " • Two-finger swipe left/right = Switch between sites only" echo " • One-finger swipes do nothing" echo echo "NOTE: Touch controls are optional. Keyboard/mouse work without touch." echo load_config || true local current_mode="dual" if sudo -u "$KIOSK_USER" test -f "$CONFIG_PATH" 2>/dev/null; then current_mode=$(sudo -u "$KIOSK_USER" jq -r '.swipeMode' "$CONFIG_PATH" 2>/dev/null) fi echo "Current: $current_mode" read -r -p "Use dual-direction mode? (y/n): " use_dual if [[ "$use_dual" =~ ^[Nn]$ ]]; then SWIPE_MODE="standard" else SWIPE_MODE="dual" fi log_success "Touch mode: $SWIPE_MODE" } configure_navigation_security() { echo " ═══ NAVIGATION SECURITY ═══" echo echo "Controls what users can access by clicking links:" echo echo " RESTRICTED:" echo " • Only the exact URL loaded (no link clicking)" echo " • Use for locked-down kiosks" echo echo " SAME-ORIGIN (recommended):" echo " • Can click links within the same domain" echo " • Example: example.com can link to example.com/page2" echo " • Cannot go to different domains" echo echo " OPEN:" echo " • Can click any link, browse anywhere" echo " • Use only for trusted environments" echo load_config || true local current_nav="same-origin" if sudo -u "$KIOSK_USER" test -f "$CONFIG_PATH" 2>/dev/null; then current_nav=$(sudo -u "$KIOSK_USER" jq -r '.allowNavigation' "$CONFIG_PATH" 2>/dev/null) fi echo "Current: $current_nav" echo read -r -p "(r)estricted / (s)ame-origin / (o)pen [s]: " nav_choice case "${nav_choice:-s}" in [Rr]) ALLOW_NAVIGATION="restricted" ;; [Oo]) ALLOW_NAVIGATION="open" ;; *) ALLOW_NAVIGATION="same-origin" ;; esac log_success "Navigation: $ALLOW_NAVIGATION" } configure_sites() { while true; do echo " ═══ SITES CONFIGURATION ═══" echo URLS=() DURS=() USERS=() PASSES=() if sudo -u "$KIOSK_USER" test -f "$CONFIG_PATH" 2>/dev/null; then HOME_TAB_INDEX=$(sudo -u "$KIOSK_USER" jq -r '.homeTabIndex // -1' "$CONFIG_PATH" 2>/dev/null) INACTIVITY_TIMEOUT=$(sudo -u "$KIOSK_USER" jq -r '.inactivityTimeout // 120' "$CONFIG_PATH" 2>/dev/null) local tab_count=$(sudo -u "$KIOSK_USER" jq -r '.tabs | length' "$CONFIG_PATH" 2>/dev/null || echo "0") if [[ "$tab_count" -gt 0 ]]; then echo "Current sites:" local has_rotation=false for ((i=0; i 0)" else echo "Auto-rotation: ✗ No sites configured for rotation" fi echo if [[ "$HOME_TAB_INDEX" != "-1" ]]; then local timeout_min=$((INACTIVITY_TIMEOUT / 60)) echo "Home URL: ✓ Enabled (${timeout_min} min timeout)" else echo "Home URL: ✗ Disabled" fi echo echo "Options:" start_menu add_option "Keep all sites as-is" add_option "Update durations" add_option "Add more sites" add_option "Delete a site" add_option "Edit a site URL" add_option "Reorder sites" add_option "Configure Home URL" add_option "Clear all, start over" show_menu_prompt site_choice=$choice case "$site_choice" in 1) log_success "Keeping existing sites" return ;; 2) update_site_durations save_config continue ;; 3) echo echo "Adding new sites:" add_new_sites_simple save_config continue ;; 4) delete_site save_config continue ;; 5) edit_site_url save_config continue ;; 6) reorder_sites save_config continue ;; 7) configure_home_url save_config continue ;; 8) URLS=() DURS=() USERS=() PASSES=() HOME_TAB_INDEX=-1 INACTIVITY_TIMEOUT=120 LOCKOUT_ENABLED="false" LOCKOUT_PASSWORD="" LOCKOUT_TIMEOUT=1800 add_new_sites save_config continue ;; 0) return ;; *) log_error "Invalid choice" sleep 1 continue ;; esac fi fi add_new_sites save_config return done } update_site_durations() { echo echo "Update site durations:" echo echo "ℹ Duration Guide:" echo " • 0 seconds = Manual only (won't auto-rotate)" echo " • 1-999 seconds = Auto-rotates every X seconds" echo " • -1 = Hidden tab (F10 to access)" echo local new_durs=() for idx in "${!URLS[@]}"; do echo "Site $((idx+1)): ${URLS[$idx]}" read -r -p " Duration in seconds (0=manual, >0=rotate) [${DURS[$idx]}]: " new_dur new_dur="${new_dur:-${DURS[$idx]}}" new_durs+=("$new_dur") echo done DURS=("${new_durs[@]}") log_success "Site durations updated" } delete_site() { echo echo "Delete which site?" for idx in "${!URLS[@]}"; do echo " $((idx+1)). ${URLS[$idx]}" done echo read -r -p "Enter number to delete (0=cancel): " del_num if [[ "$del_num" =~ ^[0-9]+$ ]] && [[ "$del_num" -ge 1 ]] && [[ "$del_num" -le "${#URLS[@]}" ]]; then local del_idx=$((del_num-1)) echo "Deleting: ${URLS[$del_idx]}" unset 'URLS[$del_idx]' unset 'DURS[$del_idx]' unset 'USERS[$del_idx]' unset 'PASSES[$del_idx]' URLS=("${URLS[@]}") DURS=("${DURS[@]}") USERS=("${USERS[@]}") PASSES=("${PASSES[@]}") log_success "Site deleted" log_warning "Site numbers have changed! Review rotation settings." else echo "Cancelled" fi } edit_site_url() { echo echo " ═══ EDIT SITE URL ═══" echo echo "Edit which site?" for idx in "${!URLS[@]}"; do local dur_display="${DURS[$idx]}s" [[ "${DURS[$idx]}" == "-1" ]] && dur_display="hidden" [[ "${DURS[$idx]}" == "0" ]] && dur_display="manual" echo " $((idx+1)). ${URLS[$idx]} ($dur_display)" done echo read -r -p "Enter number to edit (0=cancel): " edit_num if [[ "$edit_num" =~ ^[0-9]+$ ]] && [[ "$edit_num" -ge 1 ]] && [[ "$edit_num" -le "${#URLS[@]}" ]]; then local edit_idx=$((edit_num-1)) echo echo "Editing site #$edit_num:" echo "Current URL: ${URLS[$edit_idx]}" echo "Current Duration: ${DURS[$edit_idx]}s" echo "Current User: ${USERS[$edit_idx]:-none}" echo read -r -p "New URL [${URLS[$edit_idx]}]: " new_url new_url="${new_url:-${URLS[$edit_idx]}}" if [[ ! "$new_url" =~ ^https?:// ]]; then new_url="https://$new_url" fi echo read -r -p "Change duration too? (y/n) [n]: " change_dur local new_dur="${DURS[$edit_idx]}" if [[ "$change_dur" =~ ^[Yy]$ ]]; then echo echo "Duration Guide:" echo " • 0 = Manual only (won't auto-rotate)" echo " • 1-999 = Auto-rotates every X seconds" echo " • -1 = Hidden tab (F10 to access)" echo read -r -p "Duration in seconds [${DURS[$edit_idx]}]: " new_dur new_dur="${new_dur:-${DURS[$edit_idx]}}" fi echo read -r -p "Change authentication? (y/n) [n]: " change_auth local new_user="${USERS[$edit_idx]}" local new_pass="${PASSES[$edit_idx]}" if [[ "$change_auth" =~ ^[Yy]$ ]]; then echo read -r -p "Username (leave blank for none) [${USERS[$edit_idx]}]: " new_user new_user="${new_user:-${USERS[$edit_idx]}}" if [[ -n "$new_user" ]]; then read -r -s -p "Password: " new_pass echo else new_user="" new_pass="" fi fi URLS[$edit_idx]="$new_url" DURS[$edit_idx]="$new_dur" USERS[$edit_idx]="$new_user" PASSES[$edit_idx]="$new_pass" log_success "Site #$edit_num updated" echo " URL: $new_url" echo " Duration: ${new_dur}s" [[ -n "$new_user" ]] && echo " Auth: $new_user" echo else echo "Cancelled" fi return 0 } reorder_sites() { echo echo " ═══ REORDER SITES ═══" echo echo "Sites are rotated in the order listed below." echo "Use this to change the rotation order." echo while true; do echo "Current order:" for idx in "${!URLS[@]}"; do local dur_display="${DURS[$idx]}s" [[ "${DURS[$idx]}" == "-1" ]] && dur_display="hidden" [[ "${DURS[$idx]}" == "0" ]] && dur_display="manual" local home_mark="" [[ "$HOME_TAB_INDEX" == "$idx" ]] && home_mark=" [HOME]" echo " $((idx+1)). ${URLS[$idx]} ($dur_display)$home_mark" done echo read -r -p "Select site to move (0=done): " move_num if [[ "$move_num" == "0" ]]; then log_success "Reordering complete" return fi if ! [[ "$move_num" =~ ^[0-9]+$ ]] || [[ "$move_num" -lt 1 ]] || [[ "$move_num" -gt "${#URLS[@]}" ]]; then log_error "Invalid site number" sleep 1 echo continue fi local move_idx=$((move_num-1)) echo echo "Moving: ${URLS[$move_idx]}" echo echo "Options:" start_menu [[ "$move_idx" -gt 0 ]] && add_option "Move up (swap with site #$move_num)" [[ "$move_idx" -lt $((${#URLS[@]}-1)) ]] && add_option "Move down (swap with site #$((move_num+2)))" add_option "Jump to specific position" show_menu_prompt local reorder_choice=$choice local menu_offset=0 # Handle menu numbering based on available options if [[ "$move_idx" -gt 0 ]]; then case "$reorder_choice" in 1) # Move up local swap_idx=$((move_idx-1)) # Swap URLs local temp_url="${URLS[$move_idx]}" URLS[$move_idx]="${URLS[$swap_idx]}" URLS[$swap_idx]="$temp_url" # Swap durations local temp_dur="${DURS[$move_idx]}" DURS[$move_idx]="${DURS[$swap_idx]}" DURS[$swap_idx]="$temp_dur" # Swap users local temp_user="${USERS[$move_idx]}" USERS[$move_idx]="${USERS[$swap_idx]}" USERS[$swap_idx]="$temp_user" # Swap passwords local temp_pass="${PASSES[$move_idx]}" PASSES[$move_idx]="${PASSES[$swap_idx]}" PASSES[$swap_idx]="$temp_pass" # Update HOME_TAB_INDEX if needed if [[ "$HOME_TAB_INDEX" == "$move_idx" ]]; then HOME_TAB_INDEX=$swap_idx elif [[ "$HOME_TAB_INDEX" == "$swap_idx" ]]; then HOME_TAB_INDEX=$move_idx fi log_success "Moved up" echo continue ;; esac menu_offset=1 fi if [[ "$move_idx" -lt $((${#URLS[@]}-1)) ]]; then if [[ "$reorder_choice" == "$((menu_offset+1))" ]]; then # Move down local swap_idx=$((move_idx+1)) # Swap URLs local temp_url="${URLS[$move_idx]}" URLS[$move_idx]="${URLS[$swap_idx]}" URLS[$swap_idx]="$temp_url" # Swap durations local temp_dur="${DURS[$move_idx]}" DURS[$move_idx]="${DURS[$swap_idx]}" DURS[$swap_idx]="$temp_dur" # Swap users local temp_user="${USERS[$move_idx]}" USERS[$move_idx]="${USERS[$swap_idx]}" USERS[$swap_idx]="$temp_user" # Swap passwords local temp_pass="${PASSES[$move_idx]}" PASSES[$move_idx]="${PASSES[$swap_idx]}" PASSES[$swap_idx]="$temp_pass" # Update HOME_TAB_INDEX if needed if [[ "$HOME_TAB_INDEX" == "$move_idx" ]]; then HOME_TAB_INDEX=$swap_idx elif [[ "$HOME_TAB_INDEX" == "$swap_idx" ]]; then HOME_TAB_INDEX=$move_idx fi log_success "Moved down" echo continue fi menu_offset=$((menu_offset+1)) fi if [[ "$reorder_choice" == "$((menu_offset+1))" ]]; then # Jump to position echo read -r -p "Jump to position (1-${#URLS[@]}): " new_pos if ! [[ "$new_pos" =~ ^[0-9]+$ ]] || [[ "$new_pos" -lt 1 ]] || [[ "$new_pos" -gt "${#URLS[@]}" ]]; then log_error "Invalid position" sleep 1 echo continue fi local new_idx=$((new_pos-1)) if [[ "$new_idx" == "$move_idx" ]]; then echo "Already at that position" sleep 1 echo continue fi # Save the item being moved local temp_url="${URLS[$move_idx]}" local temp_dur="${DURS[$move_idx]}" local temp_user="${USERS[$move_idx]}" local temp_pass="${PASSES[$move_idx]}" # Remove from old position unset 'URLS[$move_idx]' unset 'DURS[$move_idx]' unset 'USERS[$move_idx]' unset 'PASSES[$move_idx]' URLS=("${URLS[@]}") DURS=("${DURS[@]}") USERS=("${USERS[@]}") PASSES=("${PASSES[@]}") # Adjust new_idx if moving forward if [[ "$new_idx" -gt "$move_idx" ]]; then new_idx=$((new_idx-1)) fi # Insert at new position URLS=("${URLS[@]:0:$new_idx}" "$temp_url" "${URLS[@]:$new_idx}") DURS=("${DURS[@]:0:$new_idx}" "$temp_dur" "${DURS[@]:$new_idx}") USERS=("${USERS[@]:0:$new_idx}" "$temp_user" "${USERS[@]:$new_idx}") PASSES=("${PASSES[@]:0:$new_idx}" "$temp_pass" "${PASSES[@]:$new_idx}") # Update HOME_TAB_INDEX if needed if [[ "$HOME_TAB_INDEX" == "$move_idx" ]]; then HOME_TAB_INDEX=$new_idx elif [[ "$move_idx" -lt "$HOME_TAB_INDEX" ]] && [[ "$HOME_TAB_INDEX" -le "$new_idx" ]]; then HOME_TAB_INDEX=$((HOME_TAB_INDEX-1)) elif [[ "$move_idx" -gt "$HOME_TAB_INDEX" ]] && [[ "$HOME_TAB_INDEX" -ge "$new_idx" ]]; then HOME_TAB_INDEX=$((HOME_TAB_INDEX+1)) fi log_success "Moved to position $new_pos" echo continue elif [[ "$reorder_choice" == "0" ]]; then echo continue fi log_error "Invalid choice" sleep 1 echo done } configure_home_url() { echo echo " ═══ HOME URL CONFIGURATION ═══" echo echo "A HOME URL is where the kiosk returns after inactivity on other tabs." echo echo "How it works:" echo " • Choose one site to be the HOME tab" echo " • After X minutes on other tabs, user is prompted:" echo " \"Are you still here?\"" echo " • If no response in 10 seconds, returns to HOME tab" echo " • Good for: Main dashboard, photo slideshow, default screen" echo if sudo -u "$KIOSK_USER" test -f "$CONFIG_PATH" 2>/dev/null; then HOME_TAB_INDEX=$(sudo -u "$KIOSK_USER" jq -r '.homeTabIndex // -1' "$CONFIG_PATH" 2>/dev/null) INACTIVITY_TIMEOUT=$(sudo -u "$KIOSK_USER" jq -r '.inactivityTimeout // 120' "$CONFIG_PATH" 2>/dev/null) fi if [[ "$HOME_TAB_INDEX" != "-1" ]]; then local timeout_min=$((INACTIVITY_TIMEOUT / 60)) echo "Current: Site #$((HOME_TAB_INDEX + 1)) is HOME (${timeout_min} min timeout)" else echo "Current: Disabled" fi echo echo "Options:" start_menu add_option "Enable/Change Home URL" add_option "Disable Home URL" show_menu_prompt home_choice=$choice case "$home_choice" in 1) echo echo "Select which site should be HOME:" for idx in "${!URLS[@]}"; do echo " $((idx+1)). ${URLS[$idx]}" done echo read -r -p "Site number [1]: " site_num site_num="${site_num:-1}" if [[ "$site_num" =~ ^[0-9]+$ ]] && [[ "$site_num" -ge 1 ]] && [[ "$site_num" -le "${#URLS[@]}" ]]; then HOME_TAB_INDEX=$((site_num - 1)) echo read -r -p "Inactivity timeout in minutes [2]: " timeout_min timeout_min="${timeout_min:-2}" INACTIVITY_TIMEOUT=$((timeout_min * 60)) log_success "Home URL: Site #${site_num} (${timeout_min} min timeout)" else log_error "Invalid site number" fi ;; 2) HOME_TAB_INDEX=-1 INACTIVITY_TIMEOUT=120 log_success "Home URL disabled" ;; 0) echo "Cancelled" ;; esac } configure_lockout() { echo echo " ═══ SESSION LOCKOUT CONFIGURATION ═══" echo echo "Session lockout protects your kiosk with a password after inactivity." echo echo "How it works:" echo " • After X minutes of inactivity, screen locks (goes black)" echo " • Shows 'Session Locked' message with password prompt" echo " • Enter password to unlock and resume" echo " • Password is separate from sudo/system password" echo if sudo -u "$KIOSK_USER" test -f "$CONFIG_PATH" 2>/dev/null; then LOCKOUT_ENABLED=$(sudo -u "$KIOSK_USER" jq -r '.lockoutEnabled // "false"' "$CONFIG_PATH" 2>/dev/null) LOCKOUT_TIMEOUT=$(sudo -u "$KIOSK_USER" jq -r '.lockoutTimeout // 1800' "$CONFIG_PATH" 2>/dev/null) fi if [[ "$LOCKOUT_ENABLED" == "true" ]]; then local timeout_min=$((LOCKOUT_TIMEOUT / 60)) echo "Current: Enabled (${timeout_min} min timeout)" else echo "Current: Disabled" fi echo echo "Options:" start_menu add_option "Enable/Change Lockout" add_option "Disable Lockout" show_menu_prompt lockout_choice=$choice case "$lockout_choice" in 1) echo read -r -p "Lockout timeout in minutes [30]: " timeout_min timeout_min="${timeout_min:-30}" LOCKOUT_TIMEOUT=$((timeout_min * 60)) echo echo "Set a password for unlocking the kiosk:" echo "(This password is separate from your system password)" echo while true; do read -r -s -p "Enter lockout password: " pass1 echo read -r -s -p "Confirm password: " pass2 echo if [[ "$pass1" == "$pass2" ]]; then if [[ -n "$pass1" ]]; then LOCKOUT_PASSWORD="$pass1" LOCKOUT_ENABLED="true" log_success "Session lockout enabled (${timeout_min} min timeout)" break else log_error "Password cannot be empty" echo fi else log_error "Passwords don't match, try again" echo fi done ;; 2) LOCKOUT_ENABLED="false" LOCKOUT_PASSWORD="" LOCKOUT_TIMEOUT=1800 log_success "Session lockout disabled" ;; 0) echo "Cancelled" ;; esac } add_new_sites() { echo " ═══ SITE ROTATION SETUP ═══" echo echo "HOW AUTO-ROTATION WORKS:" echo "────────────────────────────" echo "Each site has a DURATION:" echo echo " • Duration > 0 = Auto-rotates after X seconds" echo " • Duration = 0 = Manual only (swipe to access)" echo " • Duration = -1 = Hidden (F10 or 3-finger up + PIN)" echo echo "💡 Want NO auto-rotation? Set ALL sites to 0 seconds" echo " (You can still swipe between sites manually)" echo echo echo "EXAMPLES:" echo " Site A: 180s → Auto-rotates every 3 minutes" echo " Site B: 60s → Auto-rotates every 1 minute" echo " Site C: 0s → Manual access only" echo " Site D: -1s → Hidden behind PIN" echo pause local use_home_url=false local inactivity_minutes=2 local home_duration=180 echo " ═══ HOME URL FEATURE ═══" echo echo "A HOME URL returns the kiosk to a default screen after inactivity." echo echo "How it works:" echo " • First site = HOME" echo " • After X minutes of inactivity on OTHER sites:" echo " → Prompt: \"Are you still here?\"" echo " → No response = returns to HOME" echo echo "Good for: Photo slideshows, dashboards, screensavers" echo if ask_yes_no "Enable HOME URL feature?" "n"; then use_home_url=true read -r -p "Inactivity timeout in minutes [2]: " inactivity_minutes inactivity_minutes="${inactivity_minutes:-2}" read -r -p "Home display duration in seconds [180]: " home_duration home_duration="${home_duration:-180}" echo "✓ HOME: Returns after ${inactivity_minutes}min, displays ${home_duration}s" else echo "✓ HOME disabled" fi echo echo " ═══ ENTER SITES ═══" echo echo "Supported formats:" echo " • example.com → https://example.com" echo " • https://example.com" echo " • 192.168.1.3:8080 → http://192.168.1.3:8080" echo if $use_home_url; then echo "FIRST SITE = HOME (will display ${home_duration}s before rotating)" fi echo echo "Enter sites (blank when done):" echo local is_first=true while true; do echo "────────────────────────────" read -r -p "URL (blank=done): " raw_url [[ -z "$raw_url" ]] && break # Parse URL local url="" if [[ "$raw_url" =~ ^https?:// ]]; then url="$raw_url" elif [[ "$raw_url" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+ ]]; then url="http://${raw_url}" else url="https://${raw_url}" fi # Duration local dur="" local is_home=false if $is_first && $use_home_url; then is_home=true dur=$home_duration echo " → HOME site (${dur}s rotation)" else echo " Duration options:" echo " >0 = Auto-rotate (e.g., 180 = 3 minutes)" echo " 0 = Manual only" echo " -1 = Hidden (PIN protected)" read -r -p " Duration [180]: " dur dur="${dur:-180}" fi # Auth echo "" echo "❓ Does this website require login credentials?" echo "" echo " ONLY say yes if:" echo " • The website uses HTTP Basic Authentication" echo " • You get a browser popup asking for username/password" echo " • The website documentation says 'Basic Auth'" echo "" echo " Say NO if:" echo " • The website has a login page with forms" echo " • You don't know what Basic Auth is" echo " • The website is public (Google, YouTube, etc.)" echo "" read -r -p " Does this site use Basic Auth? (y/n): " needs_auth if [[ "$needs_auth" =~ ^[Yy]$ ]]; then echo "" echo " Enter credentials (saved in config.json)" read -r -p " Username: " auth_user read -r -s -p " Password: " auth_pass echo USERS+=("$auth_user") PASSES+=("$auth_pass") else USERS+=("") PASSES+=("") fi URLS+=("$url") DURS+=("$dur") if $is_home; then HOME_TAB_INDEX=$((${#URLS[@]} - 1)) INACTIVITY_TIMEOUT=$((inactivity_minutes * 60)) echo " ✓ HOME configured" fi local dur_display="${dur}s" [[ "$dur" == "0" ]] && dur_display="manual" [[ "$dur" == "-1" ]] && dur_display="hidden" echo " ✓ Added: $url ($dur_display)" is_first=false done if [[ ${#URLS[@]} -eq 0 ]]; then URLS=("https://www.ubuntu.com") DURS=(180) USERS=("") PASSES=("") log_success "Using default: ubuntu.com (180s)" fi echo log_success "${#URLS[@]} sites configured" # Auto-switch if any site has duration > 0 AUTOSWITCH="false" for dur in "${DURS[@]}"; do if [[ "$dur" != "0" && "$dur" != "-1" ]]; then AUTOSWITCH="true" break fi done echo if [[ "$AUTOSWITCH" == "true" ]]; then echo "✓ Auto-rotation ENABLED" echo " Sites with duration>0 will rotate" echo " Sites with duration=0 are manual-only" [[ "$use_home_url" == "true" ]] && echo " Home included in rotation (${home_duration}s)" else echo "✓ Auto-rotation DISABLED (all sites manual)" fi # Session Lockout Configuration echo echo "════════════════════════════════════════" echo "SESSION LOCKOUT (Password Protection)" echo "════════════════════════════════════════" echo echo "Lock the kiosk with a password after inactivity." echo " • After X minutes idle → screen locks" echo " • Password required to unlock" echo if ask_yes_no "Enable session lockout?" "n"; then echo read -r -p "Lockout timeout in minutes [30]: " lockout_min lockout_min="${lockout_min:-30}" LOCKOUT_TIMEOUT=$((lockout_min * 60)) echo echo "Set a password for unlocking the kiosk:" echo "(This is separate from your system password)" echo while true; do read -r -s -p "Enter lockout password: " pass1 echo read -r -s -p "Confirm password: " pass2 echo if [[ "$pass1" == "$pass2" ]]; then if [[ -n "$pass1" ]]; then LOCKOUT_PASSWORD="$pass1" LOCKOUT_ENABLED="true" log_success "Session lockout enabled (${lockout_min} min timeout)" break else log_error "Password cannot be empty" echo fi else log_error "Passwords don't match, try again" echo fi done else echo "✓ Session lockout disabled" LOCKOUT_ENABLED="false" LOCKOUT_PASSWORD="" fi } add_new_sites_simple() { if sudo -u "$KIOSK_USER" test -f "$CONFIG_PATH" 2>/dev/null; then local current_autoswitch=$(sudo -u "$KIOSK_USER" jq -r '.autoswitch' "$CONFIG_PATH" 2>/dev/null) AUTOSWITCH="$current_autoswitch" fi local has_existing_timings=false local existing_timing="" for dur in "${DURS[@]}"; do if [[ "$dur" != "0" && "$dur" != "-1" ]]; then has_existing_timings=true existing_timing="$dur" break fi done echo "Enter new sites (blank URL when done):" echo echo "ℹ Duration: 0=manual only, >0=auto-rotate every X seconds" echo while true; do read -r -p "URL (blank=done): " raw_url [[ -z "$raw_url" ]] && break local url="" if [[ "$raw_url" =~ ^https?:// ]]; then url="$raw_url" elif [[ "$raw_url" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+ ]]; then url="http://${raw_url}" else url="https://${raw_url}" fi local dur="" if [[ "$AUTOSWITCH" == "true" ]]; then if $has_existing_timings; then read -r -p " Duration in seconds [$existing_timing]: " dur dur="${dur:-$existing_timing}" else read -r -p " Duration in seconds [180]: " dur dur="${dur:-180}" fi else dur=0 echo " Duration: manual (auto-rotation is disabled)" fi read -r -p " Basic auth? (y/n): " needs_auth if [[ "$needs_auth" =~ ^[Yy]$ ]]; then read -r -p " Username: " auth_user read -r -s -p " Password: " auth_pass echo USERS+=("$auth_user") PASSES+=("$auth_pass") else USERS+=("") PASSES+=("") fi URLS+=("$url") DURS+=("$dur") done log_success "${#URLS[@]} total sites configured" } ################################################################################ ### SECTION 4: WIFI CONFIGURATION (3-method scan + better errors) ################################################################################ configure_wifi() { echo " ══ WIFI CONFIGURATION ══" echo # Verify we have necessary tools local has_tools=false if command -v nmcli &>/dev/null || command -v iw &>/dev/null || command -v wpa_cli &>/dev/null; then has_tools=true fi if ! $has_tools; then log_error "No WiFi tools found (nmcli, iw, or wpa_cli)" echo "Install: sudo apt install network-manager wireless-tools wpasupplicant" pause return 1 fi local wifi_iface=$(ls /sys/class/net 2>/dev/null | grep -E "^wl" | head -1) if [[ -z "$wifi_iface" ]]; then log_warning "No WiFi hardware detected" echo "If you have USB WiFi adapter, ensure it's plugged in" pause return 1 fi echo "Interface: $wifi_iface" echo "Current IP: $(get_ip_address)" echo if [[ -n "${SSH_CONNECTION:-}" ]]; then log_warning "SSH detected - changes auto-revert after 60s if connection fails" echo fi read -r -p "Configure WiFi? (y/n): " do_wifi [[ ! "$do_wifi" =~ ^[Yy]$ ]] && return 0 echo "Bringing up interface..." if ! sudo ip link set "$wifi_iface" up 2>/dev/null; then log_error "Failed to bring up interface" pause return 1 fi sleep 3 echo "Scanning for networks (this takes 5-10 seconds)..." local scan_results="" # Try nmcli first if command -v nmcli &>/dev/null; then if sudo nmcli device wifi rescan 2>/dev/null; then sleep 5 scan_results=$(nmcli -t -f SSID,SIGNAL device wifi list 2>/dev/null | sort -t: -k2 -rn | cut -d: -f1 | grep -v "^$" | uniq) fi fi # Fallback to iw if nmcli failed if [[ -z "$scan_results" ]] && command -v iw &>/dev/null; then if sudo iw dev "$wifi_iface" scan 2>/dev/null | grep -E "^BSS|SSID:" > /tmp/wifi_scan.txt; then scan_results=$(grep "SSID:" /tmp/wifi_scan.txt | sed 's/.*SSID: //' | grep -v "^$" | sort -u) rm -f /tmp/wifi_scan.txt fi fi # Fallback to wpa_cli if [[ -z "$scan_results" ]]; then sudo wpa_cli -i "$wifi_iface" scan >/dev/null 2>&1 || true sleep 5 scan_results=$(sudo wpa_cli -i "$wifi_iface" scan_results 2>/dev/null | awk -F'\t' 'NR>1 && $5!="" {print $5}' | sort -u) fi local ssid="" if [[ -z "$scan_results" ]]; then log_warning "No networks found in scan" echo "This could mean:" echo " • WiFi is disabled in BIOS/UEFI" echo " • Hardware WiFi switch is off" echo " • Driver not loaded" echo " • Networks out of range" echo read -r -p "Enter SSID manually anyway? (y/n): " manual if [[ "$manual" =~ ^[Yy]$ ]]; then read -r -p "SSID: " ssid else return 1 fi else echo echo "Available networks (strongest first):" echo "$scan_results" | nl -w2 -s'. ' echo " 0. Manual entry" echo read -r -p "Select network number or enter SSID: " choice if [[ "$choice" == "0" ]]; then read -r -p "SSID: " ssid elif [[ "$choice" =~ ^[0-9]+$ ]]; then ssid=$(echo "$scan_results" | sed -n "${choice}p") else ssid="$choice" fi fi [[ -z "$ssid" ]] && { log_error "No SSID provided"; return 1; } read -r -s -p "Password for '$ssid': " password echo [[ -z "$password" ]] && { log_error "No password provided"; return 1; } local netplan_file=$(ls /etc/netplan/*.yaml 2>/dev/null | head -1) if [[ -z "$netplan_file" ]]; then netplan_file="/etc/netplan/50-cloud-init.yaml" fi if [[ -f "$netplan_file" ]]; then local backup="${netplan_file}.backup-$(date +%Y%m%d-%H%M%S)" sudo cp "$netplan_file" "$backup" log_success "Backup: $backup" fi local temp_plan="/tmp/netplan-$$.yaml" cat > "$temp_plan" < "$watchdog" <<'WATCHEOF' #!/bin/bash sleep 60 if [[ -f "$1" && -f "$2" ]]; then ip=$(hostname -I | awk '{print $1}') if [[ -z "$ip" ]] || ! ping -c 2 8.8.8.8 >/dev/null 2>&1; then cp "$1" "$2" netplan apply 2>/dev/null echo "WiFi config reverted - connection failed" | wall fi fi rm -f "$0" WATCHEOF chmod +x "$watchdog" nohup sudo bash "$watchdog" "$backup" "$netplan_file" >/dev/null 2>&1 & echo "Watchdog started - will revert in 60s if connection fails" fi sudo cp "$temp_plan" "$netplan_file" sudo chmod 0600 "$netplan_file" echo "Applying configuration..." if sudo netplan apply 2>&1 | tee /tmp/netplan-error.log; then sleep 10 local new_ip=$(get_ip_address) if [[ -n "$new_ip" && "$new_ip" != "No IP" ]]; then log_success "Connected: $ssid ($new_ip)" if [[ -n "${SSH_CONNECTION:-}" ]]; then echo "Connection successful - watchdog will not revert" fi else log_warning "Config applied but no IP yet" echo "Check: sudo journalctl -u systemd-networkd -f" fi else log_error "netplan apply failed" echo "Error log:" cat /tmp/netplan-error.log if [[ -f "$backup" ]]; then echo read -r -p "Restore backup? (y/n): " restore if [[ "$restore" =~ ^[Yy]$ ]]; then sudo cp "$backup" "$netplan_file" sudo netplan apply fi fi fi rm -f "$temp_plan" /tmp/netplan-error.log pause } ################################################################################ ### FIX 4.5: EMERGENCY HOTSPOT WITH ON-SCREEN NOTIFICATION ################################################################################ # ADD THIS NEW FUNCTION after configure_wifi() function (around line 2100) configure_emergency_hotspot() { echo echo "══ EMERGENCY HOTSPOT ══" echo echo "Creates a WiFi hotspot if no internet connection after boot." echo "Allows you to connect and reconfigure the kiosk remotely." echo local hotspot_enabled=false if [[ -f /usr/local/bin/kiosk-emergency-hotspot ]]; then hotspot_enabled=true echo "Status: ✓ Configured" local hotspot_ssid=$(grep '^HOTSPOT_SSID=' /usr/local/bin/kiosk-emergency-hotspot 2>/dev/null | cut -d'=' -f2 | tr -d '"') echo " SSID: $hotspot_ssid" echo echo "Options:" echo " 1. Keep as-is" echo " 2. Reconfigure" echo " 3. Disable" echo " 0. Return" else echo "Status: Not configured" echo echo "Options:" echo " 1. Enable emergency hotspot" echo " 0. Return" fi echo read -r -p "Choose: " choice case "$choice" in 1) if $hotspot_enabled; then echo "Keeping current configuration" pause return else install_emergency_hotspot fi ;; 2) if $hotspot_enabled; then install_emergency_hotspot fi ;; 3) if $hotspot_enabled; then disable_emergency_hotspot fi ;; 0) return ;; esac } install_emergency_hotspot() { echo echo "Installing emergency hotspot system..." # Install required packages sudo apt install -y hostapd dnsmasq iptables # Stop services for now sudo systemctl stop hostapd dnsmasq 2>/dev/null || true sudo systemctl disable hostapd dnsmasq 2>/dev/null || true # Get WiFi interface local wifi_iface=$(ls /sys/class/net 2>/dev/null | grep -E "^wl" | head -1) if [[ -z "$wifi_iface" ]]; then log_error "No WiFi interface found" pause return 1 fi echo "WiFi interface: $wifi_iface" echo # Get configuration read -r -p "Hotspot SSID [Kiosk-Emergency]: " hotspot_ssid hotspot_ssid="${hotspot_ssid:-Kiosk-Emergency}" read -r -s -p "Hotspot password (8+ chars): " hotspot_pass echo while [[ ${#hotspot_pass} -lt 8 ]]; do echo "Password must be at least 8 characters" read -r -s -p "Hotspot password: " hotspot_pass echo done local hotspot_ip="192.168.50.1" # Create emergency hotspot script sudo tee /usr/local/bin/kiosk-emergency-hotspot > /dev/null </dev/null 2>&1; then logger "KIOSK: Internet connected - emergency hotspot not needed" exit 0 fi logger "KIOSK: No internet detected - starting emergency hotspot" # Stop any conflicting services systemctl stop wpa_supplicant 2>/dev/null || true ip link set \$WIFI_IFACE down 2>/dev/null || true sleep 2 # Configure static IP for hotspot ip addr flush dev \$WIFI_IFACE ip addr add \${HOTSPOT_IP}/24 dev \$WIFI_IFACE ip link set \$WIFI_IFACE up # Configure dnsmasq cat > /tmp/dnsmasq-hotspot.conf < /tmp/hostapd-hotspot.conf < /proc/sys/net/ipv4/ip_forward 2>/dev/null || true # Show notification on kiosk display sudo -u \$KIOSK_USER DISPLAY=:0 DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/\$(id -u \$KIOSK_USER)/bus \\ notify-send -u critical -t 0 "Emergency Hotspot Active" \\ "SSID: \$HOTSPOT_SSID\\nPassword: \$HOTSPOT_PASS\\nConnect to: http://\$HOTSPOT_IP" 2>/dev/null || true logger "KIOSK: Emergency hotspot started - SSID: \$HOTSPOT_SSID, IP: \$HOTSPOT_IP" # Create on-screen notification HTML sudo -u \$KIOSK_USER tee /tmp/hotspot-notification.html > /dev/null <<'NOTIFY'
📡

Emergency Hotspot Active

No internet connection detected
Hotspot created for remote access
SSID: \$HOTSPOT_SSID
Password: \$HOTSPOT_PASS
Connect to: http://\$HOTSPOT_IP
NOTIFY # Show notification window if Electron is running if pgrep -f "electron.*main.js" >/dev/null 2>&1; then sudo -u \$KIOSK_USER DISPLAY=:0 \\ /home/\$KIOSK_USER/kiosk-app/node_modules/electron/dist/electron \\ /tmp/hotspot-notification.html & fi exit 0 EOF sudo chmod +x /usr/local/bin/kiosk-emergency-hotspot # Create systemd service sudo tee /etc/systemd/system/kiosk-emergency-hotspot.service > /dev/null <<'HOTSPOTSVC' [Unit] Description=Kiosk Emergency Hotspot After=network.target lightdm.service Wants=network.target [Service] Type=oneshot ExecStart=/usr/local/bin/kiosk-emergency-hotspot RemainAfterExit=yes StandardOutput=journal StandardError=journal [Install] WantedBy=multi-user.target HOTSPOTSVC # Enable service sudo systemctl daemon-reload sudo systemctl enable kiosk-emergency-hotspot.service echo log_success "Emergency hotspot configured" echo " SSID: $hotspot_ssid" echo " Password: $hotspot_pass" echo " IP: $hotspot_ip" echo echo "Hotspot will auto-start if no internet after 60 seconds of boot" echo "On-screen notification will show connection details" pause } disable_emergency_hotspot() { echo read -r -p "Disable emergency hotspot? (y/n): " confirm if [[ "$confirm" =~ ^[Yy]$ ]]; then sudo systemctl stop kiosk-emergency-hotspot.service 2>/dev/null || true sudo systemctl disable kiosk-emergency-hotspot.service 2>/dev/null || true sudo rm -f /etc/systemd/system/kiosk-emergency-hotspot.service sudo rm -f /usr/local/bin/kiosk-emergency-hotspot sudo systemctl daemon-reload log_success "Emergency hotspot disabled" fi pause } ################################################################################ ### SECTION 5: POWER/DISPLAY/QUIET SCHEDULES ################################################################################ configure_power_display_quiet() { while true; do clear echo "════════════════════════════════════════════════════════════" echo " POWER / DISPLAY / QUIET HOURS " echo "════════════════════════════════════════════════════════════" echo show_schedule_status local rtc_available=false local rtc_status="Not available" if [[ -w /sys/class/rtc/rtc0/wakealarm ]] || sudo test -w /sys/class/rtc/rtc0/wakealarm 2>/dev/null; then rtc_available=true rtc_status="✓ Available and enabled" elif [[ -e /sys/class/rtc/rtc0/wakealarm ]]; then rtc_status="⚠ Available but not writable" fi echo " ═══ RTC Wake Capability ═══" echo "Status: $rtc_status" echo if $rtc_available; then echo "Can schedule: Power on/off + Display on/off" else echo "Can schedule: Display on/off only" if grep -qi "raspberry" /proc/cpuinfo 2>/dev/null; then echo " Raspberry Pi: Requires DS3231/DS1307 RTC module" fi fi echo echo "Options:" start_menu add_option "Configure power schedule" $rtc_available || echo " (Not available)" add_option "Configure display schedule" add_option "Configure quiet hours" add_option "Configure Electron reload" add_option "Remove all schedules" add_option "Test schedules & system" show_menu_prompt case "$choice" in 1) if $rtc_available; then configure_power_schedule else log_warning "RTC not available" pause fi ;; 2) configure_display_schedule ;; 3) configure_quiet_hours ;; 4) configure_electron_reload ;; 5) remove_all_schedules ;; 6) show_testing_menu ;; 0) return ;; esac done } #!/bin/bash ################################################################################ ### COMPLETE SCHEDULE FUNCTIONS - DROP-IN REPLACEMENT ### Insert these functions around line 3300 in install_kiosk_v 09.9.1.sh ################################################################################ # CONTEXT: These functions are called from configure_power_display_quiet() # They replace the existing schedule configuration functions ################################################################################ ### POWER SCHEDULE FUNCTION (COMPLETE) ################################################################################ configure_power_schedule() { echo echo " ══ POWER SCHEDULING ══" echo # Check if RTC is available local rtc_available=false if [[ -w /sys/class/rtc/rtc0/wakealarm ]] || sudo test -w /sys/class/rtc/rtc0/wakealarm 2>/dev/null; then rtc_available=true echo "✓ RTC wake capability detected" else echo "⚠ RTC wake not available (shutdown only, no auto-wake)" fi echo read -r -p "Shutdown time (HH:MM) [22:00]: " shutdown_time shutdown_time="${shutdown_time:-22:00}" if $rtc_available; then read -r -p "Wake time (HH:MM) [06:00]: " wake_time wake_time="${wake_time:-06:00}" else wake_time="" fi # Remove any existing power schedule files sudo systemctl stop kiosk-shutdown.timer 2>/dev/null || true sudo systemctl disable kiosk-shutdown.timer 2>/dev/null || true sudo rm -f /etc/systemd/system/kiosk-shutdown.{service,timer} sudo rm -f /usr/local/bin/kiosk-power-off.sh sudo rm -f /usr/local/bin/rtc-wake.sh sudo rm -f /etc/cron.d/kiosk-rtc-wake # Create shutdown script sudo tee /usr/local/bin/kiosk-power-off.sh > /dev/null <<'EOF' #!/bin/bash logger "KIOSK: Scheduled shutdown initiated" systemctl poweroff EOF sudo chmod +x /usr/local/bin/kiosk-power-off.sh # Create shutdown service sudo tee /etc/systemd/system/kiosk-shutdown.service > /dev/null <<'EOF' [Unit] Description=Kiosk Scheduled Shutdown [Service] Type=oneshot ExecStart=/usr/local/bin/kiosk-power-off.sh EOF # Create shutdown timer sudo tee /etc/systemd/system/kiosk-shutdown.timer > /dev/null < /dev/null <<'RTCSCRIPT' #!/bin/bash WAKE_TIME="$1" CURRENT=$(date +%s) WAKE=$(date -d "$WAKE_TIME" +%s) # If wake time is earlier than current time, schedule for tomorrow [[ $WAKE -le $CURRENT ]] && WAKE=$(date -d "tomorrow $WAKE_TIME" +%s) # Clear existing alarm echo 0 > /sys/class/rtc/rtc0/wakealarm 2>/dev/null || true # Set new alarm if echo $WAKE > /sys/class/rtc/rtc0/wakealarm 2>/dev/null; then logger "KIOSK: RTC wake set for $(date -d @$WAKE '+%Y-%m-%d %H:%M:%S')" echo "RTC wake set for $(date -d @$WAKE '+%Y-%m-%d %H:%M:%S')" else logger "KIOSK: ERROR - Failed to set RTC wake" echo "ERROR: Failed to set RTC wake" exit 1 fi RTCSCRIPT sudo chmod +x /usr/local/bin/rtc-wake.sh # Create cron job to set RTC wake 5 minutes before shutdown local shutdown_hour="${shutdown_time%%:*}" local shutdown_min="${shutdown_time##*:}" local wake_min=$((10#$shutdown_min - 5)) local wake_hour=$((10#$shutdown_hour)) # Handle negative minutes if [[ $wake_min -lt 0 ]]; then wake_min=$((wake_min + 60)) wake_hour=$((wake_hour - 1)) fi # Handle negative hour (before midnight) if [[ $wake_hour -lt 0 ]]; then wake_hour=$((wake_hour + 24)) fi sudo tee /etc/cron.d/kiosk-rtc-wake > /dev/null <> /var/log/kiosk-rtc.log 2>&1 EOF log_info "RTC wake cron job created" fi # Reload systemd and enable timer sudo systemctl daemon-reload sudo systemctl enable kiosk-shutdown.timer sudo systemctl start kiosk-shutdown.timer echo log_success "Power schedule configured" echo " Shutdown: $shutdown_time daily" if $rtc_available && [[ -n "$wake_time" ]]; then echo " Wake: $wake_time daily" fi # Show next activation echo echo "Next scheduled shutdown:" systemctl list-timers kiosk-shutdown.timer --no-pager | grep kiosk-shutdown || echo " (checking...)" # Test RTC wake if configured if $rtc_available && [[ -n "$wake_time" ]]; then echo read -r -p "Test RTC wake setup now? (y/n): " test_rtc if [[ "$test_rtc" =~ ^[Yy]$ ]]; then echo "Testing RTC wake for $wake_time..." sudo /usr/local/bin/rtc-wake.sh "$wake_time" echo "Check: cat /sys/class/rtc/rtc0/wakealarm" cat /sys/class/rtc/rtc0/wakealarm 2>/dev/null && echo "✓ RTC wake is set" || echo "✗ RTC wake failed" fi fi pause } ################################################################################ ### DISPLAY SCHEDULE FUNCTION (COMPLETE) ################################################################################ configure_display_schedule() { echo echo " ══ DISPLAY SCHEDULING ══" echo # Check if power schedule exists if systemctl is-enabled kiosk-shutdown.timer &>/dev/null 2>&1; then local ptime=$(systemctl cat kiosk-shutdown.timer 2>/dev/null | grep "^OnCalendar=" | cut -d'=' -f2 | sed 's/\*-\*-\* //' | sed 's/:00$//') echo "⚠ Power shutdown configured at $ptime" echo " Display will already be off when system shuts down" echo fi read -r -p "Display OFF time (HH:MM) [22:00]: " doff doff="${doff:-22:00}" read -r -p "Display ON time (HH:MM) [06:00]: " don don="${don:-06:00}" # Get kiosk user ID for DBUS local kiosk_uid=$(id -u "$KIOSK_USER") # Remove any existing display schedule files sudo systemctl stop kiosk-display-{on,off}.timer 2>/dev/null || true sudo systemctl disable kiosk-display-{on,off}.timer 2>/dev/null || true sudo rm -f /etc/systemd/system/kiosk-display-{on,off}.{service,timer} sudo rm -f /usr/local/bin/kiosk-display-{on,off}.sh # Create display-off script with multiple methods sudo tee /usr/local/bin/kiosk-display-off.sh > /dev/null </dev/null && echo "✓ xset dpms off" || echo "✗ xset failed" # Method 2: vbetool (if available) if command -v vbetool &>/dev/null; then vbetool dpms off 2>/dev/null && echo "✓ vbetool off" || echo "✗ vbetool failed" fi # Method 3: Backlight control (laptops) if [[ -d /sys/class/backlight ]]; then for bl in /sys/class/backlight/*/brightness; do if [[ -w "\$bl" ]]; then echo 0 > "\$bl" 2>/dev/null && echo "✓ backlight off: \$bl" || echo "✗ backlight failed" fi done fi logger "KIOSK: Display turned OFF (scheduled)" EOF sudo chmod +x /usr/local/bin/kiosk-display-off.sh # Create display-on script with multiple methods sudo tee /usr/local/bin/kiosk-display-on.sh > /dev/null </dev/null && echo "✓ xset dpms on" || echo "✗ xset failed" # Method 2: vbetool (if available) if command -v vbetool &>/dev/null; then vbetool dpms on 2>/dev/null && echo "✓ vbetool on" || echo "✗ vbetool failed" fi # Method 3: Backlight control (laptops) if [[ -d /sys/class/backlight ]]; then for bl in /sys/class/backlight/*/brightness; do if [[ -w "\$bl" ]]; then cat "\${bl%/*}/max_brightness" > "\$bl" 2>/dev/null && echo "✓ backlight on: \$bl" || echo "✗ backlight failed" fi done fi # Method 4: Wake up input (move mouse) sudo -u $KIOSK_USER DISPLAY=:0 xdotool mousemove 1 1 2>/dev/null && echo "✓ mouse wiggle" || echo "✗ mouse wiggle failed" logger "KIOSK: Display turned ON (scheduled)" EOF sudo chmod +x /usr/local/bin/kiosk-display-on.sh # Create systemd services sudo tee /etc/systemd/system/kiosk-display-off.service > /dev/null <<'EOF' [Unit] Description=Kiosk Display Off [Service] Type=oneshot ExecStart=/usr/local/bin/kiosk-display-off.sh StandardOutput=journal StandardError=journal EOF sudo tee /etc/systemd/system/kiosk-display-on.service > /dev/null <<'EOF' [Unit] Description=Kiosk Display On [Service] Type=oneshot ExecStart=/usr/local/bin/kiosk-display-on.sh StandardOutput=journal StandardError=journal EOF # Create timers sudo tee /etc/systemd/system/kiosk-display-off.timer > /dev/null < /dev/null </dev/null 2>&1; then local ptime=$(systemctl cat kiosk-shutdown.timer 2>/dev/null | grep "^OnCalendar=" | cut -d'=' -f2 | sed 's/\*-\*-\* //' | sed 's/:00$//') echo "ℹ Power shutdown: $ptime" fi if systemctl is-enabled kiosk-display-off.timer &>/dev/null 2>&1; then local doff=$(systemctl cat kiosk-display-off.timer 2>/dev/null | grep "^OnCalendar=" | cut -d'=' -f2 | sed 's/\*-\*-\* //' | sed 's/:00$//') local don=$(systemctl cat kiosk-display-on.timer 2>/dev/null | grep "^OnCalendar=" | cut -d'=' -f2 | sed 's/\*-\*-\* //' | sed 's/:00$//') echo "ℹ Display: OFF at $doff, ON at $don" fi echo read -r -p "Quiet hours start (HH:MM) [22:00]: " qstart qstart="${qstart:-22:00}" read -r -p "Quiet hours end (HH:MM) [07:00]: " qend qend="${qend:-07:00}" echo echo "What should be muted during quiet hours?" echo " 1. All audio (mute system)" echo " 2. Squeezelite only (stop music player)" echo " 3. Jitsi PTT only (stop intercom)" read -r -p "Choice [1]: " qmode qmode="${qmode:-1}" # Remove any existing quiet hours files sudo systemctl stop kiosk-quiet-{start,end}.timer 2>/dev/null || true sudo systemctl disable kiosk-quiet-{start,end}.timer 2>/dev/null || true sudo rm -f /etc/systemd/system/kiosk-quiet-{start,end}.{service,timer} sudo rm -f /usr/local/bin/kiosk-quiet-{start,end}.sh # Create quiet start/end scripts based on mode case "$qmode" in 1) # Mute all audio sudo tee /usr/local/bin/kiosk-quiet-start.sh > /dev/null <<'EOF' #!/bin/bash # Save current volume before muting pactl get-sink-volume @DEFAULT_SINK@ | grep -oE '[0-9]+%' | head -1 | tr -d '%' > /tmp/kiosk-vol-backup 2>/dev/null || echo "100" > /tmp/kiosk-vol-backup pactl set-sink-mute @DEFAULT_SINK@ 1 2>/dev/null logger "KIOSK: Quiet hours started - all audio muted" echo "✓ Quiet hours: All audio muted" EOF sudo tee /usr/local/bin/kiosk-quiet-end.sh > /dev/null <<'EOF' #!/bin/bash # Restore previous volume VOL=$(cat /tmp/kiosk-vol-backup 2>/dev/null || echo "100") pactl set-sink-mute @DEFAULT_SINK@ 0 2>/dev/null pactl set-sink-volume @DEFAULT_SINK@ ${VOL}% 2>/dev/null logger "KIOSK: Quiet hours ended - audio restored to ${VOL}%" echo "✓ Quiet hours ended: Audio restored to ${VOL}%" EOF ;; 2) # Stop Squeezelite only sudo tee /usr/local/bin/kiosk-quiet-start.sh > /dev/null <<'EOF' #!/bin/bash systemctl stop squeezelite 2>/dev/null logger "KIOSK: Quiet hours started - Squeezelite stopped" echo "✓ Quiet hours: Squeezelite stopped" EOF sudo tee /usr/local/bin/kiosk-quiet-end.sh > /dev/null <<'EOF' #!/bin/bash systemctl start squeezelite 2>/dev/null logger "KIOSK: Quiet hours ended - Squeezelite started" echo "✓ Quiet hours ended: Squeezelite started" EOF ;; 3) # Stop Jitsi PTT only sudo tee /usr/local/bin/kiosk-quiet-start.sh > /dev/null <<'EOF' #!/bin/bash systemctl stop jitsi-ptt 2>/dev/null logger "KIOSK: Quiet hours started - Jitsi PTT stopped" echo "✓ Quiet hours: Jitsi PTT stopped" EOF sudo tee /usr/local/bin/kiosk-quiet-end.sh > /dev/null <<'EOF' #!/bin/bash systemctl start jitsi-ptt 2>/dev/null logger "KIOSK: Quiet hours ended - Jitsi PTT started" echo "✓ Quiet hours ended: Jitsi PTT started" EOF ;; esac sudo chmod +x /usr/local/bin/kiosk-quiet-{start,end}.sh # Create systemd services sudo tee /etc/systemd/system/kiosk-quiet-start.service > /dev/null <<'EOF' [Unit] Description=Kiosk Quiet Hours Start [Service] Type=oneshot ExecStart=/usr/local/bin/kiosk-quiet-start.sh StandardOutput=journal StandardError=journal EOF sudo tee /etc/systemd/system/kiosk-quiet-end.service > /dev/null <<'EOF' [Unit] Description=Kiosk Quiet Hours End [Service] Type=oneshot ExecStart=/usr/local/bin/kiosk-quiet-end.sh StandardOutput=journal StandardError=journal EOF # Create timers sudo tee /etc/systemd/system/kiosk-quiet-start.timer > /dev/null < /dev/null </dev/null 2>&1; then local reload_cal=$(systemctl cat kiosk-electron-reload.timer 2>/dev/null | grep "^OnCalendar=" | cut -d'=' -f2) local reload_time=$(echo "$reload_cal" | sed 's/\*-\*-\* //' | sed 's/:00$//') echo "Status: ✓ Enabled" echo "Schedule: $reload_cal" echo echo "Options:" echo " 1. Keep current schedule" echo " 2. Change schedule" echo " 3. Disable automatic reload" echo " 0. Return" else echo "Status: ✗ Not configured" echo echo "Options:" echo " 1. Daily at 3am" echo " 2. Every 3 days at 3am" echo " 3. Custom schedule" echo " 0. Return" fi echo local max_option=3 read -r -p "Choose [0-$max_option]: " choice case "$choice" in 0) return ;; 1) if systemctl is-enabled kiosk-electron-reload.timer &>/dev/null 2>&1; then echo "Keeping current schedule" pause return else setup_electron_reload_timer "*-*-* 03:00:00" "Daily at 3am" fi ;; 2) if systemctl is-enabled kiosk-electron-reload.timer &>/dev/null 2>&1; then custom_electron_reload_schedule else setup_electron_reload_timer "*-*-1,4,7,10,13,16,19,22,25,28,31 03:00:00" "Every 3 days at 3am" fi ;; 3) if systemctl is-enabled kiosk-electron-reload.timer &>/dev/null 2>&1; then disable_electron_reload_timer else custom_electron_reload_schedule fi ;; esac pause } setup_electron_reload_timer() { local schedule="$1" local description="$2" # Remove existing files sudo systemctl stop kiosk-electron-reload.timer 2>/dev/null || true sudo systemctl disable kiosk-electron-reload.timer 2>/dev/null || true sudo rm -f /etc/systemd/system/kiosk-electron-reload.{service,timer} sudo rm -f /usr/local/bin/kiosk-reload-electron # Create reload script sudo tee /usr/local/bin/kiosk-reload-electron > /dev/null <<'RELOADSCRIPT' #!/bin/bash logger "KIOSK: Scheduled Electron reload" systemctl restart lightdm RELOADSCRIPT sudo chmod +x /usr/local/bin/kiosk-reload-electron # Create service sudo tee /etc/systemd/system/kiosk-electron-reload.service > /dev/null <<'RELOADSVC' [Unit] Description=Reload Electron App [Service] Type=oneshot ExecStart=/usr/local/bin/kiosk-reload-electron RELOADSVC # Create timer sudo tee /etc/systemd/system/kiosk-electron-reload.timer > /dev/null </dev/null || true sudo systemctl disable kiosk-electron-reload.timer 2>/dev/null || true sudo rm -f /etc/systemd/system/kiosk-electron-reload.{service,timer} sudo rm -f /usr/local/bin/kiosk-reload-electron sudo systemctl daemon-reload log_success "Automatic Electron reload disabled" fi } ################################################################################ ### END OF SCHEDULE FUNCTIONS ################################################################################ # CONTEXT: These functions are called from the menu system # They should be inserted BEFORE the configure_power_display_quiet() function # and AFTER the show_schedule_status() function test_display_control() { echo echo " ═══ TEST DISPLAY CONTROL ═══" echo echo "This will test turning the display off and on." echo read -r -p "Test now? (y/n): " do_test [[ ! "$do_test" =~ ^[Yy]$ ]] && return echo echo "Testing display OFF in 3 seconds..." sleep 3 if [[ -f /usr/local/bin/kiosk-display-off.sh ]]; then sudo /usr/local/bin/kiosk-display-off.sh echo "Display should be OFF" else # Fallback if script doesn't exist yet local kiosk_uid=$(id -u "$KIOSK_USER") sudo -u "$KIOSK_USER" DISPLAY=:0 DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/$kiosk_uid/bus xset dpms force off echo "Display turned OFF (xset method)" fi echo echo "Waiting 5 seconds..." sleep 5 echo "Testing display ON..." if [[ -f /usr/local/bin/kiosk-display-on.sh ]]; then sudo /usr/local/bin/kiosk-display-on.sh echo "Display should be ON" else # Fallback local kiosk_uid=$(id -u "$KIOSK_USER") sudo -u "$KIOSK_USER" DISPLAY=:0 DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/$kiosk_uid/bus xset dpms force on echo "Display turned ON (xset method)" fi echo if systemctl is-enabled kiosk-display-off.timer &>/dev/null 2>&1; then echo "✓ Display timers are configured" echo echo "Schedule status:" systemctl list-timers kiosk-display-* --all --no-pager 2>/dev/null else echo "⚠ Display timers not configured yet" echo " Use option 2 to configure them" fi pause } remove_all_schedules() { echo echo "Removing all schedules..." # Stop timers sudo systemctl stop kiosk-shutdown.timer 2>/dev/null || true sudo systemctl stop kiosk-display-off.timer 2>/dev/null || true sudo systemctl stop kiosk-display-on.timer 2>/dev/null || true sudo systemctl stop kiosk-quiet-start.timer 2>/dev/null || true sudo systemctl stop kiosk-quiet-end.timer 2>/dev/null || true sudo systemctl stop kiosk-electron-reload.timer 2>/dev/null || true # Disable timers sudo systemctl disable kiosk-shutdown.timer 2>/dev/null || true sudo systemctl disable kiosk-display-off.timer 2>/dev/null || true sudo systemctl disable kiosk-display-on.timer 2>/dev/null || true sudo systemctl disable kiosk-quiet-start.timer 2>/dev/null || true sudo systemctl disable kiosk-quiet-end.timer 2>/dev/null || true sudo systemctl disable kiosk-electron-reload.timer 2>/dev/null || true # Remove files sudo rm -f /etc/systemd/system/kiosk-shutdown.{service,timer} sudo rm -f /etc/systemd/system/kiosk-display-*.{service,timer} sudo rm -f /etc/systemd/system/kiosk-quiet-*.{service,timer} sudo rm -f /etc/systemd/system/kiosk-electron-reload.{service,timer} sudo rm -f /usr/local/bin/kiosk-power-off.sh sudo rm -f /usr/local/bin/kiosk-display-off.sh sudo rm -f /usr/local/bin/kiosk-display-on.sh sudo rm -f /usr/local/bin/kiosk-quiet-*.sh sudo rm -f /usr/local/bin/rtc-wake.sh sudo rm -f /usr/local/bin/kiosk-reload-electron sudo rm -f /etc/cron.d/kiosk-rtc-wake sudo systemctl daemon-reload log_success "All schedules removed" pause } ############################################################################### ### testing submenu ############################################################################### show_testing_menu() { while true; do clear echo "════════════════════════════════════════════════════════════" echo " SCHEDULE & SYSTEM TESTING " echo "════════════════════════════════════════════════════════════" echo echo "Available Tests:" echo " 1. Display Control (on/off test)" echo " 2. Quiet Hours (mute/unmute test)" echo " 3. Power Schedule (show next shutdown time)" echo " 4. Audio System Test" echo " 5. Network Test" echo " 6. Keyboard Test" echo " 7. Run All Tests" echo " 0. Return" echo local max_option=7 read -r -p "Choose [0-$max_option]: " choice case "$choice" in 1) test_display_control ;; 2) test_quiet_hours ;; 3) test_power_schedule ;; 4) audio_test ;; 5) network_test ;; 6) test_keyboard ;; 7) run_all_tests ;; 0) return ;; esac done } test_quiet_hours() { echo echo " ═══ QUIET HOURS TEST ═══" echo if ! systemctl is-enabled kiosk-quiet-start.timer &>/dev/null; then echo "❌ Quiet hours not configured" pause return fi echo "Testing quiet hours mute/unmute..." echo echo "1. Getting current volume..." local current_vol=$(pactl get-sink-volume @DEFAULT_SINK@ | grep -oE '[0-9]+%' | head -1 | tr -d '%') echo " Current: ${current_vol}%" echo echo "2. Testing MUTE (quiet start)..." sudo /usr/local/bin/kiosk-quiet-start.sh sleep 2 local muted=$(pactl get-sink-mute @DEFAULT_SINK@ | grep -q "yes" && echo "✓ MUTED" || echo "✗ NOT MUTED") echo " Result: $muted" echo echo "3. Testing UNMUTE (quiet end)..." sudo /usr/local/bin/kiosk-quiet-end.sh sleep 2 local unmuted=$(pactl get-sink-mute @DEFAULT_SINK@ | grep -q "no" && echo "✓ UNMUTED" || echo "✗ STILL MUTED") echo " Result: $unmuted" echo echo "✓ Quiet hours test complete" echo systemctl list-timers kiosk-quiet-* --all --no-pager pause } test_power_schedule() { echo echo " ═══ POWER SCHEDULE TEST ═══" echo if ! systemctl is-enabled kiosk-shutdown.timer &>/dev/null; then echo "❌ Power schedule not configured" pause return fi echo "Power schedule status:" echo systemctl list-timers kiosk-shutdown.timer --all --no-pager echo echo "⚠️ Note: Cannot test actual shutdown without shutting down!" echo " To manually test: sudo systemctl start kiosk-shutdown.service" echo if [[ -f /usr/local/bin/rtc-wake.sh ]]; then echo "RTC wake script exists ✓" local wake_config=$(grep -h "rtc-wake" /etc/cron.d/kiosk-rtc-wake 2>/dev/null) if [[ -n "$wake_config" ]]; then echo "Wake schedule: $wake_config" fi else echo "No RTC wake configured" fi pause } test_keyboard() { echo echo " ═══ KEYBOARD TEST ═══" echo echo "Testing keyboard visibility and function..." echo echo "Please perform these tests on the kiosk display:" echo echo " 1. 2-finger swipe DOWN → keyboard should appear" echo " 2. Type some characters" echo " 3. 2-finger swipe DOWN again → keyboard should close" echo " 4. Tap a text field → keyboard should auto-appear" echo " 5. Click keyboard X button → keyboard should close" echo echo "Check electron log for keyboard events:" echo " sudo tail -f /home/kiosk/electron.log | grep -i keyboard" echo pause } run_all_tests() { echo echo " ═══ RUNNING ALL TESTS ═══" echo echo "Test 1/5: Display Control" test_display_control echo echo "Test 2/5: Quiet Hours" test_quiet_hours echo echo "Test 3/5: Power Schedule" test_power_schedule echo echo "Test 4/5: Audio" audio_test echo echo "Test 5/5: Network" network_test echo echo "✓ All tests complete!" pause } ################################################################################ ### SECTION 6: CONFIG SAVE/LOAD ################################################################################ load_config() { if ! sudo -u "$KIOSK_USER" test -f "$CONFIG_PATH" 2>/dev/null; then return 1 fi local loaded_autoswitch=$(sudo -u "$KIOSK_USER" jq -r '.autoswitch' "$CONFIG_PATH" 2>/dev/null) AUTOSWITCH="$loaded_autoswitch" local loaded_swipe=$(sudo -u "$KIOSK_USER" jq -r '.swipeMode' "$CONFIG_PATH" 2>/dev/null) SWIPE_MODE="$loaded_swipe" local loaded_nav=$(sudo -u "$KIOSK_USER" jq -r '.allowNavigation' "$CONFIG_PATH" 2>/dev/null) ALLOW_NAVIGATION="$loaded_nav" HOME_TAB_INDEX=$(sudo -u "$KIOSK_USER" jq -r '.homeTabIndex // -1' "$CONFIG_PATH" 2>/dev/null) INACTIVITY_TIMEOUT=$(sudo -u "$KIOSK_USER" jq -r '.inactivityTimeout // 120' "$CONFIG_PATH" 2>/dev/null) local tab_count=$(sudo -u "$KIOSK_USER" jq -r '.tabs | length' "$CONFIG_PATH" 2>/dev/null || echo "0") URLS=() DURS=() USERS=() PASSES=() for ((i=0; i 0 local auto_json="true" local dual_json="false" [[ "$SWIPE_MODE" == "dual" ]] && dual_json="true" local lockout_enabled_json="false" [[ "$LOCKOUT_ENABLED" == "true" ]] && lockout_enabled_json="true" jq -n \ --arg unit "s" \ --argjson autoswitch "$auto_json" \ --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 lockoutEnabled "$lockout_enabled_json" \ --arg lockoutPassword "${LOCKOUT_PASSWORD:-}" \ --argjson lockoutTimeout "${LOCKOUT_TIMEOUT:-1800}" \ '{unit:$unit,autoswitch:$autoswitch,enableTouch:$enableTouch,dualSwipe:$dualSwipe,swipeMode:$swipeMode,allowNavigation:$allowNavigation,homeTabIndex:$homeTabIndex,inactivityTimeout:$inactivityTimeout,lockoutEnabled:$lockoutEnabled,lockoutPassword:$lockoutPassword,lockoutTimeout:$lockoutTimeout,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]:-}" # NOTE: No autoRotate field - duration controls rotation jq --arg u "$url" \ --argjson d "$dur" \ --arg user "$user" \ --arg pass "$pass" \ '.tabs += [{"url":$u,"duration":$d,"username":$user,"password":$pass}]' \ "$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 echo "Configuration saved. Changes take effect after reload." read -r -p "Reload kiosk now? (y/n): " do_reload if [[ ! "$do_reload" =~ ^[Nn]$ ]]; 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 } ################################################################################ ### SECTION 7: INSTALL/UNINSTALL SYSTEM FUNCTIONS ################################################################################ full_reinstall() { echo "" echo "══════════════════════════════════════════════════════════════" echo " FULL REINSTALL - NUCLEAR OPTION" echo "══════════════════════════════════════════════════════════════" echo "" echo "⚠️ This will COMPLETELY WIPE:" echo " • All kiosk configuration and sites" echo " • All Electron/Node.js installations" echo " • All browser caches and data" echo " • CUPS printer system" echo "" echo "Then reinstall everything from scratch." echo "" read -p "Are you ABSOLUTELY SURE? (type YES): " CONFIRM if [ "$CONFIRM" != "YES" ]; then echo "Cancelled." return fi # ONLY ask about VPN/VNC echo "" echo "Keep VPN/VNC settings?" read -p "(y/n): " KEEP_VPN echo "" echo "Beginning nuclear reinstall..." # Stop everything echo "[1/8] Stopping all services..." sudo systemctl stop lightdm 2>/dev/null || true # Backup ONLY VPN/VNC if requested VPN_BACKUP="" if [[ "$KEEP_VPN" =~ ^[Yy]$ ]]; then echo "[2/8] Backing up VPN/VNC..." VPN_BACKUP="/tmp/kiosk-vpn-backup-$(date +%s)" mkdir -p "$VPN_BACKUP" [ -d "/etc/openvpn" ] && sudo cp -r /etc/openvpn "$VPN_BACKUP/" 2>/dev/null || true if [ -f "/home/$KIOSK_USER/.vnc/passwd" ]; then sudo -u "$KIOSK_USER" mkdir -p "$VPN_BACKUP/vnc" sudo -u "$KIOSK_USER" cp /home/$KIOSK_USER/.vnc/passwd "$VPN_BACKUP/vnc/" 2>/dev/null || true fi echo "✓ VPN/VNC backed up" else echo "[2/8] Nuking VPN/VNC too" fi # NUCLEAR WIPE echo "[3/8] Wiping kiosk files..." sudo rm -rf "$KIOSK_DIR" sudo rm -f /home/$KIOSK_USER/.xsession sudo rm -f /home/$KIOSK_USER/electron.log sudo rm -rf /home/$KIOSK_USER/.cache sudo rm -rf /home/$KIOSK_USER/.config/Electron sudo rm -rf /home/$KIOSK_USER/.config/chromium echo "[4/8] Removing CUPS..." sudo systemctl stop cups 2>/dev/null || true sudo systemctl disable cups 2>/dev/null || true sudo apt-get purge -y cups cups-client cups-common 2>/dev/null || true echo "[5/8] Removing Node.js..." sudo apt-get purge -y nodejs npm 2>/dev/null || true sudo rm -rf /usr/local/lib/node_modules sudo rm -rf /usr/local/bin/node sudo rm -rf /usr/local/bin/npm if [[ ! "$KEEP_VPN" =~ ^[Yy]$ ]]; then echo "[6/8] Removing VPN/VNC..." sudo systemctl stop x11vnc openvpn* 2>/dev/null || true sudo systemctl disable x11vnc openvpn* 2>/dev/null || true sudo apt-get purge -y x11vnc openvpn 2>/dev/null || true sudo rm -rf /home/$KIOSK_USER/.vnc sudo rm -rf /etc/openvpn sudo rm -f /etc/systemd/system/x11vnc.service else echo "[6/8] Preserving VPN/VNC" fi echo "[7/8] System cleanup..." sudo systemctl daemon-reload sudo apt-get autoremove -y 2>/dev/null || true sudo apt-get autoclean 2>/dev/null || true echo "" echo "✓✓✓ EVERYTHING WIPED ✓✓✓" echo "" # Fresh install echo "[8/8] Fresh installation starting..." first_time_install # Restore ONLY VPN/VNC if backed up if [ -n "$VPN_BACKUP" ] && [ -d "$VPN_BACKUP" ]; then echo "" echo "Restoring VPN/VNC..." [ -d "$VPN_BACKUP/openvpn" ] && sudo cp -r "$VPN_BACKUP/openvpn" /etc/ 2>/dev/null || true if [ -f "$VPN_BACKUP/vnc/passwd" ]; then sudo -u "$KIOSK_USER" mkdir -p /home/$KIOSK_USER/.vnc sudo -u "$KIOSK_USER" cp "$VPN_BACKUP/vnc/passwd" /home/$KIOSK_USER/.vnc/ 2>/dev/null || true fi rm -rf "$VPN_BACKUP" echo "✓ VPN/VNC restored" fi echo "" log_success "Reinstall complete! System is fresh." echo "" pause } ################################################################################ ### SECTION 8: FIRST TIME INSTALLATION ################################################################################ first_time_install() { clear echo "════════════════════════════════════════════════════════════" echo " Ubuntu Based Kiosk (UBK) v${SCRIPT_VERSION} - Installation " echo "════════════════════════════════════════════════════════════" echo echo "This will install a HEADLESS KIOSK (no desktop environment):" echo echo "CORE:" echo " • Kiosk user with auto-login" echo " • LightDM + Openbox (minimal window manager)" echo " • Electron browser (v33.4.11)" echo " • Multi-site rotation with touch controls" echo " • Hardware video acceleration" echo " • Audio support (PipeWire)" echo " • Time synchronization (NTP)" echo echo "OPTIONAL (configure after install):" echo " • Lyrion Music Server (LMS) / Squeezelite" echo " • CUPS printing" echo " • Jitsi intercom" echo " • Remote desktop (VNC)" echo " • VPN (WireGuard, Tailscale, Netbird)" echo " • Onboard touchscreen keyboard" echo read -r -p "Proceed with installation? (y/n): " proceed [[ "$proceed" != "y" ]] && exit 0 echo echo "[1/27] Installing packages..." sudo apt update sudo apt install -y \ xorg openbox lightdm unclutter screen curl git build-essential \ ca-certificates gnupg lsb-release jq ufw x11-xserver-utils xinput \ vainfo mesa-utils libgl1-mesa-dri libglx-mesa0 mesa-vulkan-drivers \ libva2 libva-drm2 libva-x11-2 mesa-va-drivers \ libegl-mesa0 libegl1-mesa-dev libgles2-mesa-dev \ pipewire pipewire-pulse pipewire-alsa wireplumber pipewire-audio-client-libraries alsa-utils libnotify-bin \ systemd-timesyncd acpid xbindkeys xdotool python3-evdev if lspci | grep -i "VGA.*Intel" >/dev/null 2>&1; then sudo apt install -y intel-gpu-tools xserver-xorg-video-intel \ i965-va-driver intel-media-va-driver fi echo "[2/27] Configuring time synchronization..." sudo systemctl enable systemd-timesyncd sudo systemctl start systemd-timesyncd log_success "NTP time sync enabled" echo "[3/27] Creating kiosk user..." if ! id "$KIOSK_USER" &>/dev/null; then sudo useradd -m -s /bin/bash -G audio,video,input,plugdev,netdev "$KIOSK_USER" echo "$KIOSK_USER:kiosk" | sudo chpasswd log_success "Kiosk user created" else log_success "Kiosk user already exists" fi echo "[4/27] Configuring timezone..." configure_timezone echo "[5/27] Setting up kiosk directories..." sudo mkdir -p "$KIOSK_DIR" sudo chown -R "$KIOSK_USER:$KIOSK_USER" "$KIOSK_HOME" echo "[6/27] Installing Node.js..." if ! command -v node &>/dev/null; then curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - sudo apt install -y nodejs fi echo "Node.js: $(node -v)" echo "[7-10/27] Core configuration..." configure_touch_controls configure_navigation_security configure_sites echo "[12/27] Initial scheduling (optional)..." echo read -r -p "Configure power/display/quiet schedules now? (y/n): " do_schedules if [[ "$do_schedules" =~ ^[Yy]$ ]]; then configure_power_display_quiet else log_info "Schedules can be configured later from Core Settings menu" fi save_config ################################################################## ####################start-main.js################################# ################################################################### echo "[13/27] Installing Electron..." sudo -u "$KIOSK_USER" tee "$KIOSK_DIR/main.js" > /dev/null <<'MAINJS' const {app,BrowserWindow,BrowserView,globalShortcut,ipcMain,dialog,powerMonitor}=require('electron'); const {exec}=require('child_process'); const fs=require('fs'); const path=require('path'); const os=require('os'); const CONFIG_FILE=path.join(__dirname,'config.json'); const VERSION='0.9.10'; let mainWindow,views=[],hiddenViews=[],tabs=[],currentIndex=0,showingHidden=false; let pinWindow=null,promptWindow=null,htmlKeyboardWindow=null; let tabIndexToViewIndex=[]; let currentHiddenIndex=0; let masterTimer=null; let siteStartTime=Date.now(); let lastUserInteraction=Date.now(); let lastMediaCheck=Date.now(); let keyboardOpenTime=0; let keyboardLastUsed=0; let inactivityExtensionUntil=0; let manualNavigationMode=false; let programmaticNavigation=false; let userInteractedWithCurrentSite=false; // v0.9.8: Track if user touched current site let mediaIsPlaying=false; let userRecentlyActive=false; let keyboardIsOpen=false; let keyboardClosePending=false; const USER_ACTIVITY_PAUSE=60000; const KEYBOARD_AUTO_CLOSE=30000; const MEDIA_CHECK_INTERVAL=3000; const MEDIA_GRACE_PERIOD=30000; const SAFETY_MAX_EXTENSION=14400000; const INACTIVITY_PROMPT_TIMEOUT=15000; let lastMediaStateChange=Date.now(); let homeTabIndex=-1; let inactivityTimeout=120000; let allowNavigation='same-origin'; let lockoutEnabled=false; let lockoutPassword=''; let lockoutTimeout=1800000; // 30 minutes in milliseconds let lockoutWindow=null; let sessionLocked=false; let lastLockoutCheck=Date.now(); function loadConfig(){ try{ if(!fs.existsSync(CONFIG_FILE)){ console.log('[CONFIG] No config file found'); return []; } const data=fs.readFileSync(CONFIG_FILE,'utf8'); const config=JSON.parse(data); homeTabIndex=(config.homeTabIndex!=null)?config.homeTabIndex:-1; inactivityTimeout=(config.inactivityTimeout||120)*1000; allowNavigation=config.allowNavigation||'same-origin'; lockoutEnabled=(config.lockoutEnabled===true||config.lockoutEnabled==='true'); lockoutPassword=config.lockoutPassword||''; lockoutTimeout=(config.lockoutTimeout||1800)*1000; console.log('[CONFIG] ═══════════════════════════'); console.log('[CONFIG] Home tab index:',homeTabIndex); console.log('[CONFIG] Inactivity timeout:',inactivityTimeout/1000,'seconds'); console.log('[CONFIG] Navigation:',allowNavigation); console.log('[CONFIG] Lockout enabled:',lockoutEnabled); if(lockoutEnabled){ console.log('[CONFIG] Lockout timeout:',lockoutTimeout/1000,'seconds'); } console.log('[CONFIG] Sites:',config.tabs?.length||0); console.log('[CONFIG] ═══════════════════════════'); return config.tabs||[]; }catch(e){ console.error('[CONFIG] Load error:',e.message); return []; } } function markActivity(resetLockoutTimer){ const now=Date.now(); const timeSinceLastActivity=now-lastUserInteraction; if(timeSinceLastActivity>5000){ console.log('[ACTIVITY] User interaction detected'); } lastUserInteraction=now; userRecentlyActive=true; // v0.9.10: When user actually interacts with content, pause rotation // This includes touches, scrolls, typing - any real content interaction // Does NOT include prompt responses or programmatic navigation if(resetLockoutTimer){ lastLockoutCheck=now; console.log('[ACTIVITY] Lockout timer reset'); // v0.9.10: User interaction pauses rotation if(!manualNavigationMode){ console.log('[ACTIVITY] 🛑 User interacted with content - pausing rotation'); manualNavigationMode=true; } } // v0.9.8: Mark that user has interacted with this site // This triggers inactivity prompt logic for ANY site (not just manual/home) if(!userInteractedWithCurrentSite){ console.log('[ACTIVITY] 🖐️ User touched this site - inactivity timer active'); userInteractedWithCurrentSite=true; } if(promptWindow&&!promptWindow.isDestroyed()){ console.log('[ACTIVITY] Closing inactivity prompt'); promptWindow.close(); promptWindow=null; } // CRITICAL FIX: Don't clear time extensions on user activity! // Extensions should only be cleared when: // 1. They naturally expire // 2. User explicitly returns to rotation // 3. User chooses "Return to Rotation" from prompt // Do NOT clear here - user activity during extension should be allowed! } function markKeyboardActivity(){ const now=Date.now(); keyboardLastUsed=now; keyboardOpenTime=now; keyboardClosePending=false; } function checkMediaPlayback(){ let view=null; if(showingHidden&&hiddenViews[currentHiddenIndex]){ view=hiddenViews[currentHiddenIndex]; }else if(views[currentIndex]){ view=views[currentIndex]; } if(!view||!view.webContents){ if(mediaIsPlaying){ mediaIsPlaying=false; lastMediaStateChange=Date.now(); } return; } if(view.webContents.isLoadingMainFrame()||!view.webContents.getURL()){ return; } view.webContents.executeJavaScript(` (function(){ try{ let playing=false; let method=''; let details=''; const videos=document.querySelectorAll("video"); for(let v of videos){ if(!v.paused&&!v.ended&&v.readyState>=2&&v.currentTime>0){ playing=true; method='video'; details='HTML5 video'; break; } } if(!playing){ const audios=document.querySelectorAll("audio"); for(let a of audios){ if(!a.paused&&!a.ended&&a.readyState>=2&&a.currentTime>0){ playing=true; method='audio'; details='HTML5 audio'; break; } } } if(!playing){ const iframes=document.querySelectorAll( 'iframe[src*="youtube"],iframe[src*="vimeo"],'+ 'iframe[src*="dailymotion"],iframe[src*="twitch"],'+ 'iframe[src*="plex"],iframe[src*="emby"],iframe[src*="jellyfin"]' ); for(let iframe of iframes){ const rect=iframe.getBoundingClientRect(); if(rect.width>200&&rect.height>100&&rect.top0){ playing=true; method='iframe'; const src=iframe.src||''; if(src.includes('youtube'))details='YouTube'; else if(src.includes('plex'))details='Plex'; else if(src.includes('emby'))details='Emby'; else if(src.includes('jellyfin'))details='Jellyfin'; else if(src.includes('vimeo'))details='Vimeo'; else details='Embedded player'; break; } } } if(!playing){ if(document.querySelector('.Player-progressBar')|| document.querySelector('[class*="PlayerControls"]')){ const plexVideo=document.querySelector('video'); if(plexVideo&&!plexVideo.paused){ playing=true; method='plex-app'; details='Plex Web'; } } if(document.querySelector('.videoPlayerContainer')|| document.querySelector('.nowPlayingBar')){ const jellyfinVideo=document.querySelector('video'); if(jellyfinVideo&&!jellyfinVideo.paused){ playing=true; method='jellyfin-app'; details='Jellyfin Web'; } } if(document.querySelector('.videoPlayerContainer')|| document.querySelector('.nowPlayingBar')){ const embyVideo=document.querySelector('video'); if(embyVideo&&!embyVideo.paused){ playing=true; method='emby-app'; details='Emby Web'; } } } return {playing:playing,method:method,details:details}; }catch(e){ return {playing:false,error:e.message}; } })(); `,true).then(result=>{ const wasPlaying=mediaIsPlaying; const now=Date.now(); if(result&&result.playing){ if(!wasPlaying){ console.log('[MEDIA] ▶ Started:',result.details||result.method); } mediaIsPlaying=true; lastMediaStateChange=now; }else{ if(wasPlaying){ console.log('[MEDIA] ⏸ Stopped'); } mediaIsPlaying=false; if(wasPlaying){ lastMediaStateChange=now; } } }).catch(err=>{}); } function startMasterTimer(){ if(masterTimer){ clearInterval(masterTimer); } console.log('[TIMER] === MASTER TIMER STARTED ===='); console.log('[TIMER] Home tab index:',homeTabIndex); console.log('[TIMER] Inactivity timeout:',inactivityTimeout/1000,'seconds'); console.log('[TIMER] ================================='); siteStartTime=Date.now(); lastUserInteraction=Date.now(); masterTimer=setInterval(()=>{ const now=Date.now(); // 1. KEYBOARD AUTO-CLOSE if(keyboardIsOpen&&!keyboardClosePending){ const idleTime=now-keyboardLastUsed; if(idleTime>KEYBOARD_AUTO_CLOSE){ keyboardClosePending=true; closeHTMLKeyboard(); } } // 2. MEDIA CHECK if(now-lastMediaCheck>MEDIA_CHECK_INTERVAL){ checkMediaPlayback(); lastMediaCheck=now; } // 3. MEDIA BLOCKING // While media plays: NO rotation, NO prompts, NOTHING interrupts playback if(mediaIsPlaying){ return; } // 4. GRACE PERIOD // After media stops: Always wait 30 seconds before rotation or prompts // This prevents interruptions when video/audio ends naturally const timeSinceMediaStopped=now-lastMediaStateChange; if(timeSinceMediaStopped=0&¤tIndex===homeViewIdx); // v0.9.9: Show inactivity prompt on ANY site where user has interacted // - Auto-rotates to recipe → user taps → prompt appears after timeout // - User keeps swiping through photos → keeps resetting, no prompt // - No user interaction → no prompt, just keeps rotating // - Works with OR without Home URL configured if(userInteractedWithCurrentSite&&inactivityTimeout>0){ const idleTime=now-lastUserInteraction; // CRITICAL FIX: Use absolute time check for extensions let effectiveTimeout=inactivityTimeout; if(inactivityExtensionUntil>0&&now0&&now>=inactivityExtensionUntil){ // Extension expired - clear it and check timeout console.log('[INACTIVITY] ⏰ Extension expired - checking timeout'); inactivityExtensionUntil=0; } // Log every 15 seconds if(Math.floor(idleTime/15000)!==Math.floor((idleTime-1000)/15000)){ const idleMinutes=Math.floor(idleTime/60000); const idleSeconds=Math.floor((idleTime%60000)/1000); const timeoutMinutes=Math.floor(effectiveTimeout/60000); const timeoutSeconds=Math.floor((effectiveTimeout%60000)/1000); const location=isOnHomePage?'HOME PAGE':'OTHER PAGE'; console.log('[INACTIVITY] 🕒 '+location+' IDLE: '+idleMinutes+'m '+idleSeconds+'s / '+timeoutMinutes+'m '+timeoutSeconds+'s'); } if(idleTime>=effectiveTimeout){ if(!promptWindow||promptWindow.isDestroyed()){ const location=isOnHomePage?'home page':'other page'; console.log('[INACTIVITY] 🔔 *** SHOWING PROMPT (on '+location+') ***'); showInactivityPrompt(); return; // Don't rotate while showing prompt } } } } // 7. SITE ROTATION // v0.9.10: Don't rotate if inactivity prompt is showing, session is locked, or user manually navigated if(!showingHidden&&views.length>1&&(!promptWindow||promptWindow.isDestroyed())&&!sessionLocked&&!manualNavigationMode){ const currentTabIdx=viewIndexToTabIndex(currentIndex); if(currentTabIdx>=0&&tabs[currentTabIdx]){ const siteDuration=parseInt(tabs[currentTabIdx].duration)||0; if(siteDuration>0){ const timeOnSite=now-siteStartTime; // CRITICAL FIX: Don't auto-rotate if time extension is active const hasActiveExtension=(inactivityExtensionUntil>0&&now=siteDuration*1000&&!hasActiveExtension){ rotateToNextSite(); return; } } } } // 8. LOCKOUT CHECK (session lock after extended inactivity) // v0.9.9: Use lastLockoutCheck instead of lastUserInteraction // This ensures lockout timer is independent from inactivity prompts if(lockoutEnabled&&!sessionLocked){ const idleTime=now-lastLockoutCheck; // Log every 30 seconds when getting close to lockout if(idleTime>lockoutTimeout*0.75){ if(Math.floor(idleTime/30000)!==Math.floor((idleTime-1000)/30000)){ const idleMinutes=Math.floor(idleTime/60000); const idleSeconds=Math.floor((idleTime%60000)/1000); const timeoutMinutes=Math.floor(lockoutTimeout/60000); console.log('[LOCKOUT] ⏱️ IDLE: '+idleMinutes+'m '+idleSeconds+'s / '+timeoutMinutes+'m (lockout)'); } } if(idleTime>=lockoutTimeout){ console.log('[LOCKOUT] 🔒 Timeout reached - locking session'); showLockout(); } } },1000); } function stopMasterTimer(){ if(masterTimer){ clearInterval(masterTimer); masterTimer=null; } } function rotateToNextSite(){ if(!views.length||showingHidden)return; let nextIdx=(currentIndex+1)%views.length; const startIdx=nextIdx; let found=false; let attempts=0; do{ const tabIdx=viewIndexToTabIndex(nextIdx); if(tabIdx>=0&&tabs[tabIdx]){ const dur=parseInt(tabs[tabIdx].duration)||0; if(dur>0){ found=true; break; } } nextIdx=(nextIdx+1)%views.length; attempts++; }while(nextIdx!==startIdx&&attempts=0&&tabs[tabIdx]){ const configuredUrl=tabs[tabIdx].url; const currentUrl=views[i].webContents.getURL(); if(currentUrl&&!currentUrl.startsWith(configuredUrl)){ programmaticNavigation=true; views[i].webContents.loadURL(configuredUrl); } } views[i].webContents.focus(); siteStartTime=Date.now(); // v0.9.9 CRITICAL FIX: Only reset interaction flag on MANUAL navigation // Don't reset during auto-rotation - this allows inactivity prompt to work on rotation sites! // Auto-rotation: user taps site A → rotates to site B after 30s → inactivity prompt can still appear // Manual swipe: user deliberately navigated, reset the flag (will be set again by markActivity()) if(!isAutoRotation){ userInteractedWithCurrentSite=false; } } function nextTab(){ if(!views.length||showingHidden)return; // v0.9.10: Clear time extension when user manually switches tabs // If user granted time on one page, then manually left, they're done with it if(inactivityExtensionUntil>0){ console.log('[MANUAL] ⏰ Clearing time extension - user manually switched tabs'); inactivityExtensionUntil=0; } currentIndex=(currentIndex+1)%views.length; attachView(currentIndex); markActivity(true); // Actual user interaction - reset lockout timer // v0.9.10: Manual swipe RESUMES rotation on new page // This is navigation, not content interaction - set AFTER markActivity manualNavigationMode=false; // v0.9.10: Manual swipe counts as interaction with new page // This ensures inactivity prompt will fire even on 0-time pages // If user swipes to page B but never touches it, prompt still appears after timeout userInteractedWithCurrentSite=true; console.log('[MANUAL] User switched tab forward → manualNavigationMode=FALSE (rotation will resume)'); } function prevTab(){ if(!views.length||showingHidden)return; // v0.9.10: Clear time extension when user manually switches tabs // If user granted time on one page, then manually left, they're done with it if(inactivityExtensionUntil>0){ console.log('[MANUAL] ⏰ Clearing time extension - user manually switched tabs'); inactivityExtensionUntil=0; } currentIndex=(currentIndex-1+views.length)%views.length; attachView(currentIndex); markActivity(true); // Actual user interaction - reset lockout timer // v0.9.10: Manual swipe RESUMES rotation on new page // This is navigation, not content interaction - set AFTER markActivity manualNavigationMode=false; // v0.9.10: Manual swipe counts as interaction with new page // This ensures inactivity prompt will fire even on 0-time pages // If user swipes to page B but never touches it, prompt still appears after timeout userInteractedWithCurrentSite=true; console.log('[MANUAL] User switched tab backward → manualNavigationMode=FALSE (rotation will resume)'); } function getHomeViewIndex(){ if(homeTabIndex<0)return -1; if(homeTabIndex>=tabIndexToViewIndex.length)return -1; return tabIndexToViewIndex[homeTabIndex]; } function returnToHome(){ const homeViewIdx=getHomeViewIndex(); console.log('[HOME] 🔄 RETURNING TO ROTATION → manualNavigationMode=FALSE'); if(showingHidden){ showingHidden=false; currentHiddenIndex=0; } if(promptWindow&&!promptWindow.isDestroyed()){ promptWindow.close(); promptWindow=null; } // v0.9.10: "Return to Rotation" behavior // If Home URL configured: go to home page and restart rotation // If NO Home URL: just restart rotation from first site // Special case: If home URL exists but NO rotation (all URLs have 0 time), // still return to home - the inactivity prompt will fire there manualNavigationMode=false; if(homeViewIdx>=0){ // Home URL is configured - go to home page currentIndex=homeViewIdx; }else{ // No Home URL - restart from first site currentIndex=0; } attachView(currentIndex); // v0.9.9: Don't reset lockout timer when returning to rotation // This is a prompt response, not actual user interaction with content markActivity(); // Resets inactivity timer but NOT lockout timer } function showInactivityPrompt(){ if(promptWindow&&!promptWindow.isDestroyed())return; promptWindow=new BrowserWindow({ width:800, height:600, frame:false, alwaysOnTop:true, parent:mainWindow, modal:true, webPreferences:{nodeIntegration:true,contextIsolation:false} }); promptWindow.loadFile(path.join(__dirname,'inactivity-prompt-extended.html')); promptWindow.on('closed',()=>{ promptWindow=null; }); // CRITICAL FIX: Store timeout ID so we can cancel it when user responds const promptTimeoutId=setTimeout(()=>{ if(promptWindow&&!promptWindow.isDestroyed()){ console.log('[PROMPT] No response - returning home'); promptWindow.close(); promptWindow=null; returnToHome(); } },INACTIVITY_PROMPT_TIMEOUT); ipcMain.once('user-still-here',(event,minutes)=>{ // CRITICAL FIX: Cancel the auto-return timeout since user responded! clearTimeout(promptTimeoutId); if(promptWindow&&!promptWindow.isDestroyed()){ promptWindow.close(); } promptWindow=null; if(minutes===-1){ // User chose "Return to Rotation" - clear extension and restart rotation inactivityExtensionUntil=0; returnToHome(); }else if(minutes===0){ // v0.9.9: User chose "I'm still here" - don't reset lockout timer // This is a prompt response, not actual interaction with content inactivityExtensionUntil=0; markActivity(); // Resets inactivity timer but NOT lockout timer }else{ // User chose a time extension - grant it! // Don't reset lockout timer - they're just buying more time const now=Date.now(); inactivityExtensionUntil=now+(minutes*60*1000); lastUserInteraction=now; console.log('[PROMPT] ⏰ Extended until: '+new Date(inactivityExtensionUntil).toLocaleTimeString()); } }); } function showLockout(){ if(lockoutWindow&&!lockoutWindow.isDestroyed())return; if(sessionLocked)return; console.log('[LOCKOUT] 🔒 Showing lockout screen'); sessionLocked=true; // Close any open prompts or keyboards if(promptWindow&&!promptWindow.isDestroyed()){ promptWindow.close(); promptWindow=null; } if(htmlKeyboardWindow&&!htmlKeyboardWindow.isDestroyed()){ htmlKeyboardWindow.close(); htmlKeyboardWindow=null; } lockoutWindow=new BrowserWindow({ fullscreen:true, frame:false, alwaysOnTop:true, webPreferences:{nodeIntegration:true,contextIsolation:false} }); lockoutWindow.loadFile(path.join(__dirname,'lockout.html')); lockoutWindow.on('closed',()=>{ lockoutWindow=null; }); } function hideLockout(){ if(lockoutWindow&&!lockoutWindow.isDestroyed()){ lockoutWindow.close(); } lockoutWindow=null; sessionLocked=false; lastUserInteraction=Date.now(); // Reset inactivity timer lastLockoutCheck=Date.now(); // Reset lockout timer console.log('[LOCKOUT] 🔓 Session unlocked'); } function showHTMLKeyboard(){ if(keyboardIsOpen){ keyboardLastUsed=Date.now(); keyboardOpenTime=Date.now(); keyboardClosePending=false; return; } if(htmlKeyboardWindow&&!htmlKeyboardWindow.isDestroyed()){ htmlKeyboardWindow.focus(); keyboardLastUsed=Date.now(); keyboardOpenTime=Date.now(); keyboardIsOpen=true; keyboardClosePending=false; return; } const{width,height}=mainWindow.getBounds(); const kbHeight=Math.floor(height*0.4); const kbY=height-kbHeight; htmlKeyboardWindow=new BrowserWindow({ width:width, height:kbHeight, x:0, y:kbY, frame:false, alwaysOnTop:true, skipTaskbar:true, focusable:false, webPreferences:{ nodeIntegration:true, contextIsolation:false, backgroundThrottling:false } }); htmlKeyboardWindow.loadFile(path.join(__dirname,'keyboard.html')); htmlKeyboardWindow.webContents.on('did-finish-load',()=>{ keyboardIsOpen=true; keyboardOpenTime=Date.now(); keyboardLastUsed=Date.now(); keyboardClosePending=false; notifyKeyboardState(true); if(mainWindow&&!mainWindow.isDestroyed()){ mainWindow.focus(); if(views[currentIndex]&&views[currentIndex].webContents){ views[currentIndex].webContents.focus(); } } }); htmlKeyboardWindow.on('closed',()=>{ keyboardIsOpen=false; keyboardClosePending=false; htmlKeyboardWindow=null; notifyKeyboardState(false); }); } function closeHTMLKeyboard(){ if(!keyboardIsOpen)return; const wasAutoClosed=keyboardClosePending; if(htmlKeyboardWindow&&!htmlKeyboardWindow.isDestroyed()){ htmlKeyboardWindow.close(); } htmlKeyboardWindow=null; keyboardIsOpen=false; keyboardClosePending=false; notifyKeyboardState(false); if(wasAutoClosed){ const allViews=[...views,...hiddenViews]; allViews.forEach(view=>{ if(view&&view.webContents){ view.webContents.send('keyboard-auto-closed'); } }); } if(mainWindow&&!mainWindow.isDestroyed()){ mainWindow.focus(); } } function notifyKeyboardState(visible){ const allViews=[...views,...hiddenViews]; allViews.forEach(view=>{ if(view&&view.webContents){ view.webContents.send('keyboard-state-changed',visible); } }); } function viewIndexToTabIndex(viewIdx){ for(let i=0;i=hiddenViews.length){ currentHiddenIndex=0; returnToTabs(); }else{ showHiddenTab(currentHiddenIndex); } }else{ currentHiddenIndex=0; showPinEntry(); } } function returnToTabs(){ if(!views.length)return; const[w,h]=mainWindow.getContentSize(); mainWindow.setTopBrowserView(views[currentIndex]); views[currentIndex].setBounds({x:0,y:0,width:w,height:h}); showingHidden=false; currentHiddenIndex=0; markActivity(true); // User toggled hidden - actual interaction } function showPinEntry(){ if(pinWindow&&!pinWindow.isDestroyed()){ pinWindow.focus(); return; } pinWindow=new BrowserWindow({ width:500, height:650, frame:false, alwaysOnTop:true, parent:mainWindow, modal:true, webPreferences:{nodeIntegration:true,contextIsolation:false} }); pinWindow.loadFile(path.join(__dirname,'pin-entry.html')); pinWindow.on('closed',()=>{pinWindow=null;}); ipcMain.once('pin-correct',()=>{ if(pinWindow&&!pinWindow.isDestroyed()){ pinWindow.close(); } pinWindow=null; showHiddenTab(currentHiddenIndex); }); ipcMain.once('pin-cancelled',()=>{ if(pinWindow&&!pinWindow.isDestroyed()){ pinWindow.close(); } pinWindow=null; }); } function showHiddenTab(index){ if(!hiddenViews[index])return; const[w,h]=mainWindow.getContentSize(); mainWindow.setTopBrowserView(hiddenViews[index]); hiddenViews[index].setBounds({x:0,y:0,width:w,height:h}); showingHidden=true; } function forceReturnToTabs(){ if(pinWindow&&!pinWindow.isDestroyed()){ pinWindow.close(); pinWindow=null; } returnToTabs(); } function showPowerMenu(){ const ipAddress=os.networkInterfaces(); let ip='No IP'; for(const name of Object.keys(ipAddress)){ for(const net of ipAddress[name]){ if(net.family==='IPv4'&&!net.internal){ ip=net.address; break; } } } const r=dialog.showMessageBoxSync(mainWindow,{ type:'question', buttons:['Shutdown','Restart','Reload','Cancel'], defaultId:3, title:'Power Options', message:'What would you like to do?\n\nVersion: '+VERSION+'\nIP Address: '+ip, noLink:true }); if(r===0)exec('systemctl poweroff'); else if(r===1)exec('systemctl reboot'); else if(r===2){app.relaunch();app.quit();} } function createWindow(){ tabs=loadConfig(); mainWindow=new BrowserWindow({ fullscreen:true, kiosk:true, frame:false, show:false, webPreferences:{ nodeIntegration:false, contextIsolation:true, sandbox:false, preload:path.join(__dirname,'preload.js') } }); mainWindow.setMenu(null); mainWindow.show(); mainWindow.on('focus',()=>markActivity(true)); mainWindow.webContents.on('before-input-event',()=>markActivity(true)); if(!tabs.length){ mainWindow.loadURL('data:text/html,No Sites Configured'); return; } let viewIndex=0; tabs.forEach((t,tabIdx)=>{ const view=new BrowserView({ webPreferences:{ contextIsolation:true, sandbox:false, preload:path.join(__dirname,'preload.js'), backgroundThrottling:false } }); mainWindow.addBrowserView(view); let url=t.url; if(t.username&&t.password){ try{ const u=new URL(t.url); u.username=t.username; u.password=t.password; url=u.toString(); }catch(e){} } const initialOrigin=new URL(t.url).origin; if(allowNavigation==='restricted'){ view.webContents.on('will-navigate',(e,u)=>{ if(u!==url&&u!==t.url)e.preventDefault(); }); view.webContents.setWindowOpenHandler(()=>({action:'deny'})); }else if(allowNavigation==='same-origin'){ view.webContents.on('will-navigate',(e,u)=>{ try{ if(new URL(u).origin!==initialOrigin)e.preventDefault(); }catch(x){ e.preventDefault(); } }); } view.webContents.on('before-input-event',()=>markActivity(true)); view.webContents.on('did-start-loading',()=>{ if(!programmaticNavigation){ markActivity(true); } }); view.webContents.on('did-navigate',()=>{ if(programmaticNavigation){ programmaticNavigation=false; }else{ markActivity(true); } }); view.webContents.setAudioMuted(false); view.webContents.loadURL(url); view.webContents.on('did-finish-load',()=>{ view.webContents.executeJavaScript(` ["mousedown","keydown","touchstart","scroll","click"].forEach(e=>{ document.addEventListener(e,()=>{ if(window.electronAPI?.notifyActivity){ window.electronAPI.notifyActivity(); } },true); }); `).catch(()=>{}); }); const isHidden=parseInt(t.duration)===-1; if(isHidden){ hiddenViews.push(view); tabIndexToViewIndex[tabIdx]=-1; }else{ views.push(view); tabIndexToViewIndex[tabIdx]=viewIndex; viewIndex++; } }); const homeViewIdx=getHomeViewIndex(); const startIndex=homeViewIdx>=0?homeViewIdx:0; if(views.length){ setTimeout(()=>{ attachView(startIndex); startMasterTimer(); // v0.9.9: Show lockout on boot if enabled if(lockoutEnabled){ console.log('[LOCKOUT] 🔒 Password protection enabled - locking on boot'); setTimeout(()=>{ showLockout(); },500); } },1000); } ipcMain.on('swipe-left',()=>{nextTab();}); ipcMain.on('swipe-right',()=>{prevTab();}); ipcMain.on('show-power-menu',showPowerMenu); ipcMain.on('toggle-hidden',toggleHidden); ipcMain.on('user-activity',()=>{markActivity(true);}); // Actual user interaction with content ipcMain.on('show-keyboard',()=>{showHTMLKeyboard();}); ipcMain.on('close-keyboard',()=>{closeHTMLKeyboard();}); ipcMain.on('keyboard-activity',()=>{markKeyboardActivity();}); ipcMain.on('unlock-session',(event,password)=>{ console.log('[LOCKOUT] Unlock attempt received'); if(!lockoutEnabled||!sessionLocked){ console.log('[LOCKOUT] Not locked or lockout disabled'); return; } if(password===lockoutPassword){ console.log('[LOCKOUT] ✓ Password correct'); if(lockoutWindow&&!lockoutWindow.isDestroyed()){ lockoutWindow.webContents.send('unlock-success'); } setTimeout(()=>{ hideLockout(); },500); }else{ console.log('[LOCKOUT] ✗ Password incorrect'); if(lockoutWindow&&!lockoutWindow.isDestroyed()){ lockoutWindow.webContents.send('unlock-failed'); } } }); ipcMain.on('keyboard-type',(event,key)=>{ markKeyboardActivity(); let view=null; if(showingHidden&&hiddenViews[currentHiddenIndex]){ view=hiddenViews[currentHiddenIndex]; }else if(views[currentIndex]){ view=views[currentIndex]; } if(!view||!view.webContents)return; const safeKey=JSON.stringify(key).slice(1,-1); if(key==='Backspace'){ view.webContents.executeJavaScript(` (function(){ const el=document.activeElement; if(el&&(el.tagName==="INPUT"||el.tagName==="TEXTAREA")){ const s=el.selectionStart||0; if(s>0){ el.value=el.value.substring(0,s-1)+el.value.substring(el.selectionEnd||s); el.selectionStart=el.selectionEnd=s-1; el.dispatchEvent(new Event("input",{bubbles:true})); } } })(); `).catch(()=>{}); }else if(key==='Enter'){ view.webContents.executeJavaScript(` (function(){ const el=document.activeElement; if(el){ if(el.tagName==="TEXTAREA"){ const s=el.selectionStart||0; el.value=el.value.substring(0,s)+"\\n"+el.value.substring(el.selectionEnd||s); el.selectionStart=el.selectionEnd=s+1; el.dispatchEvent(new Event("input",{bubbles:true})); }else if(el.tagName==="INPUT"){ const form=el.closest("form"); if(form){ form.dispatchEvent(new Event("submit",{bubbles:true,cancelable:true})); } } } })(); `).catch(()=>{}); }else if(key===' '){ view.webContents.executeJavaScript(` (function(){ const el=document.activeElement; if(el&&(el.tagName==="INPUT"||el.tagName==="TEXTAREA")){ const s=el.selectionStart||0; el.value=el.value.substring(0,s)+" "+el.value.substring(el.selectionEnd||s); el.selectionStart=el.selectionEnd=s+1; el.dispatchEvent(new Event("input",{bubbles:true})); } })(); `).catch(()=>{}); }else{ view.webContents.executeJavaScript(` (function(){ const text="${safeKey}"; const el=document.activeElement; if(el&&(el.tagName==="INPUT"||el.tagName==="TEXTAREA")){ const s=el.selectionStart||0; const e=el.selectionEnd||s; el.value=el.value.substring(0,s)+text+el.value.substring(e); el.selectionStart=el.selectionEnd=s+text.length; el.dispatchEvent(new Event("input",{bubbles:true})); el.dispatchEvent(new Event("change",{bubbles:true})); } })(); `).catch(()=>{}); } }); if(views.length>1){ globalShortcut.register('Control+Tab',()=>{nextTab();}); globalShortcut.register('Control+Shift+Tab',()=>{prevTab();}); globalShortcut.register('Control+]',()=>{nextTab();}); globalShortcut.register('Control+[',()=>{prevTab();}); globalShortcut.register('Alt+Right',()=>{nextTab();}); globalShortcut.register('Alt+Left',()=>{prevTab();}); } globalShortcut.register('F10',toggleHidden); globalShortcut.register('Control+H',toggleHidden); globalShortcut.register('Escape',forceReturnToTabs); globalShortcut.register('Control+Alt+Delete',showPowerMenu); globalShortcut.register('Control+Alt+P',showPowerMenu); globalShortcut.register('Control+Alt+Escape',showPowerMenu); globalShortcut.register('Control+K',()=>{ if(htmlKeyboardWindow&&!htmlKeyboardWindow.isDestroyed()){ closeHTMLKeyboard(); }else{ showHTMLKeyboard(); } }); mainWindow.on('resize',()=>{ const[w,h]=mainWindow.getContentSize(); if(showingHidden&&hiddenViews[currentHiddenIndex]){ hiddenViews[currentHiddenIndex].setBounds({x:0,y:0,width:w,height:h}); }else if(views[currentIndex]){ views[currentIndex].setBounds({x:0,y:0,width:w,height:h}); } }); } if(!app.requestSingleInstanceLock())app.quit(); app.on('certificate-error',(e,w,u,er,c,cb)=>{ e.preventDefault(); cb(true); }); app.on('ready',createWindow); app.on('will-quit',()=>{ globalShortcut.unregisterAll(); stopMasterTimer(); if(htmlKeyboardWindow&&!htmlKeyboardWindow.isDestroyed()){ htmlKeyboardWindow.close(); } }); app.on('window-all-closed',()=>{ if(process.platform!=='darwin')app.quit(); }); app.on('activate',()=>{ if(BrowserWindow.getAllWindows().length===0)createWindow(); }); // v0.9.9: Lock on suspend/resume/screen lock if password enabled app.whenReady().then(()=>{ powerMonitor.on('suspend',()=>{ if(lockoutEnabled){ console.log('[LOCKOUT] 💤 System suspending - will lock on resume'); } }); powerMonitor.on('resume',()=>{ if(lockoutEnabled){ console.log('[LOCKOUT] 🔒 System resumed - locking session'); setTimeout(()=>{ showLockout(); },500); } }); powerMonitor.on('lock-screen',()=>{ if(lockoutEnabled){ console.log('[LOCKOUT] 🔒 Screen locked - locking kiosk'); showLockout(); } }); }); MAINJS ############################################################################# ###############################end of main.js################################ ############################################################################## echo "[14/27] Creating keyboard.html with shift display + 30s timeout..." sudo -u "$KIOSK_USER" tee "$KIOSK_DIR/keyboard.html" > /dev/null <<'KBHTML'
×
⌨️ Keyboard - Click icon or swipe to reopen
1
2
3
4
5
6
7
8
9
0
-
=
Tab
q
w
e
r
t
y
u
i
o
p
[
]
\
Caps
a
s
d
f
g
h
j
k
l
;
'
z
x
c
v
b
n
m
,
.
/
Ctrl
Alt
Space
Alt
Ctrl
KBHTML echo "[15/27] Creating PIN entry dialog..." sudo -u "$KIOSK_USER" tee "$KIOSK_DIR/pin-entry.html" > /dev/null <<'PINHTML'

🔒 Enter PIN

••••
❌ Incorrect PIN
Default PIN: 1234 (4-8 digits)
PINHTML echo "[15/27] Creating inactivity prompt..." sudo -u "$KIOSK_USER" tee "$KIOSK_DIR/inactivity-prompt-extended.html" > /dev/null <<'INACTHTML'

👋 Are you still here?

No activity detected. Choose an option:
15
ℹ️ Extensions pause the inactivity timer
Media playback (video/audio) automatically pauses the timer
Maximum extension: 4 hours (safety timeout)
INACTHTML ########################################################################### ############################start-lockout-html############################## ########################################################################### sudo -u "$KIOSK_USER" tee "$KIOSK_DIR/lockout.html" > /dev/null <<'LOCKOUTHTML' Session Locked
🔒

Session Locked

Your kiosk session has been locked due to inactivity.
Enter your password to continue.
ℹ️ The session automatically locks after inactivity
to protect your privacy and security.
LOCKOUTHTML echo "1234" | sudo -u "$KIOSK_USER" tee "$KIOSK_DIR/.jitsi-pin" >/dev/null sudo -u "$KIOSK_USER" chmod 600 "$KIOSK_DIR/.jitsi-pin" log_success "Default PIN: 1234" ########################################################################### ############################start-preload################################## ########################################################################### sudo -u "$KIOSK_USER" tee "$KIOSK_DIR/preload.js" > /dev/null <<'PRELOAD' const {contextBridge,ipcRenderer}=require('electron'); contextBridge.exposeInMainWorld('electronAPI',{ notifyActivity:()=>ipcRenderer.send('user-activity'), showKeyboard:()=>ipcRenderer.send('show-keyboard'), closeKeyboard:()=>ipcRenderer.send('close-keyboard'), keyboardActivity:()=>ipcRenderer.send('keyboard-activity') }); window.addEventListener('DOMContentLoaded',()=>{ document.addEventListener('contextmenu',e=>e.preventDefault()); const SWIPE_THRESHOLD=120; const SWIPE_MAX_TIME=500; const SWIPE_TOLERANCE=50; let touchStartX=0; let touchStartY=0; let touchStartTime=0; let fingerCount=0; let keyboardVisible=false; let keyboardIcon=null; let lastKeyboardRequest=0; let keyboardAutoClosedThisSession=false; const KEYBOARD_REQUEST_THROTTLE=1000; const activityEvents=[ 'mousedown','mouseup','mousemove','click','dblclick', 'wheel','scroll', 'keydown','keyup','keypress', 'touchstart','touchmove','touchend', 'pointerdown','pointerup','pointermove', 'input','change' ]; let lastActivityNotification=0; const ACTIVITY_THROTTLE=1000; function notifyActivity(){ const now=Date.now(); if(now-lastActivityNotification>ACTIVITY_THROTTLE){ if(window.electronAPI?.notifyActivity){ window.electronAPI.notifyActivity(); lastActivityNotification=now; } } } activityEvents.forEach(eventType=>{ document.addEventListener(eventType,notifyActivity,{ passive:true, capture:true }); }); ipcRenderer.on('keyboard-state-changed',(event,visible)=>{ keyboardVisible=visible; if(visible){ showKeyboardIcon(); keyboardAutoClosedThisSession=false; }else{ hideKeyboardIcon(); } }); ipcRenderer.on('keyboard-auto-closed',()=>{ keyboardAutoClosedThisSession=true; }); function createKeyboardIcon(){ if(keyboardIcon)return; keyboardIcon=document.createElement('div'); keyboardIcon.id='electron-keyboard-icon'; keyboardIcon.innerHTML='⌨️'; keyboardIcon.style.cssText=` position:fixed;bottom:20px;right:20px;width:60px;height:60px; background:rgba(52,152,219,0.95);border:3px solid rgba(255,255,255,0.9); border-radius:50%;display:none;align-items:center;justify-content:center; font-size:32px;cursor:pointer;z-index:999999; box-shadow:0 4px 12px rgba(0,0,0,0.4);user-select:none; `; keyboardIcon.addEventListener('click',(e)=>{ e.preventDefault(); e.stopPropagation(); keyboardAutoClosedThisSession=false; if(keyboardVisible){ ipcRenderer.send('close-keyboard'); }else{ ipcRenderer.send('show-keyboard'); } }); document.body.appendChild(keyboardIcon); } function showKeyboardIcon(){ if(!keyboardIcon)createKeyboardIcon(); keyboardIcon.style.display='flex'; } function hideKeyboardIcon(){ if(keyboardIcon)keyboardIcon.style.display='none'; } function isTextInput(el){ if(!el)return false; const tag=(el.tagName||'').toLowerCase(); const type=(el.type||'').toLowerCase(); const editable=el.isContentEditable||el.contentEditable==='true'; return(tag==='input'&&['text','email','password','search','tel','url','number'].includes(type))||tag==='textarea'||editable; } document.addEventListener('focusin',(e)=>{ if(isTextInput(e.target)){ showKeyboardIcon(); } },true); document.addEventListener('focusout',(e)=>{ if(isTextInput(e.target)){ setTimeout(()=>{ if(!isTextInput(document.activeElement)){ hideKeyboardIcon(); } },100); } },true); document.addEventListener('mousedown',(e)=>{ if(isTextInput(e.target)){ if(keyboardVisible){ if(window.electronAPI?.keyboardActivity){ window.electronAPI.keyboardActivity(); } }else{ keyboardAutoClosedThisSession=false; const now=Date.now(); if(now-lastKeyboardRequest>KEYBOARD_REQUEST_THROTTLE){ lastKeyboardRequest=now; setTimeout(()=>ipcRenderer.send('show-keyboard'),50); } } } },true); document.addEventListener('touchstart',e=>{ if(e.touches.length>=1){ touchStartX=e.touches[0].clientX; touchStartY=e.touches[0].clientY; touchStartTime=Date.now(); fingerCount=e.touches.length; } },{passive:true}); document.addEventListener('touchend',e=>{ if(e.changedTouches.length>=1){ const touchEndX=e.changedTouches[0].clientX; const touchEndY=e.changedTouches[0].clientY; const deltaX=touchEndX-touchStartX; const deltaY=touchEndY-touchStartY; const deltaTime=Date.now()-touchStartTime; if(deltaTime>SWIPE_MAX_TIME)return; const absX=Math.abs(deltaX); const absY=Math.abs(deltaY); if(fingerCount===3&&absY>SWIPE_THRESHOLD&&absXSWIPE_THRESHOLD&&absX0){ keyboardAutoClosedThisSession=false; if(keyboardVisible){ ipcRenderer.send('close-keyboard'); }else{ ipcRenderer.send('show-keyboard'); } } else if(fingerCount===2&&absX>SWIPE_THRESHOLD&&absY0?'swipe-right':'swipe-left'); } else if(fingerCount===1&&absX<30&&absY<30){ const target=document.elementFromPoint(touchEndX,touchEndY); if(target&&isTextInput(target)&&!keyboardVisible){ keyboardAutoClosedThisSession=false; const now=Date.now(); if(now-lastKeyboardRequest>KEYBOARD_REQUEST_THROTTLE){ lastKeyboardRequest=now; setTimeout(()=>ipcRenderer.send('show-keyboard'),50); } } } } },{passive:true}); }); PRELOAD ############################################################################ ################################end-preload.js############################## ############################################################################ echo "[16/27] Creating package.json..." sudo -u "$KIOSK_USER" tee "$KIOSK_DIR/package.json" > /dev/null <<'PKGJSON' { "name": "kiosk-app", "version": "1.0.0", "main": "main.js", "dependencies": { "electron": "^33.4.11" } } PKGJSON echo "[17/27] Installing Electron packages..." echo "Note: npm may show deprecation warnings (safe to ignore)" sudo -u "$KIOSK_USER" bash -lc "cd '$KIOSK_DIR' && npm install --unsafe-perm" local sandbox="$KIOSK_DIR/node_modules/electron/dist/chrome-sandbox" if [[ -f "$sandbox" ]]; then sudo chown root:root "$sandbox" sudo chmod 4755 "$sandbox" fi sudo -u "$KIOSK_USER" tee "$KIOSK_DIR/start.sh" > /dev/null <<'LAUNCHER' #!/bin/bash cd /home/kiosk/kiosk-app # Wait for network for i in {1..30}; do ping -c 1 -W 2 8.8.8.8 >/dev/null 2>&1 && break sleep 2 done export DISPLAY=:0 export ELECTRON_ENABLE_LOGGING=1 # Ensure PipeWire is running systemctl --user is-active --quiet pipewire || systemctl --user start pipewire systemctl --user is-active --quiet pipewire-pulse || systemctl --user start pipewire-pulse systemctl --user is-active --quiet wireplumber || systemctl --user start wireplumber # Wait for PipeWire for i in {1..10}; do pactl info >/dev/null 2>&1 && break sleep 1 done exec node_modules/electron/dist/electron . \ --no-sandbox --disable-gpu-sandbox --disable-dev-shm-usage \ --enable-features=UseOzonePlatform --ozone-platform=x11 \ --enable-audio-service-sandbox=false --autoplay-policy=no-user-gesture-required \ 2>&1 | tee -a /home/kiosk/electron.log LAUNCHER sudo chmod +x "$KIOSK_DIR/start.sh" echo "[18/27] Configuring Openbox with AGGRESSIVE screen keep-alive..." sudo -u "$KIOSK_USER" mkdir -p "$KIOSK_HOME/.config/openbox" "$KIOSK_HOME/.config/pulse" sudo -u "$KIOSK_USER" tee "$KIOSK_HOME/.config/openbox/autostart" > /dev/null <<'AUTOSTART' #!/bin/bash # AGGRESSIVE DPMS disable - multiple methods xset s off xset s noblank xset -dpms xset s 0 0 xset dpms 0 0 0 xset dpms force on # Keep screen on forever - watchdog ( while true; do sleep 300 # Every 5 minutes xset s reset 2>/dev/null xset dpms force on 2>/dev/null done ) & # Start PipeWire user services systemctl --user start pipewire pipewire-pulse wireplumber sleep 3 # Wait for PipeWire to be ready for i in {1..15}; do pactl info >/dev/null 2>&1 && break sleep 1 done # Wait for ALSA devices for i in {1..10}; do pactl list sinks short | grep -q alsa && break sleep 1 done # Set audio levels (speakers 100%, mic 100%, mic unmuted) pactl set-sink-volume @DEFAULT_SINK@ 100% pactl set-source-volume @DEFAULT_SOURCE@ 100% pactl set-source-mute @DEFAULT_SOURCE@ 0 # Audio watchdog - checks every 3 minutes ( while true; do sleep 30 # Check if PipeWire is running if ! pactl info >/dev/null 2>&1; then logger "KIOSK: Audio dead, restarting PipeWire" systemctl --user restart pipewire pipewire-pulse wireplumber sleep 5 pactl set-sink-volume @DEFAULT_SINK@ 100% pactl set-source-volume @DEFAULT_SOURCE@ 100% pactl set-source-mute @DEFAULT_SOURCE@ 0 fi # Check if PTT service is running if ! systemctl is-active --quiet jitsi-ptt; then logger "KIOSK: PTT service dead, restarting" systemctl restart jitsi-ptt fi # Unmute mic (in case Jitsi muted it) pactl set-source-mute @DEFAULT_SOURCE@ 0 2>/dev/null done ) & # Other services unclutter -idle 0.1 -root & XDG_RUNTIME_DIR=/run/user/$(id -u) xbindkeys & # Start kiosk app AFTER audio is ready sleep 2 /home/kiosk/kiosk-app/start.sh & AUTOSTART sudo chmod 750 "$KIOSK_HOME/.config/openbox/autostart" if lspci | grep -i "VGA.*Intel" >/dev/null 2>&1; then sudo mkdir -p /etc/X11/xorg.conf.d/ sudo tee /etc/X11/xorg.conf.d/20-intel.conf > /dev/null <<'EOF' Section "Device" Identifier "Intel Graphics" Driver "intel" Option "AccelMethod" "sna" Option "TearFree" "true" Option "DRI" "3" EndSection EOF fi echo "[19/27] Configuring autologin..." sudo mkdir -p /etc/lightdm/lightdm.conf.d sudo tee /etc/lightdm/lightdm.conf.d/10-kiosk.conf > /dev/null < /dev/null <<'EOF' [Allow kiosk power] Identity=unix-user:kiosk Action=org.freedesktop.login1.* ResultAny=yes ResultInactive=yes ResultActive=yes EOF echo "[22/27] Configuring volume controls..." sudo tee /usr/local/bin/kiosk-volume-up > /dev/null <<'EOF' #!/bin/bash CURRENT=$(pactl get-sink-volume @DEFAULT_SINK@ | grep -oE '[0-9]+%' | head -1 | tr -d '%') NEW=$((CURRENT + 5)) [[ $NEW -gt 100 ]] && NEW=100 pactl set-sink-volume @DEFAULT_SINK@ ${NEW}% EOF sudo chmod +x /usr/local/bin/kiosk-volume-up sudo tee /usr/local/bin/kiosk-volume-down > /dev/null <<'EOF' #!/bin/bash CURRENT=$(pactl get-sink-volume @DEFAULT_SINK@ | grep -oE '[0-9]+%' | head -1 | tr -d '%') NEW=$((CURRENT - 5)) [[ $NEW -lt 0 ]] && NEW=0 pactl set-sink-volume @DEFAULT_SINK@ ${NEW}% EOF sudo chmod +x /usr/local/bin/kiosk-volume-down echo "[23/27] Installing PTT service (spacebar hardcoded)..." sudo tee /usr/local/bin/jitsi-ptt-service.py > /dev/null <<'SMARTPTT' #!/usr/bin/env python3 """ Jitsi PTT Service - Push-to-Talk with SPACEBAR DOES NOT GRAB KEYBOARD - other keys work normally """ import evdev import subprocess import sys SPACEBAR_SCANCODE = 57 def find_keyboard(): """Find a keyboard device with spacebar""" devices = [evdev.InputDevice(path) for path in evdev.list_devices()] for dev in devices: caps = dev.capabilities() if evdev.ecodes.EV_KEY in caps: if SPACEBAR_SCANCODE in caps[evdev.ecodes.EV_KEY]: print(f"PTT: Using {dev.name}") return dev print(f"ERROR: No keyboard found with spacebar") return None def mute_output(): """Mute speakers (user is talking)""" subprocess.run(["pactl", "set-sink-mute", "@DEFAULT_SINK@", "1"], capture_output=True) def unmute_output(): """Unmute speakers (user is listening)""" subprocess.run(["pactl", "set-sink-mute", "@DEFAULT_SINK@", "0"], capture_output=True) def main(): keyboard = find_keyboard() if not keyboard: sys.exit(1) print(f"PTT Key: SPACEBAR (scancode {SPACEBAR_SCANCODE})") print(f"Smart PTT: HOLD = talk, RELEASE = listen") print("NOTE: Does NOT grab keyboard - all other keys work") # CRITICAL: Do NOT call keyboard.grab() # This allows other keys to work normally try: for event in keyboard.read_loop(): if event.type == evdev.ecodes.EV_KEY and event.code == SPACEBAR_SCANCODE: if event.value == 1: # Pressed mute_output() elif event.value == 0: # Released unmute_output() # All other events pass through except KeyboardInterrupt: print("\nPTT service stopped") unmute_output() sys.exit(0) if __name__ == "__main__": main() SMARTPTT sudo chmod +x /usr/local/bin/jitsi-ptt-service.py sudo tee /etc/systemd/system/jitsi-ptt.service > /dev/null <<'PTTSVC' [Unit] Description=Jitsi PTT Service After=lightdm.service sound.target pipewire.service Wants=pipewire.service [Service] Type=simple User=root ExecStart=/usr/local/bin/jitsi-ptt-service.py Restart=always RestartSec=5 StartLimitInterval=300 StartLimitBurst=20 WatchdogSec=60 TimeoutStopSec=10 RuntimeMaxSec=infinity [Install] WantedBy=multi-user.target PTTSVC sudo systemctl daemon-reload sudo systemctl enable jitsi-ptt.service log_success "PTT service installed (Spacebar)" echo "[24/27] Configuring hardware buttons with enhanced detection..." # Install evtest for debugging sudo apt install -y evtest 2>/dev/null || true # Get kiosk UID local kiosk_uid=$(id -u "$KIOSK_USER") # Create enhanced trigger script with multiple methods sudo -u "$KIOSK_USER" tee "$KIOSK_HOME/trigger-power-menu.sh" > /dev/null </dev/null | head -1) if [ -n "\$ELECTRON_WINDOW" ]; then logger "KIOSK: Found Electron window \$ELECTRON_WINDOW" xdotool windowactivate --sync \$ELECTRON_WINDOW 2>/dev/null sleep 0.2 xdotool key --window \$ELECTRON_WINDOW --clearmodifiers ctrl+alt+Delete 2>/dev/null if [ \$? -eq 0 ]; then logger "KIOSK: Power menu triggered via Method 1 (xdotool window)" exit 0 fi fi # Method 2: Find window by PID NODE_PID=\$(pgrep -f "node.*electron" | head -1) if [ -n "\$NODE_PID" ]; then WINDOW_BY_PID=\$(xdotool search --pid \$NODE_PID 2>/dev/null | head -1) if [ -n "\$WINDOW_BY_PID" ]; then logger "KIOSK: Found window by PID: \$WINDOW_BY_PID" xdotool windowactivate --sync \$WINDOW_BY_PID 2>/dev/null sleep 0.2 xdotool key --window \$WINDOW_BY_PID --clearmodifiers ctrl+alt+Delete 2>/dev/null if [ \$? -eq 0 ]; then logger "KIOSK: Power menu triggered via Method 2 (PID)" exit 0 fi fi fi # Method 3: Direct key injection (no specific window) logger "KIOSK: Trying direct key injection" xdotool key --clearmodifiers ctrl+alt+Delete 2>/dev/null if [ \$? -eq 0 ]; then logger "KIOSK: Power menu triggered via Method 3 (direct injection)" exit 0 fi # Method 4: Send SIGUSR1 to Node process if [ -n "\$NODE_PID" ]; then logger "KIOSK: Sending SIGUSR1 to Node PID \$NODE_PID" kill -USR1 \$NODE_PID 2>/dev/null exit 0 fi logger "KIOSK: All power button trigger methods failed" PWREOF sudo chmod +x "$KIOSK_HOME/trigger-power-menu.sh" # Create test script for debugging sudo tee /usr/local/bin/test-power-button > /dev/null <<'TESTEOF' #!/bin/bash echo "Testing power button configuration..." echo echo "1. Checking acpid service..." if systemctl is-active --quiet acpid; then echo " ✓ acpid is running" else echo " ✗ acpid is NOT running" echo " Fix: sudo systemctl start acpid" fi echo echo "2. Checking acpi events..." if [ -d /etc/acpi/events ]; then echo " Event files found:" ls -la /etc/acpi/events/kiosk-* 2>/dev/null || echo " ✗ No kiosk event files" else echo " ✗ /etc/acpi/events not found" fi echo echo "3. Testing Electron window detection..." ELECTRON_WINDOW=$(DISPLAY=:0 xdotool search --class "electron" 2>/dev/null | head -1) if [ -n "$ELECTRON_WINDOW" ]; then echo " ✓ Found Electron window: $ELECTRON_WINDOW" else echo " ✗ Electron window not found" echo " Is the kiosk running?" fi echo echo "4. Manual trigger test (run as kiosk user)..." echo " sudo -u kiosk /home/kiosk/trigger-power-menu.sh" echo echo "5. Watch acpi events (press Ctrl+C to stop)..." echo " sudo acpi_listen" echo echo "6. Test hardware button (install evtest first: sudo apt install evtest)..." echo " sudo evtest" echo " Then press power button and look for button/power events" TESTEOF sudo chmod +x /usr/local/bin/test-power-button # Remove old configs sudo rm -f /etc/acpi/events/powerbtn* /etc/acpi/events/power* 2>/dev/null # Create MULTIPLE event handlers for different power button formats # Format 1: Standard power button sudo tee /etc/acpi/events/kiosk-power-button > /dev/null < /dev/null < /dev/null < /dev/null < /dev/null <<'EOF' [Login] HandlePowerKey=ignore HandlePowerKeyLongPress=poweroff HandleSuspendKey=ignore HandleHibernateKey=ignore HandleLidSwitch=ignore EOF # Reload everything sudo systemctl daemon-reload sudo systemctl restart systemd-logind sudo systemctl enable acpid sudo systemctl restart acpid # Give it time to start sleep 2 # Verify setup if systemctl is-active --quiet acpid; then log_success "Power button configured with enhanced detection" echo echo " Test command: sudo -u $KIOSK_USER $KIOSK_HOME/trigger-power-menu.sh" echo " Debug tool: test-power-button" echo " Watch events: sudo acpi_listen" else log_warning "acpid may not be running properly" echo " Check status: sudo systemctl status acpid" echo " View events: sudo journalctl -u acpid -n 50" fi echo "[25/27] WiFi configuration..." configure_wifi echo echo "[26/27] Finalizing installation..." log_success "Core installation complete!" echo echo "Next steps:" echo " • Rerun this script to configure addons" echo " • Reboot to start the kiosk" echo read -r -p "Reboot now? (y/n): " do_reboot if [[ ! "$do_reboot" =~ ^[Nn]$ ]]; then echo "Rebooting..." sleep 3 sudo reboot fi } ################################################################################ ### SECTION 9: ADDON FUNCTIONS - LMS/SQUEEZELITE ################################################################################ addon_lms_squeezelite() { while true; do clear echo "════════════════════════════════════════════════════════════" echo " LMS SERVER / SQUEEZELITE PLAYER " echo "════════════════════════════════════════════════════════════" echo local lms_installed=false local sq_installed=false if is_service_active logitechmediaserver || is_service_enabled logitechmediaserver || \ is_service_active lyrionmusicserver || is_service_enabled lyrionmusicserver; then lms_installed=true local lms_ip=$(get_ip_address) echo "LMS Server: ✓ Installed" if is_service_active logitechmediaserver || is_service_active lyrionmusicserver; then echo " Status: Running" else echo " Status: Stopped" fi echo " Web: http://${lms_ip}:9000" echo fi if is_service_active squeezelite || is_service_enabled squeezelite; then sq_installed=true local player_name="Unknown" if [[ -f /usr/local/bin/squeezelite-start.sh ]]; then player_name=$(grep '^PLAYER_NAME=' /usr/local/bin/squeezelite-start.sh 2>/dev/null | cut -d'=' -f2 | tr -d '"' || echo "Unknown") fi echo "Squeezelite Player: ✓ Installed" is_service_active squeezelite && echo " Status: Running" || echo " Status: Stopped" 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." echo echo "Options:" local menu_num=1 local -A menu_actions echo " ${menu_num}. Install/Configure LMS Server" menu_actions[$menu_num]="install_lms" ((menu_num++)) echo " ${menu_num}. Install/Configure Squeezelite Player" menu_actions[$menu_num]="install_squeezelite" ((menu_num++)) if $lms_installed; then echo " ${menu_num}. Uninstall LMS Server" menu_actions[$menu_num]="uninstall_lms" ((menu_num++)) fi if $sq_installed; then echo " ${menu_num}. Uninstall Squeezelite Player" menu_actions[$menu_num]="uninstall_squeezelite" ((menu_num++)) fi echo " 0. Return" echo local max_option=$((menu_num-1)) read -r -p "Choose [0-$max_option]: " choice if [[ "$choice" == "0" ]]; then return elif [[ -n "${menu_actions[$choice]:-}" ]]; then ${menu_actions[$choice]} else log_error "Invalid choice" sleep 1 fi done } install_lms() { echo if is_service_active logitechmediaserver || is_service_active lyrionmusicserver; then echo "LMS is already installed." read -r -p "Reconfigure port? (y/n): " reconfig if [[ "$reconfig" =~ ^[Yy]$ ]]; then read -r -p "New HTTP port [9000]: " new_port new_port="${new_port:-9000}" 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 log_success "LMS reconfigured on port $new_port" fi pause return fi echo "Installing Lyrion Music Server..." # Try 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 sudo apt update 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 # Fallback to direct download if repository failed 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 # Detect which service name to use # Detect which service name actually exists local service_name="" if systemctl list-unit-files | grep -q "lyrionmusicserver.service"; then service_name="lyrionmusicserver" elif systemctl list-unit-files | grep -q "logitechmediaserver.service"; then service_name="logitechmediaserver" else # Check what was actually installed log_warning "Service file not found, checking installed files..." service_name=$(dpkg -L lyrionmusicserver logitechmediaserver 2>/dev/null | grep -m1 '\.service$' | xargs 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" sudo systemctl enable "$service_name" 2>&1 | tee /tmp/lms-enable.log sudo systemctl start "$service_name" 2>&1 | tee /tmp/lms-start.log 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 local lms_ip=$(get_ip_address) log_success "LMS installed" echo " Web interface: http://${lms_ip}:9000" pause } uninstall_lms() { echo read -r -p "Remove LMS Server? (y/n): " confirm [[ ! "$confirm" =~ ^[Yy]$ ]] && return # Detect which service name is in use local service_name="" if systemctl list-unit-files | grep -q "lyrionmusicserver.service"; then service_name="lyrionmusicserver" elif systemctl list-unit-files | grep -q "logitechmediaserver.service"; then service_name="logitechmediaserver" fi 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 sudo apt remove -y lyrionmusicserver 2>/dev/null || true sudo apt remove -y logitechmediaserver 2>/dev/null || true # Clean up repository sudo rm -f /etc/apt/sources.list.d/lms.list sudo rm -f /usr/share/keyrings/lms-keyring.gpg # Remove config/data (optional - ask user) read -r -p "Remove LMS data and configuration? (y/n): " remove_data if [[ "$remove_data" =~ ^[Yy]$ ]]; 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 } install_squeezelite() { echo if is_service_active squeezelite; then echo "Squeezelite is already installed." read -r -p "Reconfigure? (y/n): " reconfig [[ ! "$reconfig" =~ ^[Yy]$ ]] && { pause; return; } fi if ! command -v squeezelite &>/dev/null; then sudo apt install -y squeezelite fi read -r -p "Player name [Kiosk]: " player_name player_name="${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 read -r -p "LMS Server (e.g., 192.168.1.100:3483): " lms_server sudo tee /usr/local/bin/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 /usr/local/bin/squeezelite-start.sh local kiosk_uid=$(id -u "$KIOSK_USER") sudo tee /etc/systemd/system/squeezelite.service > /dev/null </dev/null | grep -q "^ii.*cups\s"; then cups_installed=true fi if $cups_installed && systemctl is-active --quiet cups; then local cups_ip=$(get_ip_address) echo "Status: ✓ Installed and running" echo " Admin interface: http://${cups_ip}:631" echo echo "Options:" start_menu add_option "Keep as-is" add_option "Reconfigure for network access" add_option "Complete uninstall (purge)" show_menu_prompt action=$choice case "$action" in 2) reconfigure_cups ;; 3) complete_cups_uninstall ;; 0) return ;; *) log_success "Keeping CUPS"; pause ;; esac elif $cups_installed; then echo "Status: ⚠ Installed but not running" echo echo "Options:" start_menu add_option "Start CUPS" add_option "Complete uninstall (purge)" show_menu_prompt action=$choice case "$action" in 1) sudo systemctl enable cups sudo systemctl start cups log_success "CUPS started" pause ;; 2) complete_cups_uninstall ;; 0) return ;; esac else echo "Status: Not installed" echo read -r -p "Install CUPS printing? (y/N): " install if [[ "$install" =~ ^[Yy]$ ]]; then install_cups_fresh fi pause fi } complete_cups_uninstall() { echo echo "Performing complete CUPS uninstall..." sudo systemctl stop cups cups-browsed 2>/dev/null || true sudo systemctl disable cups cups-browsed 2>/dev/null || true sudo apt remove --purge -y cups cups-daemon cups-client cups-filters \ cups-common cups-core-drivers cups-server-common cups-browsed \ cups-ppdc cups-bsd libcups2 libcupsimage2 2>/dev/null || true sudo apt remove --purge -y printer-driver-all printer-driver-cups-pdf \ hplip printer-driver-gutenprint foomatic-db-compressed-ppds \ openprinting-ppds 2>/dev/null || true sudo rm -rf /etc/cups /var/cache/cups /var/spool/cups /var/log/cups /usr/share/cups sudo rm -f /etc/polkit-1/localauthority/50-local.d/kiosk-printing.pkla sudo apt autoremove -y sudo apt clean log_success "CUPS completely removed" pause } install_cups_fresh() { echo echo "Installing CUPS from scratch..." sudo apt update sudo apt install -y cups cups-client cups-filters printer-driver-all \ printer-driver-cups-pdf hplip printer-driver-gutenprint \ foomatic-db-compressed-ppds openprinting-ppds sudo systemctl enable cups sudo systemctl start cups echo "Waiting for CUPS to start..." for i in {1..30}; do if systemctl is-active --quiet cups && lpstat -r &>/dev/null 2>&1; then break fi sleep 1 done sudo usermod -aG lpadmin "$BUILD_USER" [[ -n "${SUDO_USER:-}" ]] && sudo usermod -aG lpadmin "$SUDO_USER" 2>/dev/null || true reconfigure_cups local cups_ip=$(get_ip_address) log_success "CUPS installed" echo " Web interface: http://${cups_ip}:631" } reconfigure_cups() { [[ -f /etc/cups/cupsd.conf ]] && sudo cp /etc/cups/cupsd.conf /etc/cups/cupsd.conf.backup-$(date +%Y%m%d-%H%M%S) if command -v cupsctl &>/dev/null; then sudo cupsctl --remote-admin --remote-any --share-printers 2>/dev/null || true fi sudo sed -i 's/^Listen localhost:631/Port 631/' /etc/cups/cupsd.conf 2>/dev/null sudo sed -i 's/^Listen 127.0.0.1:631/Port 631/' /etc/cups/cupsd.conf 2>/dev/null sudo tee /etc/polkit-1/localauthority/50-local.d/kiosk-printing.pkla > /dev/null <<'EOF' [Allow kiosk printing] Identity=unix-user:kiosk Action=org.opensuse.cupspkhelper.mechanism.* ResultAny=yes ResultInactive=yes ResultActive=yes EOF sudo ufw allow 631/tcp comment 'CUPS' 2>/dev/null || true sudo systemctl restart cups log_success "CUPS configured for network access" pause } ################################################################################ ### SECTION 11: ADDON - JITSI (with keep-alive) ################################################################################ addon_jitsi_intercom() { clear echo "════════════════════════════════════════════════════════════" echo " JITSI INTERCOM (TWO-WAY AUDIO) " echo "════════════════════════════════════════════════════════════" echo local jitsi_configured=false if sudo -u "$KIOSK_USER" test -f "$CONFIG_PATH" 2>/dev/null; then if sudo -u "$KIOSK_USER" jq -e '.tabs[] | select(.url | contains("jit"))' "$CONFIG_PATH" >/dev/null 2>&1; then jitsi_configured=true local room_url=$(sudo -u "$KIOSK_USER" jq -r '.tabs[] | select(.url | contains("jit")) | .url' "$CONFIG_PATH") echo "Status: ✓ Configured" echo " Room: $room_url" echo fi fi if systemctl is-active --quiet jitsi-ptt 2>/dev/null; then echo "PTT Service: ✓ Running" echo " PTT Key: Spacebar (hold to talk)" echo fi if $jitsi_configured; then echo "Options:" echo " 1. Keep as-is" echo " 2. Change room/server" echo " 3. Change PIN" echo " 4. Remove Jitsi" echo " 0. Return" echo local max_option=4 read -r -p "Choose [0-$max_option]: " action case "$action" in 2) if sudo -u "$KIOSK_USER" test -f "$CONFIG_PATH" 2>/dev/null; then sudo -u "$KIOSK_USER" jq 'del(.tabs[] | select(.url | contains("jit")))' "$CONFIG_PATH" > /tmp/config.tmp sudo -u "$KIOSK_USER" cp /tmp/config.tmp "$CONFIG_PATH" fi install_jitsi_intercom ;; 3) echo echo "PIN Configuration:" read -r -s -p "New PIN (blank=no PIN): " new_pin echo if [[ -z "$new_pin" ]]; then echo "NOPIN" | sudo -u kiosk tee /home/kiosk/kiosk-app/.jitsi-pin >/dev/null log_success "PIN disabled" elif [[ "$new_pin" =~ ^[0-9]{4,8}$ ]]; then echo "$new_pin" | sudo -u kiosk tee /home/kiosk/kiosk-app/.jitsi-pin >/dev/null sudo chmod 600 /home/kiosk/kiosk-app/.jitsi-pin log_success "PIN updated" else log_error "PIN must be 4-8 digits" fi pause ;; 4) remove_jitsi_intercom ;; 0) return ;; *) log_success "Keeping Jitsi"; pause ;; esac else echo "Status: Not configured" echo echo "Jitsi provides:" echo " • Two-way audio (Kiosk ↔ Phone)" echo " • Hidden tab (F10 or 3-finger swipe)" echo " • Audio-only mode (Spacebar PTT)" echo " • Works with mobile Jitsi app" echo read -r -p "Configure Jitsi intercom? (y/n): " install if [[ "$install" =~ ^[Yy]$ ]]; then install_jitsi_intercom fi fi } install_jitsi_intercom() { echo echo " ══ JITSI SETUP ══" echo echo "Choose Jitsi server type:" echo " 1. Public Jitsi (meet.jit.si) - Free, no setup" echo " 2. Self-hosted Jitsi - Your own server" echo read -r -p "Choose [1-3]: " server_choice local url="" local room="kiosk-$(openssl rand -hex 12)" case "$server_choice" in 1) url="https://meet.jit.si/${room}#config.startWithAudioMuted=true&config.startWithVideoMuted=true&config.prejoinPageEnabled=false&config.disableThirdPartyRequests=true" log_success "Using public Jitsi server" ;; 2) read -r -p "Enter your Jitsi server URL (e.g., jitsi.example.com): " jitsi_server if [[ -z "$jitsi_server" ]]; then log_error "No server provided" pause return fi url="https://${jitsi_server}/${room}#config.startWithAudioMuted=true&config.startWithVideoMuted=true&config.prejoinPageEnabled=false" log_success "Using self-hosted: $jitsi_server" ;; *) log_error "Invalid choice" pause return ;; esac read -r -p "PIN (4-8 digits) [1234]: " pin pin="${pin:-1234}" [[ ! "$pin" =~ ^[0-9]{4,8}$ ]] && pin="1234" echo "$pin" | sudo -u "$KIOSK_USER" tee "$KIOSK_DIR/.jitsi-pin" >/dev/null sudo -u "$KIOSK_USER" chmod 600 "$KIOSK_DIR/.jitsi-pin" load_config || true URLS+=("$url") DURS+=(-1) USERS+=("") PASSES+=("") save_config log_success "Jitsi configured: $room (PIN: $pin)" echo "Room URL: $url" if ! systemctl is-active --quiet jitsi-ptt; then sudo systemctl start jitsi-ptt fi pause } remove_jitsi_intercom() { echo read -r -p "Remove Jitsi intercom? (y/n): " confirm [[ ! "$confirm" =~ ^[Yy]$ ]] && return if sudo -u "$KIOSK_USER" test -f "$CONFIG_PATH" 2>/dev/null; then sudo -u "$KIOSK_USER" jq 'del(.tabs[] | select(.url | contains("jit")))' "$CONFIG_PATH" > /tmp/config.tmp sudo -u "$KIOSK_USER" cp /tmp/config.tmp "$CONFIG_PATH" fi sudo systemctl stop jitsi-ptt 2>/dev/null || true log_success "Jitsi intercom removed" pause } ################################################################################ ### SECTION 11.5: ADDON - TALKKONNECT/MURMUR INTERCOM (FIXED) ################################################################################ addon_talkkonnect_intercom() { clear echo "════════════════════════════════════════════════════════════" echo " TALKKONNECT/MURMUR INTERCOM " echo "════════════════════════════════════════════════════════════" echo local murmur_installed=false local talkkonnect_installed=false local murmur_running=false local talkkonnect_running=false # Check Murmur server if systemctl list-unit-files | grep -q "mumble-server.service"; then murmur_installed=true if systemctl is-active --quiet mumble-server; then murmur_running=true fi fi # Check talkkonnect client if command -v talkkonnect &>/dev/null || [[ -f "$HOME/go/bin/talkkonnect" ]]; then talkkonnect_installed=true if systemctl is-active --quiet talkkonnect; then talkkonnect_running=true fi fi # Display status if $murmur_installed; then echo "Murmur Server: ✓ Installed" if $murmur_running; then echo " Status: Running" local server_ip=$(get_ip_address) echo " Address: $server_ip:64738" else echo " Status: Stopped" fi echo fi if $talkkonnect_installed; then echo "talkkonnect Client: ✓ Installed" if $talkkonnect_running; then echo " Status: Running" if [[ -f /home/$KIOSK_USER/talkkonnect.xml ]]; then local server=$(grep "serveraddress" /home/$KIOSK_USER/talkkonnect.xml 2>/dev/null | sed 's/.*\(.*\)<\/serveraddress>/\1/' || echo "Unknown") echo " Server: $server" fi else echo " Status: Stopped" fi echo fi # Menu options echo "Options:" local menu_num=1 if ! $murmur_installed && ! $talkkonnect_installed; then echo " 1. Install Murmur server + talkkonnect (all-in-one)" echo " 2. Install talkkonnect only (connect to existing server)" echo " 0. Return" echo local max_option=2 read -r -p "Choose [0-$max_option]: " choice case "$choice" in 1) install_murmur_and_talkkonnect ;; 2) install_talkkonnect_only ;; 0) return ;; esac else if $murmur_installed; then echo " $menu_num. $([ $murmur_running = true ] && echo 'Stop' || echo 'Start') Murmur server" local murmur_toggle=$menu_num ((menu_num++)) echo " $menu_num. Reconfigure Murmur server" local murmur_reconfig=$menu_num ((menu_num++)) echo " $menu_num. Uninstall Murmur server" local murmur_uninstall=$menu_num ((menu_num++)) else echo " $menu_num. Install Murmur server" local murmur_install=$menu_num ((menu_num++)) fi if $talkkonnect_installed; then echo " $menu_num. $([ $talkkonnect_running = true ] && echo 'Stop' || echo 'Start') talkkonnect client" local tk_toggle=$menu_num ((menu_num++)) echo " $menu_num. Reconfigure talkkonnect client" local tk_reconfig=$menu_num ((menu_num++)) echo " $menu_num. View talkkonnect logs" local tk_logs=$menu_num ((menu_num++)) echo " $menu_num. Uninstall talkkonnect client" local tk_uninstall=$menu_num ((menu_num++)) else echo " $menu_num. Install talkkonnect client" local tk_install=$menu_num ((menu_num++)) fi echo " 0. Return" echo read -r -p "Choose [0-$((menu_num-1))]: " choice # Handle selection if [[ -n "${murmur_toggle:-}" ]] && [[ "$choice" == "$murmur_toggle" ]]; then toggle_murmur_service elif [[ -n "${murmur_reconfig:-}" ]] && [[ "$choice" == "$murmur_reconfig" ]]; then reconfigure_murmur elif [[ -n "${murmur_uninstall:-}" ]] && [[ "$choice" == "$murmur_uninstall" ]]; then uninstall_murmur elif [[ -n "${murmur_install:-}" ]] && [[ "$choice" == "$murmur_install" ]]; then install_murmur_server elif [[ -n "${tk_toggle:-}" ]] && [[ "$choice" == "$tk_toggle" ]]; then toggle_talkkonnect_service elif [[ -n "${tk_reconfig:-}" ]] && [[ "$choice" == "$tk_reconfig" ]]; then reconfigure_talkkonnect elif [[ -n "${tk_logs:-}" ]] && [[ "$choice" == "$tk_logs" ]]; then view_talkkonnect_logs elif [[ -n "${tk_uninstall:-}" ]] && [[ "$choice" == "$tk_uninstall" ]]; then uninstall_talkkonnect elif [[ -n "${tk_install:-}" ]] && [[ "$choice" == "$tk_install" ]]; then install_talkkonnect_only elif [[ "$choice" == "0" ]]; then return fi fi } install_murmur_server() { echo echo " ═══ INSTALLING MURMUR SERVER ═══" echo echo "[1/5] Installing mumble-server package..." sudo apt update sudo apt install -y mumble-server echo "[2/5] Getting configuration details..." read -r -s -p "SuperUser password: " superuser_pass echo read -r -s -p "Server password (clients need this): " server_pass echo read -r -p "Welcome text [Welcome to Kiosk Intercom]: " welcome_text welcome_text="${welcome_text:-Welcome to Kiosk Intercom}" echo "[3/5] Creating configuration..." local config_file="/etc/mumble-server.ini" # Create config if it doesn't exist if [[ ! -f "$config_file" ]]; then sudo tee "$config_file" > /dev/null <<'MURMURCONF' # Murmur configuration file # Database location database=/var/lib/mumble-server/mumble-server.sqlite # Network settings port=64738 host=0.0.0.0 # Logging logfile=/var/log/mumble-server/mumble-server.log # Limits users=10 bandwidth=72000 # Welcome message welcometext=Welcome # Server password serverpassword= # Allow pings allowping=true # Enable HTML allowhtml=true MURMURCONF log_success "Config file created" fi # Update configuration sudo sed -i "s|^welcometext=.*|welcometext=$welcome_text|" "$config_file" sudo sed -i "s|^port=.*|port=64738|" "$config_file" sudo sed -i "s|^users=.*|users=10|" "$config_file" sudo sed -i "s|^bandwidth=.*|bandwidth=72000|" "$config_file" if [[ -n "$server_pass" ]]; then sudo sed -i "s|^serverpassword=.*|serverpassword=$server_pass|" "$config_file" fi echo "[4/5] Setting SuperUser password..." # Stop service before setting password sudo systemctl stop mumble-server 2>/dev/null || true sleep 2 # Set SuperUser password echo "$superuser_pass" | sudo murmurd -ini "$config_file" -supw - 2>/dev/null || { log_warning "Could not set SuperUser password via murmurd command" echo "You can set it later with: sudo murmurd -ini $config_file -supw YOUR_PASSWORD" } echo "[5/5] Starting service..." sudo systemctl enable mumble-server sudo systemctl start mumble-server # Configure firewall sudo ufw allow 64738/tcp comment 'Mumble/Murmur' 2>/dev/null || true sudo ufw allow 64738/udp comment 'Mumble/Murmur' 2>/dev/null || true sleep 3 local server_ip=$(get_ip_address) log_success "Murmur server installed" echo echo " Server: $server_ip:64738" echo " SuperUser: SuperUser / $superuser_pass" [[ -n "$server_pass" ]] && echo " Password: $server_pass" echo echo "Connect with Mumble app:" echo " Server: $server_ip" echo " Port: 64738" [[ -n "$server_pass" ]] && echo " Password: $server_pass" pause } install_murmur_and_talkkonnect() { echo echo "Installing Murmur server..." install_murmur_server echo echo "Now installing talkkonnect client..." echo "Configuring to connect to local server..." # Auto-configure for local server AUTO_SERVER="127.0.0.1" AUTO_PORT="64738" read -r -p "Username for talkkonnect [kiosk]: " tk_user tk_user="${tk_user:-kiosk}" read -r -s -p "Server password: " tk_pass echo install_talkkonnect_with_config "$AUTO_SERVER" "$AUTO_PORT" "$tk_user" "$tk_pass" "Root" } install_talkkonnect_only() { echo echo " ═══ INSTALLING TALKKONNECT (STANDALONE) ═══" echo echo "Enter Murmur/Mumble server details:" read -r -p "Server address (IP or domain): " server_addr read -r -p "Port [64738]: " server_port server_port="${server_port:-64738}" read -r -p "Username: " tk_user read -r -s -p "Password: " tk_pass echo read -r -p "Channel [Root]: " tk_channel tk_channel="${tk_channel:-Root}" install_talkkonnect_with_config "$server_addr" "$server_port" "$tk_user" "$tk_pass" "$tk_channel" } install_talkkonnect_with_config() { local server_addr="$1" local server_port="$2" local tk_user="$3" local tk_pass="$4" local tk_channel="$5" echo echo "[1/4] Installing Go..." if ! command -v go &>/dev/null; then wget -q https://golang.org/dl/go1.23.4.linux-amd64.tar.gz sudo tar -C /usr/local -xzf go1.23.4.linux-amd64.tar.gz rm go1.23.4.linux-amd64.tar.gz export PATH=$PATH:/usr/local/go/bin echo 'export PATH=$PATH:/usr/local/go/bin' | sudo tee /etc/profile.d/go.sh fi echo "[2/4] Installing audio dependencies..." sudo apt install -y libopenal-dev libopus-dev alsa-utils portaudio19-dev git echo "[3/4] Cloning and building talkkonnect..." local tk_src="/tmp/talkkonnect-src" rm -rf "$tk_src" git clone https://github.com/talkkonnect/talkkonnect.git "$tk_src" echo "Building (this takes 5-10 minutes)..." # Build as kiosk user with proper environment sudo -u "$KIOSK_USER" bash -c " export PATH=/usr/local/go/bin:\$PATH export HOME=/home/$KIOSK_USER export GOPATH=/home/$KIOSK_USER/go export CGO_ENABLED=1 mkdir -p /home/$KIOSK_USER/go/bin cd '$tk_src' || exit 1 echo 'Compiling...' go build -v -o /home/$KIOSK_USER/go/bin/talkkonnect . 2>&1 | tail -20 if [[ -f /home/$KIOSK_USER/go/bin/talkkonnect ]]; then chmod +x /home/$KIOSK_USER/go/bin/talkkonnect echo 'Build successful' exit 0 else echo 'Build failed - binary not created' exit 1 fi " local build_result=$? rm -rf "$tk_src" if [[ $build_result -ne 0 ]]; then log_error "Build failed" echo echo "Troubleshooting:" echo " 1. Check Go version: go version" echo " 2. Ensure build tools: sudo apt install build-essential" echo " 3. Check logs above for specific errors" pause return 1 fi # Verify binary if [[ ! -x "/home/$KIOSK_USER/go/bin/talkkonnect" ]]; then log_error "Binary not executable after build" pause return 1 fi log_success "talkkonnect built successfully" echo "[4/4] Creating configuration..." sudo -u "$KIOSK_USER" tee /home/$KIOSK_USER/talkkonnect.xml > /dev/null < true Primary $server_addr $tk_user $tk_pass true $server_port $tk_channel default 3 false true false rpi TKXML # Create systemd service sudo tee /etc/systemd/system/talkkonnect.service > /dev/null </dev/null sudo systemctl start mumble-server log_success "SuperUser password updated" ;; 4) read -r -p "New port [64738]: " new_port new_port="${new_port:-64738}" sudo sed -i "s|^port=.*|port=$new_port|" "$config_file" sudo systemctl restart mumble-server log_success "Port updated to $new_port" ;; esac pause } reconfigure_talkkonnect() { echo echo "Reconfigure talkkonnect:" echo " 1. Change server" echo " 2. Change credentials" echo " 3. Change channel" echo " 0. Cancel" read -r -p "Choose: " reconfig_choice case "$reconfig_choice" in 1) read -r -p "Server address: " new_server read -r -p "Port [64738]: " new_port new_port="${new_port:-64738}" sudo -u "$KIOSK_USER" sed -i "s|.*|$new_server|" /home/$KIOSK_USER/talkkonnect.xml sudo -u "$KIOSK_USER" sed -i "s|.*|$new_port|" /home/$KIOSK_USER/talkkonnect.xml sudo systemctl restart talkkonnect log_success "Server updated" ;; 2) read -r -p "Username: " new_user read -r -s -p "Password: " new_pass echo sudo -u "$KIOSK_USER" sed -i "s|.*|$new_user|" /home/$KIOSK_USER/talkkonnect.xml sudo -u "$KIOSK_USER" sed -i "s|.*|$new_pass|" /home/$KIOSK_USER/talkkonnect.xml sudo systemctl restart talkkonnect log_success "Credentials updated" ;; 3) read -r -p "Channel: " new_channel sudo -u "$KIOSK_USER" sed -i "s|.*|$new_channel|" /home/$KIOSK_USER/talkkonnect.xml sudo systemctl restart talkkonnect log_success "Channel updated" ;; esac pause } view_talkkonnect_logs() { echo echo "Recent talkkonnect logs:" echo "═══════════════════════════════════════════════════════════════" sudo journalctl -u talkkonnect -n 50 --no-pager echo pause } uninstall_murmur() { echo read -r -p "Uninstall Murmur server? (yes/no): " confirm [[ "$confirm" != "yes" ]] && return echo "Uninstalling..." sudo systemctl stop mumble-server 2>/dev/null || true sudo systemctl disable mumble-server 2>/dev/null || true sudo apt remove -y mumble-server 2>/dev/null || true read -r -p "Remove configuration and database? (y/n): " remove_data if [[ "$remove_data" =~ ^[Yy]$ ]]; then sudo rm -rf /var/lib/mumble-server sudo rm -f /etc/mumble-server.ini log_success "Murmur and data removed" else log_success "Murmur removed (data preserved)" fi pause } uninstall_talkkonnect() { echo read -r -p "Uninstall talkkonnect? (yes/no): " confirm [[ "$confirm" != "yes" ]] && return echo "Uninstalling..." sudo systemctl stop talkkonnect 2>/dev/null || true sudo systemctl disable talkkonnect 2>/dev/null || true sudo rm -f /etc/systemd/system/talkkonnect.service sudo rm -f /home/$KIOSK_USER/talkkonnect.xml sudo rm -f /home/$KIOSK_USER/go/bin/talkkonnect sudo systemctl daemon-reload log_success "talkkonnect uninstalled" pause } ################################################################################ ### SECTION 12: ADDON - VNC ################################################################################ addon_vnc() { clear echo "════════════════════════════════════════════════════════════" echo " VNC REMOTE DESKTOP " echo "════════════════════════════════════════════════════════════" echo if is_service_active x11vnc; then echo "Status: ✓ Running" local vnc_ip=$(get_ip_address) echo " Connect: ${vnc_ip}:5900" echo echo "Options:" echo " 1. Keep as-is" echo " 2. Reconfigure password" echo " 3. Uninstall" echo " 0. Return" echo local max_option=3 read -r -p "Choose [0-$max_option]: " action case "$action" in 2) echo read -r -s -p "New VNC password: " vnc_pass echo sudo -u "$KIOSK_USER" x11vnc -storepasswd "$vnc_pass" "$KIOSK_HOME/.vnc/passwd" sudo systemctl restart x11vnc log_success "VNC password updated" pause ;; 3) echo read -r -p "Remove VNC? (y/n): " confirm if [[ "$confirm" =~ ^[Yy]$ ]]; then sudo systemctl stop x11vnc sudo systemctl disable x11vnc sudo rm -f /etc/systemd/system/x11vnc.service sudo apt remove -y x11vnc log_success "VNC removed" fi pause ;; 0) return ;; *) log_success "Keeping VNC"; pause ;; esac else echo "Status: Not installed" read -r -p "Install x11vnc? (y/n): " install if [[ "$install" =~ ^[Yy]$ ]]; then sudo apt install -y x11vnc read -r -s -p "VNC password: " vnc_pass echo sudo -u "$KIOSK_USER" mkdir -p "$KIOSK_HOME/.vnc" sudo -u "$KIOSK_USER" x11vnc -storepasswd "$vnc_pass" "$KIOSK_HOME/.vnc/passwd" sudo tee /etc/systemd/system/x11vnc.service > /dev/null </dev/null || true local vnc_ip=$(get_ip_address) log_success "VNC installed" echo " Connect: ${vnc_ip}:5900" fi pause fi } ################################################################################ ### SECTION 13: ADDON - VPNs (with setup key support) ################################################################################ addon_wireguard() { clear echo "════════════════════════════════════════════════════════════" echo " WIREGUARD VPN " echo "════════════════════════════════════════════════════════════" echo if command -v wg &>/dev/null && sudo wg show 2>/dev/null | grep -q interface; then echo "Status: ✓ Connected" echo sudo wg show | grep -E "interface:|endpoint:|allowed ips:" | sed 's/^/ /' echo echo "Options:" echo " 1. Keep as-is" echo " 2. Show full config" echo " 3. Paste new config" echo " 4. Uninstall" echo " 0. Return" echo local max_option=4 read -r -p "Choose [0-$max_option]: " action case "$action" in 2) sudo wg show all; pause ;; 3) configure_wireguard_paste ;; 4) read -r -p "Remove WireGuard? (y/n): " confirm if [[ "$confirm" =~ ^[Yy]$ ]]; then sudo systemctl stop wg-quick@* 2>/dev/null || true sudo systemctl disable wg-quick@* 2>/dev/null || true sudo apt remove -y wireguard wireguard-tools log_success "WireGuard removed" fi pause ;; 0) return ;; *) pause ;; esac else echo "Status: Not installed" echo read -r -p "Install WireGuard? (y/n): " install if [[ "$install" =~ ^[Yy]$ ]]; then sudo apt install -y wireguard wireguard-tools log_success "WireGuard installed" echo echo "Options:" echo " 1. Paste config now" echo " 2. Configure later" read -r -p "Choose [1-2]: " config_choice [[ "$config_choice" == "1" ]] && configure_wireguard_paste fi pause fi } configure_wireguard_paste() { echo echo "Paste your WireGuard config (Ctrl+D when done):" local config=$(cat) if [[ -z "$config" ]]; then log_error "No config provided" return fi read -r -p "Config name [wg0]: " wg_name wg_name="${wg_name:-wg0}" echo "$config" | sudo tee "/etc/wireguard/${wg_name}.conf" > /dev/null sudo chmod 600 "/etc/wireguard/${wg_name}.conf" sudo systemctl enable "wg-quick@${wg_name}" sudo systemctl start "wg-quick@${wg_name}" log_success "WireGuard configured: $wg_name" pause } addon_tailscale() { clear echo "════════════════════════════════════════════════════════════" echo " TAILSCALE VPN " echo "════════════════════════════════════════════════════════════" echo if command -v tailscale &>/dev/null; then local ts_status=$(tailscale status --json 2>/dev/null | jq -r '.BackendState' 2>/dev/null || echo "unknown") if [[ "$ts_status" == "Running" ]]; then echo "Status: ✓ Connected" local ts_ip=$(tailscale ip -4 2>/dev/null) local ts_name=$(tailscale status --json 2>/dev/null | jq -r '.Self.HostName' 2>/dev/null) echo " Hostname: $ts_name" echo " IP: $ts_ip" echo else echo "Status: ✓ Installed, not connected" echo fi echo "Options:" echo " 1. Keep as-is" echo " 2. Connect (interactive)" echo " 3. Connect with auth key" echo " 4. Show status" echo " 5. Uninstall" echo " 0. Return" echo local max_option=5 read -r -p "Choose [0-$max_option]: " action case "$action" in 2) echo sudo tailscale up log_success "Tailscale connected" pause ;; 3) echo echo "Get auth key from: https://login.tailscale.com/admin/settings/keys" read -r -p "Enter auth key: " authkey if [[ -n "$authkey" ]]; then sudo tailscale up --authkey="$authkey" log_success "Tailscale connected" fi pause ;; 4) tailscale status; pause ;; 5) read -r -p "Remove Tailscale? (y/n): " confirm if [[ "$confirm" =~ ^[Yy]$ ]]; then sudo tailscale down sudo apt remove -y tailscale log_success "Tailscale removed" fi pause ;; 0) return ;; *) pause ;; esac else echo "Status: Not installed" echo read -r -p "Install Tailscale? (y/n): " install if [[ "$install" =~ ^[Yy]$ ]]; then curl -fsSL https://tailscale.com/install.sh | sh log_success "Tailscale installed" echo echo "Options:" echo " 1. Connect now (interactive)" echo " 2. Connect with auth key" echo " 3. Connect later" read -r -p "Choose [1-3]: " connect_choice case "$connect_choice" in 1) sudo tailscale up ;; 2) echo "Get auth key from: https://login.tailscale.com/admin/settings/keys" read -r -p "Enter auth key: " authkey [[ -n "$authkey" ]] && sudo tailscale up --authkey="$authkey" ;; esac fi pause fi } addon_netbird() { clear echo "════════════════════════════════════════════════════════════" echo " NETBIRD VPN " echo "════════════════════════════════════════════════════════════" echo if command -v netbird &>/dev/null; then local nb_status=$(netbird status 2>/dev/null | grep "Status:" | awk '{print $2}') if [[ "$nb_status" == "Connected" ]]; then echo "Status: ✓ Connected" netbird status | grep -E "NetBird IP:|Public key:" | sed 's/^/ /' echo else echo "Status: ✓ Installed, not connected" echo fi echo "Options:" echo " 1. Keep as-is" echo " 2. Connect with setup key" echo " 3. Show status" echo " 4. Uninstall" echo " 0. Return" echo local max_option=4 read -r -p "Choose [0-$max_option]: " action case "$action" in 2) echo echo "Get setup key from Netbird dashboard" read -r -p "Enter setup key: " setup_key if [[ -n "$setup_key" ]]; then sudo netbird up --setup-key "$setup_key" log_success "Netbird connected" fi pause ;; 3) netbird status; pause ;; 4) read -r -p "Remove Netbird? (y/n): " confirm if [[ "$confirm" =~ ^[Yy]$ ]]; then sudo netbird down sudo apt remove -y netbird log_success "Netbird removed" fi pause ;; 0) return ;; *) pause ;; esac else echo "Status: Not installed" echo read -r -p "Install Netbird? (y/n): " install if [[ "$install" =~ ^[Yy]$ ]]; then curl -fsSL https://pkgs.netbird.io/install.sh | sh log_success "Netbird installed" echo read -r -p "Connect with setup key now? (y/n): " do_connect if [[ "$do_connect" =~ ^[Yy]$ ]]; then echo "Get setup key from Netbird dashboard" read -r -p "Enter setup key: " setup_key [[ -n "$setup_key" ]] && sudo netbird up --setup-key "$setup_key" fi fi pause fi } ################################################################################ ### SECTION 14: ADDON - HTML ON-SCREEN KEYBOARD ################################################################################ addon_onscreen_keyboard() { clear echo "════════════════════════════════════════════════════════════" echo " HTML ON-SCREEN KEYBOARD " echo "════════════════════════════════════════════════════════════" echo local keyboard_installed=false local auto_show_enabled=false if [[ -f "$KIOSK_DIR/keyboard.html" ]]; then keyboard_installed=true echo "Status: ✓ Installed" # Check if auto-show is enabled if sudo grep -q "Auto-shows on text fields" "$KIOSK_DIR/preload.js" 2>/dev/null; then auto_show_enabled=true echo " Mode: Auto-show on text fields" else echo " Mode: Manual (3-finger tap or icon)" fi else echo "Status: Not installed" fi echo echo "Options:" if ! $keyboard_installed; then echo " 1. Install HTML Keyboard" echo " 0. Return" echo local max_option=1 read -r -p "Choose [0-$max_option]: " choice case "$choice" in 1) install_html_keyboard ;; 0) return ;; esac else echo " 1. Toggle auto-show (currently: $([ $auto_show_enabled = true ] && echo 'enabled' || echo 'disabled'))" echo " 2. Test keyboard" echo " 3. View keyboard logs" echo " 4. SSH Credentials Helper" echo " 5. Uninstall" echo " 0. Return" echo local max_option=5 read -r -p "Choose [0-$max_option]: " choice case "$choice" in 1) toggle_keyboard_autoshow ;; 2) test_html_keyboard ;; 3) view_keyboard_logs ;; 4) ssh_credentials_helper ;; 5) uninstall_html_keyboard ;; 0) return ;; esac fi } install_html_keyboard() { echo echo "Installing HTML On-Screen Keyboard..." echo # Create keyboard.html sudo -u "$KIOSK_USER" tee "$KIOSK_DIR/keyboard.html" > /dev/null <<'KBHTML'
×
⌨️ Keyboard - 2-finger swipe down to reopen
1
2
3
4
5
6
7
8
9
0
-
=
Tab
q
w
e
r
t
y
u
i
o
p
[
]
\
Caps
a
s
d
f
g
h
j
k
l
;
'
z
x
c
v
b
n
m
,
.
/
Ctrl
Alt
Space
Alt
Ctrl
KBHTML sudo chown "$KIOSK_USER:$KIOSK_USER" "$KIOSK_DIR/keyboard.html" log_success "keyboard.html created" # Update keyboard button to show only when keyboard visible sudo -u "$KIOSK_USER" tee "$KIOSK_DIR/keyboard-button.html" > /dev/null <<'BTNHTML' BTNHTML sudo chown "$KIOSK_USER:$KIOSK_USER" "$KIOSK_DIR/keyboard-button.html" log_success "keyboard-button.html created" # Ask about auto-show echo echo "Keyboard Mode:" echo " 1. Auto-show on text fields (recommended)" echo " 2. Manual only (3-finger tap)" echo read -r -p "Choose mode [1]: " kb_mode kb_mode="${kb_mode:-1}" if [[ "$kb_mode" == "1" ]]; then enable_keyboard_autoshow else disable_keyboard_autoshow fi # Update main.js update_mainjs_keyboard log_success "HTML Keyboard installed" echo echo "Restart kiosk to activate: Main Menu → Option 4" pause } update_mainjs_keyboard() { local mainjs="$KIOSK_DIR/main.js" # Backup sudo cp "$mainjs" "${mainjs}.backup-htmlkb-$(date +%Y%m%d-%H%M%S)" # Add keyboard variables if not exists if ! sudo grep -q "let htmlKeyboardWindow" "$mainjs"; then sudo sed -i '/let keyboardWindow=null;/a let htmlKeyboardWindow=null;let keyboardButtonCheckInterval=null;' "$mainjs" fi # Add keyboard functions local tmpfunc=$(mktemp) cat > "$tmpfunc" <<'KBFUNC' function showHTMLKeyboard(){ if(htmlKeyboardWindow&&!htmlKeyboardWindow.isDestroyed()){ htmlKeyboardWindow.focus(); return; } const{width,height}=mainWindow.getBounds(); const kbHeight=Math.floor(height*0.4); const kbY=height-kbHeight; htmlKeyboardWindow=new BrowserWindow({ width:width, height:kbHeight, x:0, y:kbY, frame:false, alwaysOnTop:true, skipTaskbar:true, webPreferences:{nodeIntegration:true,contextIsolation:false} }); htmlKeyboardWindow.loadFile(path.join(__dirname,'keyboard.html')); htmlKeyboardWindow.on('closed',()=>{ htmlKeyboardWindow=null; }); // Focus first input field after keyboard shows setTimeout(()=>{ let view=null; if(showingHidden&&hiddenViews[currentHiddenIndex]){ view=hiddenViews[currentHiddenIndex]; }else if(views[currentIndex]){ view=views[currentIndex]; } if(view&&view.webContents){ view.webContents.executeJavaScript(` (function(){ const inputs=document.querySelectorAll('input[type="text"],input[type="email"],input[type="password"],input[type="search"],input[type="tel"],input[type="url"],input[type="number"],textarea'); if(inputs.length>0){ inputs[0].focus(); inputs[0].scrollIntoView({behavior:'smooth',block:'center'}); console.log('[KB] Focused first input field'); return true; } return false; })(); `).catch(e=>console.error('[KB] Focus error:',e)); } },300); console.log('[KEYBOARD] HTML keyboard shown'); } function closeHTMLKeyboard(){ if(!keyboardIsOpen){ return; } const wasAutoClosed=keyboardClosePending; if(htmlKeyboardWindow&&!htmlKeyboardWindow.isDestroyed()){ htmlKeyboardWindow.close(); } htmlKeyboardWindow=null; keyboardIsOpen=false; keyboardClosePending=false; notifyKeyboardState(false); // Notify all views if this was an auto-close if(wasAutoClosed){ console.log('[KB] Auto-closed - notifying views'); const allViews=[...views,...hiddenViews]; allViews.forEach(view=>{ if(view&&view.webContents){ view.webContents.send('keyboard-auto-closed'); } }); } if(mainWindow&&!mainWindow.isDestroyed()){ mainWindow.focus(); } console.log('[KB] Closed'+(wasAutoClosed?' (auto)':' (manual)')); } function toggleHTMLKeyboard(){ if(htmlKeyboardWindow&&!htmlKeyboardWindow.isDestroyed()){ closeHTMLKeyboard(); }else{ showHTMLKeyboard(); } } function toggleKeyboard(){ toggleHTMLKeyboard(); } KBFUNC sudo sed -i '/^function createWindow(){/e cat '"$tmpfunc" "$mainjs" rm "$tmpfunc" # Add keyboard button persistence if ! sudo grep -q "keyboardButtonCheckInterval=setInterval" "$mainjs"; then sudo sed -i '/createKeyboardButton();/a\ keyboardButtonCheckInterval=setInterval(()=>{if(!keyboardWindow||keyboardWindow.isDestroyed()){createKeyboardButton();}},3000);' "$mainjs" fi # Add IPC handlers if ! sudo grep -q "ipcMain.on('keyboard-type'" "$mainjs"; then sudo sed -i "/ipcMain.on('toggle-keyboard',toggleKeyboard);/a\ ipcMain.on('keyboard-type',(event,key)=> if(!mainWindow||mainWindow.isDestroyed())return;\ const view=mainWindow.getTopBrowserView();\ if(view){\ view.webContents.sendInputEvent({type:'keyDown',keyCode:key});\ view.webContents.sendInputEvent({type:'char',keyCode:key});\ view.webContents.sendInputEvent({type:'keyUp',keyCode:key});\ markActivity(true);\ }\ });\ ipcMain.on('close-keyboard',()=>{closeHTMLKeyboard();});\ ipcMain.on('show-keyboard',()=>{if(!htmlKeyboardWindow||htmlKeyboardWindow.isDestroyed()){showHTMLKeyboard();}});\ ipcMain.on('hide-keyboard',()=>{closeHTMLKeyboard();});" "$mainjs" fi sudo chown "$KIOSK_USER:$KIOSK_USER" "$mainjs" log_success "main.js updated" } enable_keyboard_autoshow() { sudo cp "$KIOSK_DIR/preload.js" "$KIOSK_DIR/preload.js.backup-autoshow-$(date +%Y%m%d-%H%M%S)" disable_keyboard_autoshow() { sudo cp "$KIOSK_DIR/preload.js" "$KIOSK_DIR/preload.js.backup-manual-$(date +%Y%m%d-%H%M%S)" # Set autoShowEnabled to false in the preload.js sudo sed -i 's/let autoShowEnabled = true/let autoShowEnabled = false/' "$KIOSK_DIR/preload.js" sudo chown "$KIOSK_USER:$KIOSK_USER" "$KIOSK_DIR/preload.js" log_success "Auto-show disabled" } sudo -u "$KIOSK_USER" tee "$KIOSK_DIR/preload.js" > /dev/null <<'PRELOAD' const {contextBridge,ipcRenderer}=require('electron'); console.log('════════════════════════════════════════════════════════════'); console.log(' Jitsi PTT: SPACEBAR (hold to talk, release to listen)'); console.log(' Hidden Tab: 3-finger UP swipe (PIN protected)'); console.log(' Keyboard: 2-finger DOWN swipe (always available)'); console.log('════════════════════════════════════════════════════════════'); contextBridge.exposeInMainWorld('electronAPI', { notifyActivity: () => ipcRenderer.send('user-activity'), showKeyboard: () => ipcRenderer.send('show-keyboard') }); window.addEventListener('DOMContentLoaded',()=>{ document.addEventListener('contextmenu',e=>e.preventDefault()); const SWIPE_THRESHOLD=120; const SWIPE_MAX_TIME=500; const SWIPE_TOLERANCE=50; let touchStartX=0; let touchStartY=0; let touchStartTime=0; let fingerCount=0; // Track keyboard state globally let keyboardVisible = false; let lastKeyboardRequest = 0; // Listen for keyboard state changes from main process ipcRenderer.on('keyboard-visible', (event, visible) => { keyboardVisible = visible; console.log(`[KEYBOARD] State: ${visible ? 'visible' : 'hidden'}`); }); document.addEventListener('touchstart',e=>{ if(e.touches.length>=1){ touchStartX=e.touches[0].clientX; touchStartY=e.touches[0].clientY; touchStartTime=Date.now(); fingerCount=e.touches.length; } },{passive:true}); document.addEventListener('touchend',e=>{ if(e.changedTouches.length>=1){ const touchEndX=e.changedTouches[0].clientX; const touchEndY=e.changedTouches[0].clientY; const deltaX=touchEndX-touchStartX; const deltaY=touchEndY-touchStartY; const deltaTime=Date.now()-touchStartTime; if(deltaTime>SWIPE_MAX_TIME)return; const absX=Math.abs(deltaX); const absY=Math.abs(deltaY); // 3-finger UP = hidden tabs if(fingerCount===3 && absY>SWIPE_THRESHOLD && absX