install.sh 62 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654
  1. #!/usr/bin/env bash
  2. #
  3. # SpoolBuddy Installation Script for Raspberry Pi
  4. #
  5. # Supports two scenarios:
  6. # 1) SpoolBuddy only — NFC/scale companion connecting to a remote Bambuddy instance
  7. # 2) SpoolBuddy + Bambuddy — both running natively on this Raspberry Pi
  8. #
  9. # Usage:
  10. # Interactive: curl -fsSL https://raw.githubusercontent.com/maziggy/bambuddy/main/spoolbuddy/install.sh -o install.sh && chmod +x install.sh && sudo ./install.sh
  11. # Unattended: sudo ./install.sh --mode spoolbuddy --bambuddy-url http://192.168.1.100:8000 --api-key bb_xxx --yes
  12. #
  13. # Options:
  14. # --mode MODE Installation mode: "spoolbuddy" (companion only) or "full" (both)
  15. # --repo URL Git repository URL to install from (default: upstream repo)
  16. # --ref REF Git ref to install (branch/tag/commit, default: main)
  17. # --bambuddy-url URL Bambuddy server URL (required for spoolbuddy mode)
  18. # --api-key KEY Bambuddy API key (required for spoolbuddy mode)
  19. # --path PATH Installation directory (default: /opt/spoolbuddy or /opt/bambuddy)
  20. # --port PORT Bambuddy port (full mode only, default: 8000)
  21. # --ssh-pubkey KEY Bambuddy SSH public key for remote updates
  22. # --yes, -y Non-interactive mode, accept defaults
  23. # --help, -h Show this help message
  24. #
  25. set -e
  26. # ─────────────────────────────────────────────────────────────────────────────
  27. # Constants
  28. # ─────────────────────────────────────────────────────────────────────────────
  29. RED='\033[0;31m'
  30. GREEN='\033[0;32m'
  31. YELLOW='\033[1;33m'
  32. CYAN='\033[0;36m'
  33. BOLD='\033[1m'
  34. NC='\033[0m'
  35. GITHUB_REPO="https://github.com/maziggy/bambuddy.git"
  36. SPOOLBUDDY_SERVICE_USER="spoolbuddy"
  37. BAMBUDDY_SERVICE_USER="bambuddy"
  38. # Packages needed for SpoolBuddy hardware (NFC reader + scale)
  39. SYSTEM_PACKAGES="python3 python3-pip python3-venv python3-dev python3-spidev python3-libgpiod gpiod libgpiod-dev i2c-tools git plymouth-themes"
  40. # Python packages for SpoolBuddy daemon
  41. SPOOLBUDDY_PIP_PACKAGES="spidev gpiod smbus2 httpx"
  42. # ─────────────────────────────────────────────────────────────────────────────
  43. # Variables (set by args or prompts)
  44. # ─────────────────────────────────────────────────────────────────────────────
  45. INSTALL_MODE="" # "spoolbuddy" or "full"
  46. INSTALL_PATH=""
  47. INSTALL_REPO=""
  48. INSTALL_REF=""
  49. DETECTED_INSTALLER_REPO=""
  50. DETECTED_INSTALLER_REF=""
  51. BAMBUDDY_URL=""
  52. API_KEY=""
  53. BAMBUDDY_PORT="8000"
  54. NON_INTERACTIVE="false"
  55. REBOOT_NEEDED="false"
  56. KIOSK_USER="" # auto-detected from $SUDO_USER
  57. KIOSK_URL="" # derived from $BAMBUDDY_URL/spoolbuddy?token=$API_KEY
  58. SSH_PUBKEY="" # Bambuddy's SSH public key for remote updates
  59. # ─────────────────────────────────────────────────────────────────────────────
  60. # Helpers
  61. # ─────────────────────────────────────────────────────────────────────────────
  62. info() { echo -e "${CYAN}[INFO]${NC} $1"; }
  63. success() { echo -e "${GREEN}[OK]${NC} $1"; }
  64. warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
  65. error() { echo -e "${RED}[ERROR]${NC} $1"; exit 1; }
  66. # Run a long-running command with a spinner + live progress output.
  67. # Usage: run_with_progress "description" command [args...]
  68. run_with_progress() {
  69. local desc="$1"
  70. shift
  71. local log_file
  72. log_file=$(mktemp /tmp/spoolbuddy-install.XXXXXX)
  73. local start_time=$SECONDS
  74. # Run command in background, capture stdout+stderr
  75. "$@" > "$log_file" 2>&1 &
  76. local pid=$!
  77. # Spinner frames (braille pattern)
  78. local -a spin=("⠋" "⠙" "⠹" "⠸" "⠼" "⠴" "⠦" "⠧" "⠇" "⠏")
  79. local i=0
  80. while kill -0 "$pid" 2>/dev/null; do
  81. local elapsed=$(( SECONDS - start_time ))
  82. local time_str
  83. if (( elapsed >= 60 )); then
  84. time_str="$(( elapsed / 60 ))m$(printf '%02d' $(( elapsed % 60 )))s"
  85. else
  86. time_str="${elapsed}s"
  87. fi
  88. # Last chunk of output (handles \r progress lines and regular \n lines)
  89. local last_line=""
  90. last_line=$(tail -c 4096 "$log_file" 2>/dev/null | tr '\r' '\n' | sed 's/\x1b\[[0-9;]*[mGKHJ]//g' | sed '/^[[:space:]]*$/d' | tail -1 | sed 's/^[[:space:]]*//' | cut -c1-50) || true
  91. printf "\r ${spin[$((i % 10))]} %-36s ${CYAN}%6s${NC} %s\033[K" "$desc" "$time_str" "$last_line"
  92. i=$(( i + 1 ))
  93. sleep 0.15
  94. done
  95. local exit_code=0
  96. wait "$pid" || exit_code=$?
  97. # Clear spinner line
  98. printf "\r\033[K"
  99. # Format elapsed time for summary
  100. local elapsed=$(( SECONDS - start_time ))
  101. local time_suffix=""
  102. if (( elapsed >= 60 )); then
  103. time_suffix=" ($(( elapsed / 60 ))m $(( elapsed % 60 ))s)"
  104. elif (( elapsed >= 5 )); then
  105. time_suffix=" (${elapsed}s)"
  106. fi
  107. if [[ $exit_code -eq 0 ]]; then
  108. success "${desc}${time_suffix}"
  109. rm -f "$log_file"
  110. else
  111. echo -e "${RED}[FAIL]${NC} ${desc}${time_suffix}"
  112. echo ""
  113. echo -e " ${YELLOW}Last 20 lines:${NC}"
  114. tail -20 "$log_file" 2>/dev/null | sed 's/^/ /'
  115. echo ""
  116. echo -e " Full log: ${CYAN}$log_file${NC}"
  117. exit 1
  118. fi
  119. }
  120. prompt() {
  121. local prompt_text="$1"
  122. local default_value="$2"
  123. local var_name="$3"
  124. if [[ "$NON_INTERACTIVE" == "true" ]]; then
  125. eval "$var_name=\"$default_value\""
  126. return
  127. fi
  128. if [[ -n "$default_value" ]]; then
  129. echo -en "${BOLD}$prompt_text${NC} [${CYAN}$default_value${NC}]: "
  130. else
  131. echo -en "${BOLD}$prompt_text${NC}: "
  132. fi
  133. read -r input
  134. if [[ -z "$input" ]]; then
  135. eval "$var_name=\"$default_value\""
  136. else
  137. eval "$var_name=\"$input\""
  138. fi
  139. }
  140. prompt_yes_no() {
  141. local prompt_text="$1"
  142. local default="$2"
  143. if [[ "$NON_INTERACTIVE" == "true" ]]; then
  144. [[ "$default" == "y" ]] && return 0 || return 1
  145. fi
  146. local yn_hint="[y/n]"
  147. [[ "$default" == "y" ]] && yn_hint="[Y/n]"
  148. [[ "$default" == "n" ]] && yn_hint="[y/N]"
  149. while true; do
  150. echo -en "${BOLD}$prompt_text${NC} $yn_hint: "
  151. read -r yn
  152. [[ -z "$yn" ]] && yn="$default"
  153. case "$yn" in
  154. [Yy]* ) return 0;;
  155. [Nn]* ) return 1;;
  156. * ) echo "Please answer yes or no.";;
  157. esac
  158. done
  159. }
  160. show_help() {
  161. echo "SpoolBuddy Installation Script for Raspberry Pi"
  162. echo ""
  163. echo "Usage: sudo $0 [OPTIONS]"
  164. echo ""
  165. echo "Options:"
  166. echo " --mode MODE \"spoolbuddy\" (companion only) or \"full\" (Bambuddy + SpoolBuddy)"
  167. echo " --repo URL Git repository URL to install from"
  168. echo " --ref REF Git ref to install (branch/tag/commit)"
  169. echo " --bambuddy-url URL Bambuddy server URL (required for spoolbuddy mode)"
  170. echo " --api-key KEY Bambuddy API key (required for spoolbuddy mode)"
  171. echo " --path PATH Installation directory (default: /opt/spoolbuddy or /opt/bambuddy)"
  172. echo " --port PORT Bambuddy port (full mode only, default: 8000)"
  173. echo " --ssh-pubkey KEY Bambuddy SSH public key for remote updates"
  174. echo " --yes, -y Non-interactive mode, accept defaults"
  175. echo " --help, -h Show this help message"
  176. echo ""
  177. echo "Examples:"
  178. echo " Interactive:"
  179. echo " sudo ./install.sh"
  180. echo ""
  181. echo " SpoolBuddy companion (unattended):"
  182. echo " sudo ./install.sh --mode spoolbuddy --bambuddy-url http://192.168.1.100:8000 --api-key bb_xxx -y"
  183. echo ""
  184. echo " Full install (unattended):"
  185. echo " sudo ./install.sh --mode full --port 8000 -y"
  186. exit 0
  187. }
  188. normalize_github_repo_url() {
  189. local url="$1"
  190. if [[ -z "$url" ]]; then
  191. echo ""
  192. return
  193. fi
  194. # Convert git@github.com:owner/repo(.git) to https://github.com/owner/repo.git
  195. if [[ "$url" =~ ^git@github.com:(.+)$ ]]; then
  196. url="https://github.com/${BASH_REMATCH[1]}"
  197. fi
  198. # Keep remote URL style consistent.
  199. url="${url%.git}"
  200. echo "${url}.git"
  201. }
  202. detect_installer_source_context() {
  203. local script_dir
  204. script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
  205. if git -C "$script_dir" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
  206. DETECTED_INSTALLER_REF="$(git -C "$script_dir" rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
  207. local origin_url
  208. origin_url="$(git -C "$script_dir" remote get-url origin 2>/dev/null || true)"
  209. DETECTED_INSTALLER_REPO="$(normalize_github_repo_url "$origin_url")"
  210. fi
  211. # Optional environment overrides for raw-download installs.
  212. if [[ -n "${SPOOLBUDDY_INSTALL_REPO:-}" ]]; then
  213. DETECTED_INSTALLER_REPO="$(normalize_github_repo_url "$SPOOLBUDDY_INSTALL_REPO")"
  214. fi
  215. if [[ -n "${SPOOLBUDDY_INSTALL_REF:-}" ]]; then
  216. DETECTED_INSTALLER_REF="$SPOOLBUDDY_INSTALL_REF"
  217. fi
  218. if [[ -z "$INSTALL_REPO" ]]; then
  219. if [[ -n "$DETECTED_INSTALLER_REPO" ]]; then
  220. INSTALL_REPO="$DETECTED_INSTALLER_REPO"
  221. else
  222. INSTALL_REPO="$GITHUB_REPO"
  223. fi
  224. fi
  225. if [[ -z "$INSTALL_REF" ]]; then
  226. if [[ -n "$DETECTED_INSTALLER_REF" && "$DETECTED_INSTALLER_REF" != "HEAD" ]]; then
  227. INSTALL_REF="$DETECTED_INSTALLER_REF"
  228. else
  229. INSTALL_REF="main"
  230. fi
  231. fi
  232. }
  233. resolve_install_ref() {
  234. local ref="$1"
  235. # If ref exists on origin as a branch, track/reset it. Otherwise treat it as tag/commit.
  236. if git ls-remote --exit-code --heads origin "$ref" >/dev/null 2>&1; then
  237. git checkout -B "$ref" "origin/$ref" > /dev/null 2>&1
  238. git reset --hard "origin/$ref" > /dev/null 2>&1
  239. else
  240. git checkout "$ref" > /dev/null 2>&1
  241. fi
  242. }
  243. # ─────────────────────────────────────────────────────────────────────────────
  244. # Pre-flight Checks
  245. # ─────────────────────────────────────────────────────────────────────────────
  246. check_root() {
  247. if [[ $EUID -ne 0 ]]; then
  248. error "This script must be run as root (use sudo)"
  249. fi
  250. }
  251. check_raspberry_pi() {
  252. if ! grep -q "Raspberry Pi\|BCM2" /proc/cpuinfo 2>/dev/null; then
  253. error "This script is designed for Raspberry Pi only"
  254. fi
  255. # Detect Pi model for hardware recommendations
  256. local model
  257. model=$(tr -d '\0' < /proc/device-tree/model 2>/dev/null) || model="Unknown"
  258. success "Detected: $model"
  259. }
  260. check_raspberry_pi_os() {
  261. if [[ ! -f /etc/os-release ]]; then
  262. error "Cannot detect operating system"
  263. fi
  264. . /etc/os-release
  265. if [[ "$ID" != "raspbian" && "$ID" != "debian" ]]; then
  266. warn "Expected Raspberry Pi OS (Debian-based), found: $ID"
  267. if ! prompt_yes_no "Continue anyway?" "n"; then
  268. exit 0
  269. fi
  270. fi
  271. success "OS: $PRETTY_NAME"
  272. }
  273. detect_python() {
  274. local cmd=""
  275. if command -v python3 &>/dev/null; then
  276. cmd="python3"
  277. elif command -v python &>/dev/null; then
  278. local ver
  279. ver=$(python --version 2>&1 | cut -d' ' -f2 | cut -d'.' -f1)
  280. if [[ "$ver" -ge 3 ]]; then
  281. cmd="python"
  282. fi
  283. fi
  284. if [[ -z "$cmd" ]]; then
  285. return 1
  286. fi
  287. local version
  288. version=$($cmd -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')
  289. local major minor
  290. major=$(echo "$version" | cut -d'.' -f1)
  291. minor=$(echo "$version" | cut -d'.' -f2)
  292. if [[ "$major" -lt 3 ]] || { [[ "$major" -eq 3 ]] && [[ "$minor" -lt 10 ]]; }; then
  293. warn "Python $version found, but 3.10+ is required"
  294. return 1
  295. fi
  296. PYTHON_CMD="$cmd"
  297. success "Found Python $version"
  298. return 0
  299. }
  300. # ─────────────────────────────────────────────────────────────────────────────
  301. # Raspberry Pi Hardware Configuration
  302. # ─────────────────────────────────────────────────────────────────────────────
  303. enable_spi() {
  304. if raspi-config nonint get_spi 2>/dev/null | grep -q "1"; then
  305. info "Enabling SPI..."
  306. raspi-config nonint do_spi 0
  307. REBOOT_NEEDED="true"
  308. success "SPI enabled"
  309. else
  310. success "SPI already enabled"
  311. fi
  312. }
  313. enable_i2c() {
  314. if raspi-config nonint get_i2c 2>/dev/null | grep -q "1"; then
  315. info "Enabling I2C..."
  316. raspi-config nonint do_i2c 0
  317. REBOOT_NEEDED="true"
  318. success "I2C enabled"
  319. else
  320. success "I2C already enabled"
  321. fi
  322. }
  323. configure_boot_config() {
  324. # Find the boot config file (Bookworm+ uses /boot/firmware/config.txt)
  325. local boot_config="/boot/firmware/config.txt"
  326. if [[ ! -f "$boot_config" ]]; then
  327. boot_config="/boot/config.txt"
  328. fi
  329. if [[ ! -f "$boot_config" ]]; then
  330. warn "Boot config not found at /boot/firmware/config.txt or /boot/config.txt"
  331. warn "You may need to manually add: dtparam=i2c_arm=on and dtoverlay=spi0-0cs"
  332. return
  333. fi
  334. info "Configuring $boot_config..."
  335. # Migrate legacy SpoolBuddy setting (bus 0 / i2c_vc) to bus 1 / i2c_arm.
  336. if grep -q "^dtparam=i2c_vc=on" "$boot_config"; then
  337. sed -i "s/^dtparam=i2c_vc=on$/# dtparam=i2c_vc=on (disabled by SpoolBuddy installer; use i2c_arm bus 1)/" "$boot_config"
  338. REBOOT_NEEDED="true"
  339. success "Disabled legacy dtparam=i2c_vc=on"
  340. fi
  341. if grep -q "^# SpoolBuddy: I2C bus 0 for NAU7802 scale (GPIO0/GPIO1)" "$boot_config"; then
  342. sed -i "s/^# SpoolBuddy: I2C bus 0 for NAU7802 scale (GPIO0\/GPIO1)$/# SpoolBuddy: I2C bus 1 for NAU7802 scale (GPIO2\/GPIO3)/" "$boot_config"
  343. fi
  344. # Ensure I2C bus 1 (GPIO2/GPIO3) is enabled for NAU7802 scale
  345. if ! grep -q "^dtparam=i2c_arm=on" "$boot_config"; then
  346. echo "" >> "$boot_config"
  347. echo "# SpoolBuddy: I2C bus 1 for NAU7802 scale (GPIO2/GPIO3)" >> "$boot_config"
  348. echo "dtparam=i2c_arm=on" >> "$boot_config"
  349. REBOOT_NEEDED="true"
  350. success "Added dtparam=i2c_arm=on"
  351. else
  352. success "dtparam=i2c_arm=on already set"
  353. fi
  354. # Disable SPI auto chip-select (manual CS on GPIO23 for PN5180)
  355. if ! grep -q "^dtoverlay=spi0-0cs" "$boot_config"; then
  356. echo "" >> "$boot_config"
  357. echo "# SpoolBuddy: Disable SPI auto CS (manual CS on GPIO23 for PN5180)" >> "$boot_config"
  358. echo "dtoverlay=spi0-0cs" >> "$boot_config"
  359. REBOOT_NEEDED="true"
  360. success "Added dtoverlay=spi0-0cs"
  361. else
  362. success "dtoverlay=spi0-0cs already set"
  363. fi
  364. }
  365. # ─────────────────────────────────────────────────────────────────────────────
  366. # Package Installation
  367. # ─────────────────────────────────────────────────────────────────────────────
  368. install_system_packages() {
  369. run_with_progress "Updating package lists" apt-get update
  370. run_with_progress "Installing system packages" apt-get install -y $SYSTEM_PACKAGES
  371. }
  372. install_wifi_safeguard() {
  373. # Protect WiFi credentials from being wiped by apt upgrades.
  374. # Raspberry Pi OS Bookworm migrated from wpa_supplicant/dhcpcd to
  375. # NetworkManager, but certain package upgrades (raspberrypi-sys-mods,
  376. # raspi-config, NetworkManager itself) can delete saved connections
  377. # from /etc/NetworkManager/system-connections/. This hook backs them
  378. # up before dpkg runs and restores them if they vanish.
  379. local hook_file="/etc/apt/apt.conf.d/80-preserve-wifi"
  380. if [[ -f "$hook_file" ]]; then
  381. success "WiFi safeguard already installed"
  382. return
  383. fi
  384. # Only install if NetworkManager is the active network manager
  385. if ! systemctl is-active --quiet NetworkManager 2>/dev/null; then
  386. return
  387. fi
  388. # Write a helper script (avoids quote escaping issues in APT config)
  389. local helper="/usr/local/sbin/preserve-wifi"
  390. cat > "$helper" << 'HELPEREOF'
  391. #!/bin/sh
  392. # Called by APT hooks to preserve NetworkManager WiFi connections.
  393. NM_DIR="/etc/NetworkManager/system-connections"
  394. BAK_DIR="/etc/NetworkManager/system-connections.bak"
  395. case "$1" in
  396. backup)
  397. if [ -d "$NM_DIR" ] && [ -n "$(ls -A "$NM_DIR" 2>/dev/null)" ]; then
  398. cp -a "$NM_DIR/" "$BAK_DIR/"
  399. fi
  400. ;;
  401. restore)
  402. if [ -d "$BAK_DIR" ] && [ -z "$(ls -A "$NM_DIR" 2>/dev/null)" ]; then
  403. cp -a "$BAK_DIR"/* "$NM_DIR"/
  404. nmcli general reload 2>/dev/null
  405. fi
  406. rm -rf "$BAK_DIR" 2>/dev/null
  407. ;;
  408. esac
  409. HELPEREOF
  410. chmod +x "$helper"
  411. cat > "$hook_file" << 'APTEOF'
  412. // Preserve NetworkManager WiFi connections across apt upgrades.
  413. // Installed by SpoolBuddy.
  414. DPkg::Pre-Invoke {"/usr/local/sbin/preserve-wifi backup";};
  415. DPkg::Post-Invoke {"/usr/local/sbin/preserve-wifi restore";};
  416. APTEOF
  417. success "WiFi safeguard installed (${hook_file})"
  418. }
  419. upgrade_system_packages() {
  420. run_with_progress "Upgrading system packages" apt-get upgrade -y
  421. }
  422. # ─────────────────────────────────────────────────────────────────────────────
  423. # SpoolBuddy Installation
  424. # ─────────────────────────────────────────────────────────────────────────────
  425. create_spoolbuddy_user() {
  426. if id "$SPOOLBUDDY_SERVICE_USER" &>/dev/null; then
  427. info "User '$SPOOLBUDDY_SERVICE_USER' already exists"
  428. # Ensure existing installs get a real shell for SSH access
  429. usermod --shell /bin/bash "$SPOOLBUDDY_SERVICE_USER" 2>/dev/null || true
  430. else
  431. info "Creating service user '$SPOOLBUDDY_SERVICE_USER'..."
  432. useradd --system --shell /bin/bash --home-dir "$INSTALL_PATH" "$SPOOLBUDDY_SERVICE_USER"
  433. success "Service user created"
  434. fi
  435. # Add to hardware access groups (gpio, spi, i2c, video for backlight)
  436. for group in gpio spi i2c video; do
  437. if getent group "$group" &>/dev/null; then
  438. usermod -aG "$group" "$SPOOLBUDDY_SERVICE_USER" 2>/dev/null || true
  439. fi
  440. done
  441. success "User added to gpio, spi, i2c, video groups"
  442. # Allow passwordless restart of daemon + kiosk (needed for SSH-based updates from Bambuddy)
  443. cat > /etc/sudoers.d/spoolbuddy << 'SUDOERS'
  444. spoolbuddy ALL=(root) NOPASSWD: /usr/bin/systemctl restart spoolbuddy.service
  445. spoolbuddy ALL=(root) NOPASSWD: /usr/bin/systemctl restart getty@tty1.service
  446. spoolbuddy ALL=(root) NOPASSWD: /usr/bin/find /home -maxdepth 5 *
  447. spoolbuddy ALL=(root) NOPASSWD: /sbin/reboot
  448. spoolbuddy ALL=(root) NOPASSWD: /sbin/shutdown -h now
  449. SUDOERS
  450. chmod 440 /etc/sudoers.d/spoolbuddy
  451. success "Sudoers entries created for service, kiosk restart, reboot and shutdown"
  452. }
  453. download_spoolbuddy() {
  454. if [[ -d "$INSTALL_PATH/.git" ]]; then
  455. info "Existing installation found, updating..."
  456. # Pre-emptively normalise tree ownership before git touches anything.
  457. # Stray root-owned files (e.g. static/assets/* left by an earlier
  458. # `sudo` run, or a frontend build that wrote as root) make
  459. # `git reset --hard` fail with EACCES on the unlink/replace step,
  460. # aborting the update before the post-install chown below ever
  461. # runs. The script already runs as root (see check_root), so this
  462. # is always safe.
  463. chown -R "$SPOOLBUDDY_SERVICE_USER:$SPOOLBUDDY_SERVICE_USER" "$INSTALL_PATH"
  464. git config --global --add safe.directory "$INSTALL_PATH" 2>/dev/null || true
  465. cd "$INSTALL_PATH"
  466. git remote set-url origin "$INSTALL_REPO" 2>/dev/null || true
  467. run_with_progress "Fetching updates" git fetch origin
  468. resolve_install_ref "$INSTALL_REF"
  469. else
  470. mkdir -p "$INSTALL_PATH"
  471. run_with_progress "Cloning repository" git clone "$INSTALL_REPO" "$INSTALL_PATH"
  472. cd "$INSTALL_PATH"
  473. resolve_install_ref "$INSTALL_REF"
  474. fi
  475. chown -R "$SPOOLBUDDY_SERVICE_USER:$SPOOLBUDDY_SERVICE_USER" "$INSTALL_PATH"
  476. }
  477. setup_spoolbuddy_venv() {
  478. cd "$INSTALL_PATH/spoolbuddy"
  479. run_with_progress "Creating SpoolBuddy venv" $PYTHON_CMD -m venv --system-site-packages venv
  480. run_with_progress "Upgrading pip" "$INSTALL_PATH/spoolbuddy/venv/bin/pip" install --upgrade pip
  481. run_with_progress "Installing SpoolBuddy packages" "$INSTALL_PATH/spoolbuddy/venv/bin/pip" install $SPOOLBUDDY_PIP_PACKAGES
  482. chown -R "$SPOOLBUDDY_SERVICE_USER:$SPOOLBUDDY_SERVICE_USER" "$INSTALL_PATH/spoolbuddy/venv"
  483. }
  484. create_spoolbuddy_env() {
  485. info "Creating SpoolBuddy configuration..."
  486. local env_file="$INSTALL_PATH/spoolbuddy/.env"
  487. cat > "$env_file" << EOF
  488. # SpoolBuddy Configuration
  489. # Generated by install.sh on $(date)
  490. # Bambuddy backend URL
  491. SPOOLBUDDY_BACKEND_URL=$BAMBUDDY_URL
  492. # API key (create one in Bambuddy Settings -> API Keys)
  493. SPOOLBUDDY_API_KEY=$API_KEY
  494. # NAU7802 scale bus (RPi GPIO2/GPIO3)
  495. SPOOLBUDDY_I2C_BUS=1
  496. EOF
  497. chown "$SPOOLBUDDY_SERVICE_USER:$SPOOLBUDDY_SERVICE_USER" "$env_file"
  498. # Keep secrets owner-writable while allowing kiosk user (in spoolbuddy group)
  499. # to read backend URL/API key for dynamic launcher URL resolution.
  500. chgrp "$SPOOLBUDDY_SERVICE_USER" "$env_file"
  501. chmod 640 "$env_file"
  502. success "Configuration saved to $env_file"
  503. }
  504. ensure_kiosk_env_access() {
  505. local env_file="$INSTALL_PATH/spoolbuddy/.env"
  506. if [[ ! -f "$env_file" ]]; then
  507. warn "SpoolBuddy env file not found at $env_file"
  508. return
  509. fi
  510. # Ensure kiosk user is known even when this function is called outside setup_kiosk.
  511. if [[ -z "$KIOSK_USER" ]]; then
  512. KIOSK_USER="${SUDO_USER:-$(logname 2>/dev/null || echo pi)}"
  513. fi
  514. if id "$KIOSK_USER" &>/dev/null; then
  515. usermod -aG "$SPOOLBUDDY_SERVICE_USER" "$KIOSK_USER" 2>/dev/null || true
  516. fi
  517. chgrp "$SPOOLBUDDY_SERVICE_USER" "$env_file"
  518. chmod 640 "$env_file"
  519. if ! su -s /bin/sh -c "test -r '$env_file'" "$KIOSK_USER"; then
  520. error "Kiosk user '$KIOSK_USER' cannot read $env_file (required for dynamic kiosk URL). Check groups/permissions."
  521. fi
  522. success "Verified kiosk user '$KIOSK_USER' can read SpoolBuddy env"
  523. }
  524. setup_ssh_key() {
  525. info "Setting up SSH access for Bambuddy remote updates..."
  526. local ssh_dir="$INSTALL_PATH/.ssh"
  527. local auth_keys="$ssh_dir/authorized_keys"
  528. mkdir -p "$ssh_dir"
  529. chmod 700 "$ssh_dir"
  530. if [[ -n "$SSH_PUBKEY" ]]; then
  531. # Manual key provided via --ssh-pubkey flag
  532. if [[ -f "$auth_keys" ]] && grep -qF "$SSH_PUBKEY" "$auth_keys" 2>/dev/null; then
  533. info "SSH key already present in authorized_keys"
  534. else
  535. echo "$SSH_PUBKEY" >> "$auth_keys"
  536. success "SSH public key added"
  537. fi
  538. else
  539. # No manual key — the daemon will auto-deploy it on first registration
  540. info "SSH key will be deployed automatically when the daemon connects to Bambuddy"
  541. touch "$auth_keys"
  542. fi
  543. chmod 600 "$auth_keys"
  544. chown -R "$SPOOLBUDDY_SERVICE_USER:$SPOOLBUDDY_SERVICE_USER" "$ssh_dir"
  545. }
  546. create_spoolbuddy_service() {
  547. info "Creating SpoolBuddy systemd service..."
  548. local after_line="After=network-online.target"
  549. if [[ "$INSTALL_MODE" == "full" ]]; then
  550. after_line="After=network-online.target bambuddy.service"
  551. fi
  552. cat > /etc/systemd/system/spoolbuddy.service << EOF
  553. [Unit]
  554. Description=SpoolBuddy - NFC Spool Management Daemon
  555. Documentation=https://github.com/maziggy/bambuddy
  556. $after_line
  557. Wants=network-online.target
  558. [Service]
  559. Type=simple
  560. User=$SPOOLBUDDY_SERVICE_USER
  561. WorkingDirectory=$INSTALL_PATH/spoolbuddy
  562. EnvironmentFile=$INSTALL_PATH/spoolbuddy/.env
  563. ExecStart=$INSTALL_PATH/spoolbuddy/venv/bin/python -m daemon.main
  564. Restart=always
  565. RestartSec=5
  566. StandardOutput=journal
  567. StandardError=journal
  568. [Install]
  569. WantedBy=multi-user.target
  570. EOF
  571. systemctl daemon-reload
  572. systemctl enable spoolbuddy.service
  573. success "SpoolBuddy service created and enabled"
  574. }
  575. # ─────────────────────────────────────────────────────────────────────────────
  576. # Bambuddy Installation (full mode only)
  577. # ─────────────────────────────────────────────────────────────────────────────
  578. create_bambuddy_user() {
  579. if id "$BAMBUDDY_SERVICE_USER" &>/dev/null; then
  580. info "User '$BAMBUDDY_SERVICE_USER' already exists"
  581. return
  582. fi
  583. info "Creating service user '$BAMBUDDY_SERVICE_USER'..."
  584. useradd --system --shell /usr/sbin/nologin --home-dir "$INSTALL_PATH" "$BAMBUDDY_SERVICE_USER"
  585. success "Service user created"
  586. }
  587. setup_bambuddy_venv() {
  588. cd "$INSTALL_PATH"
  589. run_with_progress "Creating Bambuddy venv" $PYTHON_CMD -m venv venv
  590. run_with_progress "Upgrading pip" "$INSTALL_PATH/venv/bin/pip" install --upgrade pip
  591. run_with_progress "Installing Bambuddy dependencies" "$INSTALL_PATH/venv/bin/pip" install -r requirements.txt
  592. chown -R "$BAMBUDDY_SERVICE_USER:$BAMBUDDY_SERVICE_USER" "$INSTALL_PATH/venv"
  593. }
  594. install_nodejs() {
  595. if command -v node &>/dev/null; then
  596. local version
  597. version=$(node --version 2>/dev/null | sed 's/^v//')
  598. local major
  599. major=$(echo "$version" | cut -d'.' -f1)
  600. if [[ "$major" -ge 20 ]]; then
  601. success "Found Node.js v$version"
  602. return
  603. fi
  604. fi
  605. apt-get remove -y nodejs npm > /dev/null 2>&1 || true
  606. run_with_progress "Setting up Node.js repository" bash -c "curl -fsSL https://deb.nodesource.com/setup_22.x | bash -"
  607. run_with_progress "Installing Node.js" apt-get install -y nodejs
  608. hash -r 2>/dev/null || true
  609. success "Node.js installed: $(node --version)"
  610. }
  611. build_frontend() {
  612. cd "$INSTALL_PATH/frontend"
  613. run_with_progress "Installing frontend dependencies" npm ci
  614. run_with_progress "Building frontend" npm run build
  615. }
  616. create_bambuddy_env() {
  617. info "Creating Bambuddy configuration..."
  618. local env_file="$INSTALL_PATH/.env"
  619. cat > "$env_file" << EOF
  620. # Bambuddy Configuration
  621. # Generated by install.sh on $(date)
  622. DEBUG=false
  623. LOG_LEVEL=INFO
  624. LOG_TO_FILE=true
  625. EOF
  626. chown "$BAMBUDDY_SERVICE_USER:$BAMBUDDY_SERVICE_USER" "$env_file"
  627. chmod 600 "$env_file"
  628. success "Configuration saved to $env_file"
  629. }
  630. create_bambuddy_directories() {
  631. mkdir -p "$INSTALL_PATH/data" "$INSTALL_PATH/logs"
  632. chown -R "$BAMBUDDY_SERVICE_USER:$BAMBUDDY_SERVICE_USER" "$INSTALL_PATH/data" "$INSTALL_PATH/logs"
  633. success "Data directories created"
  634. }
  635. create_bambuddy_service() {
  636. info "Creating Bambuddy systemd service..."
  637. cat > /etc/systemd/system/bambuddy.service << EOF
  638. [Unit]
  639. Description=Bambuddy - Bambu Lab Print Management
  640. Documentation=https://github.com/maziggy/bambuddy
  641. After=network.target
  642. [Service]
  643. Type=simple
  644. User=$BAMBUDDY_SERVICE_USER
  645. Group=$BAMBUDDY_SERVICE_USER
  646. WorkingDirectory=$INSTALL_PATH
  647. EnvironmentFile=$INSTALL_PATH/.env
  648. Environment="DATA_DIR=$INSTALL_PATH/data"
  649. Environment="LOG_DIR=$INSTALL_PATH/logs"
  650. # --loop asyncio required: uvloop can truncate VP FTP uploads (#1896)
  651. ExecStart=$INSTALL_PATH/venv/bin/uvicorn backend.app.main:app --host 0.0.0.0 --port $BAMBUDDY_PORT --loop asyncio
  652. Restart=on-failure
  653. RestartSec=5
  654. StandardOutput=journal
  655. StandardError=journal
  656. NoNewPrivileges=true
  657. PrivateTmp=true
  658. ProtectSystem=strict
  659. ProtectHome=true
  660. ReadWritePaths=$INSTALL_PATH/data $INSTALL_PATH/logs $INSTALL_PATH
  661. [Install]
  662. WantedBy=multi-user.target
  663. EOF
  664. systemctl daemon-reload
  665. systemctl enable bambuddy.service
  666. success "Bambuddy service created and enabled"
  667. }
  668. bootstrap_spoolbuddy_kiosk_key() {
  669. # Provision an API key for the local SpoolBuddy kiosk and write it into
  670. # spoolbuddy/.env. Runs against the Bambuddy DB directly (via the CLI),
  671. # so the bambuddy service does not need to be running yet.
  672. info "Provisioning SpoolBuddy kiosk API key..."
  673. local env_file="$INSTALL_PATH/spoolbuddy/.env"
  674. if [[ ! -f "$env_file" ]]; then
  675. warn "SpoolBuddy env file not found at $env_file — skipping kiosk key bootstrap"
  676. return
  677. fi
  678. # CWD must be $INSTALL_PATH so `python -m backend.app.cli` finds the backend
  679. # package on sys.path (matches the systemd unit's WorkingDirectory).
  680. local kiosk_key
  681. if ! kiosk_key="$(cd "$INSTALL_PATH" && sudo -u "$BAMBUDDY_SERVICE_USER" \
  682. env DATA_DIR="$INSTALL_PATH/data" LOG_DIR="$INSTALL_PATH/logs" \
  683. "$INSTALL_PATH/venv/bin/python" -m backend.app.cli kiosk-bootstrap --force)"; then
  684. error "Failed to bootstrap SpoolBuddy kiosk API key"
  685. fi
  686. if [[ -z "$kiosk_key" || "$kiosk_key" != bb_* ]]; then
  687. error "CLI returned an invalid API key (got: ${kiosk_key:0:8}...)"
  688. fi
  689. if ! grep -q '^SPOOLBUDDY_API_KEY=' "$env_file"; then
  690. error "Sentinel 'SPOOLBUDDY_API_KEY=' line missing in $env_file"
  691. fi
  692. # Escape for sed replacement (the key is base64url-safe, no slashes, but be defensive)
  693. local escaped_key
  694. escaped_key=$(printf '%s\n' "$kiosk_key" | sed -e 's/[\/&]/\\&/g')
  695. sed -i "s/^SPOOLBUDDY_API_KEY=.*/SPOOLBUDDY_API_KEY=${escaped_key}/" "$env_file"
  696. success "SpoolBuddy kiosk API key provisioned"
  697. }
  698. # ─────────────────────────────────────────────────────────────────────────────
  699. # System Strip-Down (dedicated appliance — remove unnecessary services/packages)
  700. # ─────────────────────────────────────────────────────────────────────────────
  701. strip_services() {
  702. info "Disabling unnecessary services..."
  703. local services=(
  704. bluetooth.service
  705. lightdm.service
  706. cloud-init-local.service
  707. cloud-init.service
  708. cloud-init-network.service
  709. cloud-config.service
  710. cloud-final.service
  711. cloud-init-hotplugd.socket
  712. avahi-daemon.service
  713. avahi-daemon.socket
  714. ModemManager.service
  715. udisks2.service
  716. apparmor.service
  717. man-db.timer
  718. e2scrub_all.timer
  719. e2scrub_reap.service
  720. # Audio stack (no speakers on a spool reader)
  721. pipewire.service
  722. pipewire.socket
  723. pipewire-pulse.service
  724. pipewire-pulse.socket
  725. wireplumber.service
  726. # Printing
  727. cups.service
  728. cups.socket
  729. cups-browsed.service
  730. # Desktop services
  731. accounts-daemon.service
  732. upower.service
  733. polkit.service
  734. # Flatpak portals (not using Flatpak)
  735. xdg-desktop-portal.service
  736. xdg-desktop-portal-gtk.service
  737. xdg-document-portal.service
  738. xdg-permission-store.service
  739. # NFS/RPC (unnecessary + security surface)
  740. rpcbind.service
  741. rpcbind.socket
  742. # Bluetooth media proxy
  743. mpris-proxy.service
  744. )
  745. local disabled=0
  746. for svc in "${services[@]}"; do
  747. if systemctl is-enabled "$svc" &>/dev/null; then
  748. systemctl disable "$svc" 2>/dev/null || true
  749. systemctl mask "$svc" 2>/dev/null || true
  750. (( ++disabled ))
  751. fi
  752. done
  753. if (( disabled > 0 )); then
  754. success "Disabled $disabled unnecessary services"
  755. else
  756. success "No unnecessary services to disable"
  757. fi
  758. # Mask user-level services globally via /etc/systemd/user/ overrides.
  759. # The su-based approach doesn't reliably reach the user's systemd instance
  760. # when run from sudo, so we create global masks that apply before login.
  761. local user_services=(
  762. pipewire.service
  763. pipewire.socket
  764. pipewire-pulse.service
  765. pipewire-pulse.socket
  766. wireplumber.service
  767. xdg-desktop-portal.service
  768. xdg-desktop-portal-gtk.service
  769. xdg-document-portal.service
  770. xdg-permission-store.service
  771. mpris-proxy.service
  772. )
  773. mkdir -p /etc/systemd/user
  774. local user_masked=0
  775. for svc in "${user_services[@]}"; do
  776. if [[ ! -L "/etc/systemd/user/$svc" ]] || [[ "$(readlink /etc/systemd/user/$svc)" != "/dev/null" ]]; then
  777. ln -sf /dev/null "/etc/systemd/user/$svc"
  778. (( ++user_masked ))
  779. fi
  780. done
  781. if (( user_masked > 0 )); then
  782. success "Masked $user_masked unnecessary user services globally"
  783. fi
  784. }
  785. strip_packages() {
  786. info "Removing unnecessary packages..."
  787. local packages=(
  788. mkvtoolnix
  789. firmware-atheros
  790. firmware-mediatek
  791. cloud-init
  792. rpi-cloud-init-mods
  793. rpi-connect-lite
  794. avahi-daemon
  795. modemmanager
  796. udisks2
  797. pipewire
  798. pipewire-pulse
  799. wireplumber
  800. cups
  801. cups-browsed
  802. cups-common
  803. cups-client
  804. rpcbind
  805. )
  806. local to_remove=()
  807. for pkg in "${packages[@]}"; do
  808. if dpkg -l "$pkg" &>/dev/null 2>&1; then
  809. to_remove+=("$pkg")
  810. fi
  811. done
  812. if (( ${#to_remove[@]} > 0 )); then
  813. run_with_progress "Removing ${#to_remove[@]} packages" apt-get remove --purge -y "${to_remove[@]}"
  814. run_with_progress "Cleaning up dependencies" apt-get autoremove --purge -y
  815. else
  816. success "No unnecessary packages to remove"
  817. fi
  818. }
  819. # ─────────────────────────────────────────────────────────────────────────────
  820. # Kiosk Setup (labwc + Chromium + Plymouth splash)
  821. # ─────────────────────────────────────────────────────────────────────────────
  822. setup_kiosk() {
  823. info "Setting up touchscreen kiosk..."
  824. # Detect kiosk user (the human user who ran sudo)
  825. KIOSK_USER="${SUDO_USER:-$(logname 2>/dev/null || echo pi)}"
  826. KIOSK_URL="${BAMBUDDY_URL}/spoolbuddy?token=${API_KEY}"
  827. local KIOSK_HOME
  828. KIOSK_HOME=$(eval echo "~$KIOSK_USER")
  829. info "Kiosk user: $KIOSK_USER (home: $KIOSK_HOME)"
  830. info "Kiosk URL: $KIOSK_URL"
  831. # Allow kiosk user to read SpoolBuddy env so launcher can resolve backend URL
  832. # and API key dynamically instead of using stale install-time fallback values.
  833. local spoolbuddy_env="$INSTALL_PATH/spoolbuddy/.env"
  834. if [[ -f "$spoolbuddy_env" ]]; then
  835. usermod -aG "$SPOOLBUDDY_SERVICE_USER" "$KIOSK_USER" 2>/dev/null || true
  836. chgrp "$SPOOLBUDDY_SERVICE_USER" "$spoolbuddy_env" 2>/dev/null || true
  837. chmod 640 "$spoolbuddy_env" 2>/dev/null || true
  838. fi
  839. # ── Install kiosk packages ────────────────────────────────────────────
  840. # Temporarily block initramfs rebuilds during package install — we rebuild
  841. # once at the end after the Plymouth theme is configured, saving ~4 runs
  842. # (one per installed kernel per hook trigger).
  843. if [[ -x /usr/sbin/update-initramfs ]]; then
  844. dpkg-divert --local --rename --add /usr/sbin/update-initramfs >/dev/null 2>&1 || true
  845. ln -sf /bin/true /usr/sbin/update-initramfs
  846. fi
  847. run_with_progress "Installing kiosk packages" apt-get install -y labwc chromium plymouth wlr-randr swayidle wlopm jq curl
  848. # Restore real update-initramfs
  849. if dpkg-divert --list /usr/sbin/update-initramfs 2>/dev/null | grep -q local; then
  850. rm -f /usr/sbin/update-initramfs
  851. dpkg-divert --local --rename --remove /usr/sbin/update-initramfs >/dev/null 2>&1 || true
  852. fi
  853. # ── config.txt tweaks ─────────────────────────────────────────────────
  854. local boot_config="/boot/firmware/config.txt"
  855. if [[ ! -f "$boot_config" ]]; then
  856. boot_config="/boot/config.txt"
  857. fi
  858. if [[ -f "$boot_config" ]]; then
  859. info "Configuring $boot_config for kiosk..."
  860. # Disable audio (change existing on→off)
  861. sed -i 's/^dtparam=audio=on/dtparam=audio=off/' "$boot_config"
  862. # Disable camera auto-detect (change existing 1→0)
  863. sed -i 's/^camera_auto_detect=1/camera_auto_detect=0/' "$boot_config"
  864. # Append if missing: gpu_mem=32
  865. if ! grep -q "^gpu_mem=" "$boot_config"; then
  866. echo "" >> "$boot_config"
  867. echo "# Kiosk: Minimal GPU firmware memory (KMS uses CMA from system RAM)" >> "$boot_config"
  868. echo "gpu_mem=32" >> "$boot_config"
  869. fi
  870. # Append if missing: dtoverlay=disable-bt
  871. if ! grep -q "^dtoverlay=disable-bt" "$boot_config"; then
  872. echo "" >> "$boot_config"
  873. echo "# Kiosk: Disable Bluetooth hardware" >> "$boot_config"
  874. echo "dtoverlay=disable-bt" >> "$boot_config"
  875. fi
  876. # Append if missing: disable_splash=1
  877. if ! grep -q "^disable_splash=" "$boot_config"; then
  878. echo "" >> "$boot_config"
  879. echo "# Kiosk: Disable Raspberry Pi firmware splash, use custom splash.png" >> "$boot_config"
  880. echo "disable_splash=1" >> "$boot_config"
  881. fi
  882. success "Boot config updated"
  883. fi
  884. # ── cmdline.txt tweaks ────────────────────────────────────────────────
  885. local cmdline="/boot/firmware/cmdline.txt"
  886. if [[ ! -f "$cmdline" ]]; then
  887. cmdline="/boot/cmdline.txt"
  888. fi
  889. if [[ -f "$cmdline" ]]; then
  890. info "Configuring $cmdline for kiosk..."
  891. # Remove serial console (Plymouth needs tty-only console)
  892. sed -i 's/console=serial0,[0-9]* //' "$cmdline"
  893. # Disable console blanking (kernel default is 600s, can blank during boot transition)
  894. grep -q "consoleblank=" "$cmdline" || sed -i 's/$/ consoleblank=0/' "$cmdline"
  895. # Add splash quiet loglevel=3 logo.nologo if missing
  896. grep -q "splash" "$cmdline" || sed -i 's/$/ splash quiet loglevel=3 logo.nologo/' "$cmdline"
  897. # Add video mode if missing
  898. grep -q "video=HDMI-A-1" "$cmdline" || sed -i 's/$/ video=HDMI-A-1:1024x600@60/' "$cmdline"
  899. success "Kernel cmdline updated"
  900. fi
  901. # ── Plymouth splash theme ─────────────────────────────────────────────
  902. info "Installing Plymouth boot splash..."
  903. local theme_dir="/usr/share/plymouth/themes/spoolbuddy"
  904. mkdir -p "$theme_dir"
  905. # Copy bundled splash image from the install directory
  906. local script_dir
  907. script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
  908. if [[ -f "$script_dir/splash.png" ]]; then
  909. cp "$script_dir/splash.png" "$theme_dir/splash.png"
  910. elif [[ -f "$INSTALL_PATH/spoolbuddy/install/splash.png" ]]; then
  911. cp "$INSTALL_PATH/spoolbuddy/install/splash.png" "$theme_dir/splash.png"
  912. else
  913. warn "splash.png not found — Plymouth splash will not display an image"
  914. fi
  915. # Write .plymouth theme file
  916. cat > "$theme_dir/spoolbuddy.plymouth" << 'EOF'
  917. [Plymouth Theme]
  918. Name=SpoolBuddy
  919. Description=SpoolBuddy boot splash
  920. ModuleName=script
  921. [script]
  922. ImageDir=/usr/share/plymouth/themes/spoolbuddy
  923. ScriptFile=/usr/share/plymouth/themes/spoolbuddy/spoolbuddy.script
  924. EOF
  925. # Write .script theme file
  926. cat > "$theme_dir/spoolbuddy.script" << 'EOF'
  927. wallpaper_image = Image("splash.png");
  928. screen_width = Window.GetWidth();
  929. screen_height = Window.GetHeight();
  930. resized_wallpaper_image = wallpaper_image.Scale(screen_width, screen_height);
  931. wallpaper_sprite = Sprite(resized_wallpaper_image);
  932. wallpaper_sprite.SetZ(-100);
  933. EOF
  934. plymouth-set-default-theme spoolbuddy
  935. run_with_progress "Updating initramfs" update-initramfs -u
  936. success "Plymouth splash installed"
  937. # ── Auto-login on tty1 ────────────────────────────────────────────────
  938. info "Configuring auto-login for $KIOSK_USER..."
  939. mkdir -p /etc/systemd/system/getty@tty1.service.d
  940. cat > /etc/systemd/system/getty@tty1.service.d/autologin.conf << EOF
  941. [Unit]
  942. After=network-online.target
  943. Wants=network-online.target
  944. [Service]
  945. ExecStart=
  946. ExecStart=-/sbin/agetty --autologin $KIOSK_USER --noclear %I \$TERM
  947. EOF
  948. success "Auto-login configured"
  949. # ── labwc rc.xml (no decorations, no keybinds) ────────────────────────
  950. info "Configuring labwc window manager..."
  951. local labwc_dir="$KIOSK_HOME/.config/labwc"
  952. mkdir -p "$labwc_dir"
  953. cat > "$labwc_dir/rc.xml" << 'EOF'
  954. <?xml version="1.0"?>
  955. <labwc_config>
  956. <!-- Disable screen blanking — kiosk must stay on -->
  957. <core>
  958. <screenBlankTimeout>0</screenBlankTimeout>
  959. </core>
  960. <theme>
  961. <name></name>
  962. <cornerRadius>0</cornerRadius>
  963. </theme>
  964. <!-- Disable all keybindings - kiosk lockdown -->
  965. <keyboard>
  966. </keyboard>
  967. <!-- Disable right-click menu -->
  968. <mouse>
  969. <default />
  970. </mouse>
  971. <!-- Remove window decorations, maximize Chromium, prevent unfullscreen -->
  972. <windowRules>
  973. <windowRule identifier="*">
  974. <serverDecoration>no</serverDecoration>
  975. </windowRule>
  976. <windowRule identifier="chromium">
  977. <skipTaskbar>yes</skipTaskbar>
  978. <fixedPosition>yes</fixedPosition>
  979. </windowRule>
  980. </windowRules>
  981. </labwc_config>
  982. EOF
  983. # ── Override Debian/RPi Chromium defaults for kiosk performance ──────
  984. cat > /etc/chromium.d/spoolbuddy-kiosk << 'CHROMIUM_EOF'
  985. # SpoolBuddy kiosk: add kiosk-specific flags on top of Pi defaults.
  986. # Preserves Pi GPU settings (gpu-rasterization, ANGLE/GLES) for stability.
  987. CHROMIUM_FLAGS="$CHROMIUM_FLAGS --disable-smooth-scrolling"
  988. CHROMIUM_FLAGS="$CHROMIUM_FLAGS --disable-extensions"
  989. CHROMIUM_FLAGS="$CHROMIUM_FLAGS --disable-background-timer-throttling"
  990. CHROMIUM_FLAGS="$CHROMIUM_FLAGS --disable-renderer-backgrounding"
  991. CHROMIUM_FLAGS="$CHROMIUM_FLAGS --disable-crash-reporter"
  992. CHROMIUM_EOF
  993. success "Chromium kiosk performance flags installed"
  994. # ── kiosk launcher (dynamic URL from spoolbuddy/.env) ─────────────────
  995. local kiosk_launcher="/usr/local/bin/spoolbuddy-kiosk-launch"
  996. cat > "$kiosk_launcher" << EOF
  997. #!/usr/bin/env bash
  998. set -euo pipefail
  999. ENV_FILE="$INSTALL_PATH/spoolbuddy/.env"
  1000. FALLBACK_URL="$KIOSK_URL"
  1001. backend_url=""
  1002. api_key=""
  1003. if [[ -r "\$ENV_FILE" ]]; then
  1004. backend_url="\$(sed -n 's/^SPOOLBUDDY_BACKEND_URL=//p' "\$ENV_FILE" | tail -n1 | tr -d '\r')"
  1005. api_key="\$(sed -n 's/^SPOOLBUDDY_API_KEY=//p' "\$ENV_FILE" | tail -n1 | tr -d '\r')"
  1006. backend_url="\${backend_url%\"}"
  1007. backend_url="\${backend_url#\"}"
  1008. api_key="\${api_key%\"}"
  1009. api_key="\${api_key#\"}"
  1010. elif [[ -f "\$ENV_FILE" ]]; then
  1011. echo "spoolbuddy-kiosk-launch: ERROR: \$ENV_FILE exists but is not readable" >&2
  1012. echo "spoolbuddy-kiosk-launch: Fix permissions (group-readable by kiosk user) and restart kiosk" >&2
  1013. exit 1
  1014. fi
  1015. if [[ -n "\$backend_url" && -n "\$api_key" ]]; then
  1016. backend_url="\${backend_url%/}"
  1017. kiosk_url="\${backend_url}/spoolbuddy?token=\${api_key}"
  1018. else
  1019. kiosk_url="\$FALLBACK_URL"
  1020. fi
  1021. # Wait for the Bambuddy backend to be reachable before launching Chromium.
  1022. # Without this the browser opens before uvicorn has bound to the port on a
  1023. # cold boot and the user sees an ERR_CONNECTION_REFUSED splash until they
  1024. # manually reload. Probe /health (no auth, no body) with a short timeout.
  1025. probe_url="\${backend_url:-http://localhost}/health"
  1026. for _i in \$(seq 1 60); do
  1027. if curl -sf --max-time 2 "\$probe_url" >/dev/null 2>&1; then
  1028. break
  1029. fi
  1030. sleep 1
  1031. done
  1032. # Ephemeral user-data-dir under /tmp + wipe on every launch.
  1033. #
  1034. # The kiosk has no per-user state worth persisting (the auth token is in
  1035. # the URL query, not a stored cookie), but the default profile at
  1036. # ~/.config/chromium was accumulating two specific kinds of state across
  1037. # reboots that broke deploys badly:
  1038. #
  1039. # 1. HTTP disk cache holding old index.html across browser restarts.
  1040. # Chromium's heuristic-cache freshness window kept the old HTML
  1041. # "fresh" for days, which referenced an old content-hashed bundle,
  1042. # so newly deployed code never reached the running tab even after
  1043. # pkill+relaunch. Reproduced in the wild during the #1133 rollout
  1044. # — the kiosk kept showing the pre-fix picker for hours after every
  1045. # cache-clear attempt because the persistent profile would re-seed
  1046. # the cache from disk on next start.
  1047. # 2. A stuck Service Worker registration, which intercepted requests
  1048. # with its own cache layer (CacheStorage), independent of the HTTP
  1049. # cache. Even after \`rm -rf Default/Cache/*\` the SW could replay
  1050. # stale responses from CacheStorage until explicitly unregistered.
  1051. #
  1052. # Wiping the user-data-dir on every launch is the simplest, most
  1053. # bulletproof escape hatch — every kiosk restart is now functionally
  1054. # equivalent to a private-window first-load. Future deploys propagate
  1055. # automatically: the next chromium launch picks up the latest bundle
  1056. # without any extra tooling. Trade-off is a slightly slower first paint
  1057. # (no warm cache) and zero offline support, neither of which matter for
  1058. # a single-purpose kiosk facing a backend on the same LAN.
  1059. USER_DATA_DIR="/tmp/spoolbuddy-kiosk-userdata"
  1060. rm -rf "\$USER_DATA_DIR"
  1061. exec chromium --kiosk --no-first-run --disable-infobars \
  1062. --disable-session-crashed-bubble --disable-features=TranslateUI \
  1063. --noerrdialogs --disable-component-update \
  1064. --overscroll-history-navigation=0 \
  1065. --ozone-platform=wayland \
  1066. --disable-crash-reporter --disable-breakpad \
  1067. --user-data-dir="\$USER_DATA_DIR" \
  1068. "\$kiosk_url"
  1069. EOF
  1070. chmod 755 "$kiosk_launcher"
  1071. # Tiny self-check: ensure sed command substitutions were not expanded
  1072. # while generating the launcher script.
  1073. if ! grep -Fq 'backend_url="$(sed -n' "$kiosk_launcher" || ! grep -Fq 'api_key="$(sed -n' "$kiosk_launcher"; then
  1074. error "Kiosk launcher generation failed: dynamic env parsing commands were expanded unexpectedly"
  1075. fi
  1076. # ── labwc autostart ───────────────────────────────────────────────────
  1077. cat > "$labwc_dir/autostart" << EOF
  1078. # Force 1024x600 (panel doesn't advertise this natively)
  1079. wlr-randr --output HDMI-A-1 --custom-mode 1024x600@60 &
  1080. # Idle watchdog: powers off HDMI via wlopm after the configured inactivity
  1081. # timeout (SpoolBuddy Settings → Display → Screen blank timeout). Reads the
  1082. # current value from the backend on startup; UI changes take effect on the
  1083. # next reboot / kiosk restart.
  1084. $INSTALL_PATH/spoolbuddy/install/spoolbuddy-idle.sh &
  1085. # Launch Chromium via helper that resolves URL from spoolbuddy/.env
  1086. $kiosk_launcher &
  1087. EOF
  1088. chown -R "$KIOSK_USER:$KIOSK_USER" "$labwc_dir"
  1089. # ── .bash_profile (source .bashrc, exec labwc on tty1) ────────────────
  1090. cat > "$KIOSK_HOME/.bash_profile" << 'EOF'
  1091. # Source .bashrc if it exists
  1092. if [ -f ~/.bashrc ]; then
  1093. . ~/.bashrc
  1094. fi
  1095. # Auto-start kiosk on tty1
  1096. if [ "$(tty)" = "/dev/tty1" ]; then
  1097. exec labwc
  1098. fi
  1099. EOF
  1100. chown "$KIOSK_USER:$KIOSK_USER" "$KIOSK_HOME/.bash_profile"
  1101. REBOOT_NEEDED="true"
  1102. success "Kiosk setup complete"
  1103. }
  1104. # ─────────────────────────────────────────────────────────────────────────────
  1105. # User Prompts
  1106. # ─────────────────────────────────────────────────────────────────────────────
  1107. parse_args() {
  1108. while [[ $# -gt 0 ]]; do
  1109. case "$1" in
  1110. --mode)
  1111. INSTALL_MODE="$2"
  1112. shift 2
  1113. ;;
  1114. --repo)
  1115. INSTALL_REPO="$(normalize_github_repo_url "$2")"
  1116. shift 2
  1117. ;;
  1118. --ref)
  1119. INSTALL_REF="$2"
  1120. shift 2
  1121. ;;
  1122. --bambuddy-url)
  1123. BAMBUDDY_URL="$2"
  1124. shift 2
  1125. ;;
  1126. --api-key)
  1127. API_KEY="$2"
  1128. shift 2
  1129. ;;
  1130. --path)
  1131. INSTALL_PATH="$2"
  1132. shift 2
  1133. ;;
  1134. --port)
  1135. BAMBUDDY_PORT="$2"
  1136. shift 2
  1137. ;;
  1138. --ssh-pubkey)
  1139. SSH_PUBKEY="$2"
  1140. shift 2
  1141. ;;
  1142. --yes|-y)
  1143. NON_INTERACTIVE="true"
  1144. shift
  1145. ;;
  1146. --help|-h)
  1147. show_help
  1148. ;;
  1149. *)
  1150. error "Unknown option: $1 (use --help for usage)"
  1151. ;;
  1152. esac
  1153. done
  1154. }
  1155. ask_install_mode() {
  1156. if [[ -n "$INSTALL_MODE" ]]; then
  1157. return
  1158. fi
  1159. echo ""
  1160. echo -e "${BOLD}How would you like to set up SpoolBuddy?${NC}"
  1161. echo ""
  1162. echo -e " ${CYAN}1)${NC} SpoolBuddy only"
  1163. echo " NFC reader + scale on this RPi, Bambuddy runs on another device"
  1164. echo ""
  1165. echo -e " ${CYAN}2)${NC} SpoolBuddy + Bambuddy"
  1166. echo " Both running natively on this Raspberry Pi"
  1167. echo ""
  1168. while true; do
  1169. echo -en "${BOLD}Choose${NC} [${CYAN}1${NC}/${CYAN}2${NC}]: "
  1170. read -r choice
  1171. case "$choice" in
  1172. 1) INSTALL_MODE="spoolbuddy"; return;;
  1173. 2) INSTALL_MODE="full"; return;;
  1174. *) echo "Please enter 1 or 2.";;
  1175. esac
  1176. done
  1177. }
  1178. gather_config() {
  1179. echo ""
  1180. echo -e "${BOLD}Configuration${NC}"
  1181. echo -e "${CYAN}─────────────────────────────────────────${NC}"
  1182. echo ""
  1183. # Set default install path based on mode
  1184. if [[ -z "$INSTALL_PATH" ]]; then
  1185. if [[ "$INSTALL_MODE" == "full" ]]; then
  1186. INSTALL_PATH="/opt/bambuddy"
  1187. else
  1188. INSTALL_PATH="/opt/bambuddy"
  1189. fi
  1190. fi
  1191. prompt "Installation directory" "$INSTALL_PATH" INSTALL_PATH
  1192. if [[ -z "$INSTALL_REPO" ]]; then
  1193. INSTALL_REPO="$GITHUB_REPO"
  1194. fi
  1195. prompt "Git repository URL" "$INSTALL_REPO" INSTALL_REPO
  1196. INSTALL_REPO="$(normalize_github_repo_url "$INSTALL_REPO")"
  1197. if [[ -z "$INSTALL_REF" ]]; then
  1198. INSTALL_REF="main"
  1199. fi
  1200. if [[ "$NON_INTERACTIVE" != "true" && -n "$DETECTED_INSTALLER_REF" && "$DETECTED_INSTALLER_REF" != "HEAD" ]]; then
  1201. echo ""
  1202. echo -e "${BOLD}Install Source Ref${NC}"
  1203. echo "1) main"
  1204. echo "2) $DETECTED_INSTALLER_REF (detected from installer context)"
  1205. echo "3) custom"
  1206. while true; do
  1207. echo -en "${BOLD}Choose${NC} [1/2/3]: "
  1208. read -r ref_choice
  1209. case "$ref_choice" in
  1210. ""|1)
  1211. INSTALL_REF="main"
  1212. break
  1213. ;;
  1214. 2)
  1215. INSTALL_REF="$DETECTED_INSTALLER_REF"
  1216. break
  1217. ;;
  1218. 3)
  1219. prompt "Git ref (branch/tag/commit)" "$INSTALL_REF" INSTALL_REF
  1220. break
  1221. ;;
  1222. *)
  1223. echo "Please enter 1, 2, or 3."
  1224. ;;
  1225. esac
  1226. done
  1227. else
  1228. prompt "Git ref (branch/tag/commit)" "$INSTALL_REF" INSTALL_REF
  1229. fi
  1230. if [[ "$INSTALL_MODE" == "spoolbuddy" ]]; then
  1231. # Need remote Bambuddy URL and API key
  1232. echo ""
  1233. info "SpoolBuddy needs to connect to your Bambuddy server."
  1234. info "You can find/create an API key in Bambuddy under Settings -> API Keys."
  1235. echo ""
  1236. while [[ -z "$BAMBUDDY_URL" ]]; do
  1237. prompt "Bambuddy server URL (e.g. http://192.168.1.100:8000)" "" BAMBUDDY_URL
  1238. if [[ -z "$BAMBUDDY_URL" ]]; then
  1239. warn "Bambuddy URL is required"
  1240. fi
  1241. done
  1242. while [[ -z "$API_KEY" ]]; do
  1243. prompt "Bambuddy API key" "" API_KEY
  1244. if [[ -z "$API_KEY" ]]; then
  1245. warn "API key is required"
  1246. fi
  1247. done
  1248. else
  1249. # Full mode — Bambuddy runs locally
  1250. prompt "Bambuddy port" "$BAMBUDDY_PORT" BAMBUDDY_PORT
  1251. BAMBUDDY_URL="http://localhost:$BAMBUDDY_PORT"
  1252. echo ""
  1253. info "After installation, create an API key in Bambuddy (Settings -> API Keys)"
  1254. info "and update it in: $INSTALL_PATH/spoolbuddy/.env"
  1255. API_KEY="CHANGE_ME_AFTER_SETUP"
  1256. fi
  1257. # Summary
  1258. echo ""
  1259. echo -e "${BOLD}Installation Summary${NC}"
  1260. echo -e "${CYAN}─────────────────────────────────────────${NC}"
  1261. echo -e " Mode: ${GREEN}$([ "$INSTALL_MODE" == "full" ] && echo "Bambuddy + SpoolBuddy" || echo "SpoolBuddy only")${NC}"
  1262. echo -e " Install path: ${GREEN}$INSTALL_PATH${NC}"
  1263. echo -e " Git repo: ${GREEN}$INSTALL_REPO${NC}"
  1264. echo -e " Git ref: ${GREEN}$INSTALL_REF${NC}"
  1265. if [[ "$INSTALL_MODE" == "full" ]]; then
  1266. echo -e " Bambuddy port: ${GREEN}$BAMBUDDY_PORT${NC}"
  1267. echo -e " Bambuddy URL: ${GREEN}$BAMBUDDY_URL${NC}"
  1268. else
  1269. echo -e " Bambuddy URL: ${GREEN}$BAMBUDDY_URL${NC}"
  1270. fi
  1271. echo ""
  1272. if ! prompt_yes_no "Proceed with installation?" "y"; then
  1273. echo "Installation cancelled."
  1274. exit 0
  1275. fi
  1276. }
  1277. # ─────────────────────────────────────────────────────────────────────────────
  1278. # Main
  1279. # ─────────────────────────────────────────────────────────────────────────────
  1280. main() {
  1281. parse_args "$@"
  1282. detect_installer_source_context
  1283. echo ""
  1284. echo -e "${CYAN}╔══════════════════════════════════════════════════════════╗${NC}"
  1285. echo -e "${CYAN}║ ║${NC}"
  1286. echo -e "${CYAN}║ ____ _ ____ _ _ ║${NC}"
  1287. echo -e "${CYAN}║ / ___| _ __ ___ ___ | | __ ) _ _ __| | __| |_ _ ║${NC}"
  1288. echo -e "${CYAN}║ \\___ \\| '_ \\ / _ \\ / _ \\| | _ \\| | | |/ _\` |/ _\` | | | |║${NC}"
  1289. echo -e "${CYAN}║ ___) | |_) | (_) | (_) | | |_) | |_| | (_| | (_| | |_| |║${NC}"
  1290. echo -e "${CYAN}║ |____/| .__/ \\___/ \\___/|_|____/ \\__,_|\\__,_|\\__,_|\\__, |║${NC}"
  1291. echo -e "${CYAN}║ |_| |___/ ║${NC}"
  1292. echo -e "${CYAN}║ ║${NC}"
  1293. echo -e "${CYAN}║ NFC Spool Management for Bambuddy ║${NC}"
  1294. echo -e "${CYAN}║ ║${NC}"
  1295. echo -e "${CYAN}╚══════════════════════════════════════════════════════════╝${NC}"
  1296. echo ""
  1297. # Check if running via pipe without -y
  1298. if [[ ! -t 0 ]] && [[ "$NON_INTERACTIVE" != "true" ]]; then
  1299. error "Interactive mode requires a terminal. Use -y for unattended install, or download and run directly."
  1300. fi
  1301. # Pre-flight checks
  1302. check_root
  1303. check_raspberry_pi
  1304. check_raspberry_pi_os
  1305. if ! detect_python; then
  1306. info "Python 3.10+ not found, will install..."
  1307. fi
  1308. # Gather user preferences
  1309. ask_install_mode
  1310. gather_config
  1311. # Validate mode
  1312. if [[ "$INSTALL_MODE" != "spoolbuddy" && "$INSTALL_MODE" != "full" ]]; then
  1313. error "Invalid mode: $INSTALL_MODE (must be 'spoolbuddy' or 'full')"
  1314. fi
  1315. echo ""
  1316. echo -e "${BOLD}Starting Installation${NC}"
  1317. echo -e "${CYAN}─────────────────────────────────────────${NC}"
  1318. echo ""
  1319. # ── Step 1: Raspberry Pi hardware config ──────────────────────────────
  1320. info "Configuring Raspberry Pi hardware..."
  1321. enable_spi
  1322. enable_i2c
  1323. configure_boot_config
  1324. echo ""
  1325. # ── Step 2: System packages ───────────────────────────────────────────
  1326. install_system_packages
  1327. install_wifi_safeguard
  1328. upgrade_system_packages
  1329. detect_python || error "Failed to install Python 3.10+"
  1330. echo ""
  1331. # ── Step 2b: Strip unnecessary services & packages ────────────────────
  1332. strip_services
  1333. strip_packages
  1334. echo ""
  1335. # ── Step 3: Download source code ──────────────────────────────────────
  1336. create_spoolbuddy_user
  1337. download_spoolbuddy
  1338. echo ""
  1339. # ── Step 3b: Kiosk setup (labwc + Chromium + squeekboard + Plymouth) ──
  1340. setup_kiosk
  1341. echo ""
  1342. # ── Step 4: SpoolBuddy setup ──────────────────────────────────────────
  1343. info "Setting up SpoolBuddy..."
  1344. setup_spoolbuddy_venv
  1345. create_spoolbuddy_env
  1346. # Kiosk env access: only needed if actual kiosk hardware is available
  1347. if [[ -f /boot/firmware/config.txt ]] || [[ -f /boot/config.txt ]]; then
  1348. ensure_kiosk_env_access
  1349. fi
  1350. setup_ssh_key
  1351. create_spoolbuddy_service
  1352. echo ""
  1353. # ── Step 5: Bambuddy setup (full mode only) ───────────────────────────
  1354. if [[ "$INSTALL_MODE" == "full" ]]; then
  1355. info "Setting up Bambuddy..."
  1356. create_bambuddy_user
  1357. setup_bambuddy_venv
  1358. install_nodejs
  1359. build_frontend
  1360. create_bambuddy_directories
  1361. create_bambuddy_env
  1362. create_bambuddy_service
  1363. bootstrap_spoolbuddy_kiosk_key
  1364. echo ""
  1365. fi
  1366. # ── Done ──────────────────────────────────────────────────────────────
  1367. echo ""
  1368. echo -e "${GREEN}╔══════════════════════════════════════════════════════════╗${NC}"
  1369. echo -e "${GREEN}║ ║${NC}"
  1370. echo -e "${GREEN}║ Installation Complete! ║${NC}"
  1371. echo -e "${GREEN}║ ║${NC}"
  1372. echo -e "${GREEN}╚══════════════════════════════════════════════════════════╝${NC}"
  1373. echo ""
  1374. local ip_addr
  1375. ip_addr=$(hostname -I 2>/dev/null | awk '{print $1}') || ip_addr="<your-ip>"
  1376. if [[ "$INSTALL_MODE" == "full" ]]; then
  1377. echo -e " ${BOLD}Bambuddy:${NC} ${CYAN}http://$ip_addr:$BAMBUDDY_PORT${NC}"
  1378. else
  1379. echo -e " ${BOLD}SpoolBuddy:${NC} Connecting to ${CYAN}$BAMBUDDY_URL${NC}"
  1380. fi
  1381. echo -e " ${BOLD}Kiosk URL:${NC} ${CYAN}$KIOSK_URL${NC}"
  1382. echo -e " ${BOLD}Kiosk user:${NC} ${CYAN}$KIOSK_USER${NC}"
  1383. echo ""
  1384. if [[ "$INSTALL_MODE" == "full" ]]; then
  1385. echo -e " ${BOLD}Next steps:${NC}"
  1386. echo -e " 1. Reboot (required for kiosk, Plymouth splash, and hardware changes)"
  1387. echo -e " 2. The touchscreen kiosk will start automatically after reboot"
  1388. echo -e " 3. On another device, open ${CYAN}http://$ip_addr:$BAMBUDDY_PORT${NC} to complete first-run admin setup"
  1389. fi
  1390. echo ""
  1391. echo -e " ${BOLD}Manage services:${NC}"
  1392. echo -e " SpoolBuddy status: ${CYAN}sudo systemctl status spoolbuddy${NC}"
  1393. echo -e " SpoolBuddy logs: ${CYAN}sudo journalctl -u spoolbuddy -f${NC}"
  1394. if [[ "$INSTALL_MODE" == "full" ]]; then
  1395. echo -e " Bambuddy status: ${CYAN}sudo systemctl status bambuddy${NC}"
  1396. echo -e " Bambuddy logs: ${CYAN}sudo journalctl -u bambuddy -f${NC}"
  1397. fi
  1398. echo ""
  1399. echo -e " ${BOLD}Configuration:${NC} ${CYAN}$INSTALL_PATH/spoolbuddy/.env${NC}"
  1400. echo -e " ${BOLD}Hardware wiring:${NC} ${CYAN}$INSTALL_PATH/spoolbuddy/README.md${NC}"
  1401. echo -e " ${BOLD}Diagnostics:${NC} ${CYAN}sudo $INSTALL_PATH/spoolbuddy/venv/bin/python $INSTALL_PATH/spoolbuddy/pn5180_diag.py${NC}"
  1402. echo ""
  1403. echo -e " ${YELLOW}A reboot is required to apply all changes (kiosk, Plymouth splash, hardware).${NC}"
  1404. echo ""
  1405. if prompt_yes_no "Reboot now?" "y"; then
  1406. reboot
  1407. else
  1408. echo -e " Run ${CYAN}sudo reboot${NC} when ready."
  1409. fi
  1410. echo ""
  1411. }
  1412. main "$@"