#!/bin/bash # BotWave Installer # https://github.com/dpipstudio/botwave # https://botwave.dpip.lol # A DPIP Studio project. https://dpip.lol # Licensed under GPL-v3.0 (see LICENSE) set -e # ============================================================================ # CONSTANTS & CONFIGURATION # ============================================================================ readonly SCRIPT_VERSION="1.4.0" readonly START_PWD=$(pwd) readonly BANNER=$(cat <&2 || true } silent() { "$@" >> "$LOG_FILE" 2>&1 } # parsing parse_arguments() { TARGET_VERSION="" USE_LATEST=false INSTALL_MODE="" SETUP_ALSA="" TARGET_BRANCH="main" while [[ $# -gt 0 ]]; do case $1 in -l|--latest) USE_LATEST=true shift ;; -t|--to) if [[ -z "$2" ]] || [[ "$2" == -* ]]; then log ERROR "Option --to requires a version argument" exit 1 fi TARGET_VERSION="$2" shift 2 ;; -b|--branch) if [[ -z "$2" ]] || [[ "$2" == -* ]]; then log ERROR "Option --branch requires a branch name" exit 1 fi USE_LATEST=true TARGET_BRANCH="$2" shift 2 ;; --alsa) SETUP_ALSA=true shift ;; --no-alsa) SETUP_ALSA=false shift ;; -h|--help) show_help exit 0 ;; client|server|both) INSTALL_MODE="$1" shift ;; *) log ERROR "Unknown option: $1" log INFO "Did you mean one of these?" log INFO " client Install client components" log INFO " server Install server components" log INFO " both Install both client and server" log INFO " -l, --latest Install from latest commit (unreleased)" log INFO " -t, --to Install specific release version" log INFO " -b, --branch Install from a specific branch (default: main)" log INFO " --[no-]alsa Setup ALSA loopback card" log INFO " -h, --help Show help message" exit 1 ;; esac done } show_help() { cat << EOF BotWave Installation Script v$SCRIPT_VERSION Usage: $(basename "$0") [MODE] [OPTIONS] Modes: client Install client components server Install server components both Install both client and server components Options: -l, --latest Install from the latest commit (even if unreleased) -t, --to Install a specific release version -b, --branch Install from a specific branch (default: main) --[no-]alsa Setup ALSA loopback card -h, --help Show this help message Examples: $(basename "$0") client # Install client (latest release) $(basename "$0") both --latest # Install both (latest commit) $(basename "$0") server --to v1.0.0-oak EOF } # interactive select select_option() { # Credit: https://unix.stackexchange.com/questions/146570/arrow-key-enter-menu set +e # Handle non-interactive environments if [[ -e /dev/tty ]]; then exec < /dev/tty > /dev/tty elif [[ ! -t 0 ]] || [[ ! -t 1 ]]; then _fallback_menu "$@" return $? fi _arrow_key_menu "$@" local result=$? return $result } _fallback_menu() { local idx=1 for opt; do echo " $idx) $opt" >&2 ((idx++)) done while true; do echo -n "Enter number (1-$#): " >&2 read selection if [[ "$selection" =~ ^[0-9]+$ ]] && [ "$selection" -ge 1 ] && [ "$selection" -le $# ]; then return $((selection - 1)) else echo "Invalid selection. Please enter a number between 1 and $#." >&2 fi done } _arrow_key_menu() { local ESC=$(printf "\033") local MENU_SELECT_COLOR="${MENU_SELECT_COLOR:-$ESC[94m}" local MENU_UNSELECT_COLOR="${MENU_UNSELECT_COLOR:-$NC}" cursor_blink_on() { printf "$ESC[?25h"; } cursor_blink_off() { printf "$ESC[?25l"; } cursor_to() { printf "$ESC[$1;${2:-1}H"; } print_option() { printf " ${MENU_UNSELECT_COLOR}$1${NC}"; } print_selected() { printf "${MENU_SELECT_COLOR}> $1${NC}"; } get_cursor_row() { IFS=';' read -sdR -p $'\E[6n' ROW COL; echo ${ROW#*[}; } key_input() { read -s -n3 key 2>/dev/null >&2 if [[ $key = $ESC[A ]]; then echo up; fi if [[ $key = $ESC[B ]]; then echo down; fi if [[ $key = "" ]]; then echo enter; fi } # Print blank lines for menu for opt; do printf "\n"; done local lastrow=$(get_cursor_row) local startrow=$(($lastrow - $#)) trap "cursor_blink_on; stty echo; printf '\n'; exit" 2 cursor_blink_off local selected=0 while true; do local idx=0 for opt; do cursor_to $(($startrow + $idx)) if [ $idx -eq $selected ]; then print_selected "$opt" else print_option "$opt" fi ((idx++)) done case $(key_input) in enter) break;; up) ((selected--)); if [ $selected -lt 0 ]; then selected=$(($# - 1)); fi;; down) ((selected++)); if [ $selected -ge $# ]; then selected=0; fi;; esac done cursor_to $lastrow printf "\n" cursor_blink_on return $selected } # validations check_root_privileges() { if [[ "$EUID" -ne 0 ]]; then if [[ ! -t 0 ]]; then log WARN "This script must be run as root. Please run it again with sudo." exit 1 fi log WARN "This script must be run as root. Re-run with sudo?" export MENU_SELECT_COLOR="$RED" select_option "Yes (sudo)" "No (exit)" local choice=$? set -e if [[ "$choice" -eq 0 ]]; then log WARN "Restarting with sudo..." sudo bash "$0" "$@" exit $? else log ERROR "Root privileges required. Exiting." exit 1 fi fi } detect_package_manager() { if command -v apt-get &>/dev/null; then PKG_MANAGER="apt" elif command -v dnf &>/dev/null; then PKG_MANAGER="dnf" elif command -v pacman &>/dev/null; then PKG_MANAGER="pacman" else log WARN "Could not detect a supported package manager (apt/dnf/pacman)." log WARN "Continue anyway? You may need to install dependencies manually." export MENU_SELECT_COLOR="$RED" select_option "No (exit)" "Yes (continue)" [[ $? -eq 0 ]] && exit 1 PKG_MANAGER="unknown" set -e fi log INFO "Detected package manager: $PKG_MANAGER" } validate_hardware() { local mode="$1" if [[ "$mode" != "client" && "$mode" != "both" ]]; then return 0 fi local model="$(tr -d '\0' /dev/null)" local supported=false if echo "$model" | grep -qi "raspberry pi"; then if ! echo "$model" | grep -qiE "raspberry pi [5-9]"; then supported=true fi fi if [[ "$supported" == false ]]; then log ERROR "Unsupported device detected." log WARN "This program requires a Raspberry Pi (models 1-4, Zero) but not Pi 5 or newer." log WARN "Continue anyway?" export MENU_SELECT_COLOR="$RED" select_option "No (exit)" "Yes (continue)" if [[ $? -eq 0 ]]; then exit 1 fi export MENU_SELECT_COLOR="" set -e fi } prompt_alsa_setup() { local mode="$1" if [[ -n "$SETUP_ALSA" ]]; then return 0 fi log INFO "Setup ALSA loopback card for live streaming?" log INFO "(This will create/overwrite /etc/modules-load.d/aloop.conf and /etc/modprobe.d/aloop.conf)" select_option "No" "Yes" if [[ $? -eq 1 ]]; then SETUP_ALSA=true else SETUP_ALSA=false fi set -e } detect_local_repo() { LOCAL_REPO=false LOCAL_REPO_ROOT="" # Check current dir or parent for git repo + installation.json for candidate in "." ".."; do if [[ -d "$candidate/.git" ]] && [[ -f "$candidate/assets/installation.json" ]]; then LOCAL_REPO=true LOCAL_REPO_ROOT=$(realpath "$candidate") log INFO "Local BotWave repo detected at $LOCAL_REPO_ROOT, installing from local files" break fi done } # needs to the installation.json stuff validate_python_version() { local install_json="$1" local min_version=$(echo "$install_json" | jq -r ".min_python_version") local current_version=$(python3 --version 2>&1 | awk '{print $2}') local cur_major=$(echo "$current_version" | cut -d. -f1) local cur_minor=$(echo "$current_version" | cut -d. -f2) local req_major=$(echo "$min_version" | cut -d. -f1) local req_minor=$(echo "$min_version" | cut -d. -f2) if (( cur_major < req_major )) || { (( cur_major == req_major )) && (( cur_minor < req_minor )); }; then log ERROR "Python $current_version found, but $min_version or newer is required" exit 1 fi log INFO "Python OK: $current_version (>= $min_version required)" } # version managment # Resolves what to install and how to fetch it. Prints two lines: # - "commit" or "release" # - the commit SHA or the release tag resolve_target() { # Local repo: just use the current HEAD, ignore --latest / --to if [[ "$LOCAL_REPO" == true ]]; then local commit=$(git -C "$LOCAL_REPO_ROOT" rev-parse HEAD) log INFO "Local repo HEAD: ${commit:0:7}" echo "commit" echo "$commit" return 0 fi if [[ "$USE_LATEST" == true ]]; then log INFO "Fetching latest commit..." local latest_commit=$(curl -sSL "https://api.github.com/repos/dpipstudio/botwave/commits?sha=${TARGET_BRANCH}" | \ grep '"sha":' | \ head -n 1 | \ cut -d '"' -f 4) if [[ -z "$latest_commit" ]]; then log ERROR "Failed to fetch latest commit" exit 1 fi log INFO "Latest commit: ${latest_commit:0:7}" echo "commit" echo "$latest_commit" return 0 fi if [[ -n "$TARGET_VERSION" ]]; then log INFO "Checking release: $TARGET_VERSION" local http_status=$(curl -sSL -o /dev/null -w "%{http_code}" \ "https://api.github.com/repos/dpipstudio/botwave/releases/tags/${TARGET_VERSION}") if [[ "$http_status" != "200" ]]; then log ERROR "Release '$TARGET_VERSION' not found" log INFO "Available releases:" curl -sSL "https://api.github.com/repos/dpipstudio/botwave/releases" | \ jq -r '.[].tag_name' | while read -r tag; do log INFO " - $tag" done exit 1 fi log INFO "Found release: $TARGET_VERSION" echo "release" echo "$TARGET_VERSION" return 0 fi # Default: latest release log INFO "Fetching latest release..." local release_json=$(curl -sSL "https://api.github.com/repos/dpipstudio/botwave/releases/latest") local tag=$(echo "$release_json" | jq -r '.tag_name') if [[ -z "$tag" || "$tag" == "null" ]]; then log ERROR "Failed to fetch latest release" exit 1 fi log INFO "Latest release: $tag" echo "release" echo "$tag" } # source download fetch_source_tarball() { local kind="$1" # "commit" or "release", from resolve_target local ref="$2" # commit SHA or release tag, from resolve_target local extract_dir="$TMP_DIR/src" local tarball_path="$TMP_DIR/src.tar.gz" local tarball_url rm -rf "$extract_dir" mkdir -p "$extract_dir" if [[ "$kind" == "release" ]]; then tarball_url="${GITHUB_REPO_URL}/archive/refs/tags/${ref}.tar.gz" else tarball_url="${GITHUB_REPO_URL}/archive/${ref}.tar.gz" fi log INFO "Downloading source tarball..." silent curl -SL "$tarball_url" -o "$tarball_path" log INFO "Extracting tarball..." silent tar -xzf "$tarball_path" -C "$extract_dir" --strip-components=1 if [[ ! -f "$extract_dir/assets/installation.json" ]]; then log ERROR "installation.json not found in extracted tarball" exit 1 fi SRC_DIR="$extract_dir" log INFO "Source extracted to $SRC_DIR" } # system setup install_system_dependencies() { log INFO "Installing system dependencies..." case "$PKG_MANAGER" in apt) silent apt-get update silent apt-get install -y \ python3 python3-pip python3-venv python3-dev \ libsndfile1-dev libasound2-dev libffi-dev libssl-dev \ build-essential make ffmpeg git curl jq ;; dnf) silent dnf install -y \ python3 python3-pip python3-devel \ libsndfile-devel alsa-lib-devel libffi-devel openssl-devel \ gcc make ffmpeg git curl jq ;; pacman) silent pacman -Sy --noconfirm \ python python-pip \ libsndfile alsa-lib libffi openssl \ base-devel ffmpeg git curl jq ;; unknown) log WARN "Skipping dependency installation." ;; esac } setup_directory_structure() { log INFO "Creating directory structure..." mkdir -p "$INSTALL_DIR/uploads" mkdir -p "$INSTALL_DIR/handlers" mkdir -p "$INSTALL_DIR/scripts" mkdir -p "$BIN_DIR" mkdir -p "$BACKENDS_DIR" cd "$INSTALL_DIR" umask 002 } setup_python_environment() { if [[ ! -d venv ]]; then log INFO "Creating Python virtual environment..." silent python3 -m venv venv log INFO "Upgrading pip..." silent ./venv/bin/pip install --upgrade pip else log INFO "Python virtual environment already exists." fi } setup_alsa_loopback() { if [[ "$SETUP_ALSA" != true ]]; then return 0 fi log INFO "Setting up ALSA loopback card..." log INFO "Creating $ALSA_MODULES_CONF" mkdir -p "$(dirname "$ALSA_MODULES_CONF")" cat > "$ALSA_MODULES_CONF" < "$ALSA_MODPROBE_CONF" </dev/null) if [[ -z "$file_list" ]]; then return 0 fi log INFO "Installing files for: $section" while IFS= read -r file; do [[ -z "$file" ]] && continue local target_path="$INSTALL_DIR/$file" local target_dir=$(dirname "$target_path") mkdir -p "$target_dir" log INFO " - $file" cp "$SRC_DIR/$file" "$target_path" done <<< "$file_list" } install_requirements() { local section="$1" local install_json="$2" local req_list=$(echo "$install_json" | jq -r ".${section}.requirements[]?" 2>/dev/null) if [[ -z "$req_list" ]]; then return 0 fi log INFO "Installing Python requirements for: $section" while IFS= read -r req; do [[ -z "$req" ]] && continue log INFO " - $req" silent ./venv/bin/pip install "$req" done <<< "$req_list" } install_binaries() { local section="$1" local install_json="$2" local bin_list=$(echo "$install_json" | jq -r ".${section}.binaries[]?" 2>/dev/null) if [[ -z "$bin_list" ]]; then return 0 fi log INFO "Installing binaries for: $section" while IFS= read -r binary; do [[ -z "$binary" ]] && continue local bin_name=$(basename "$binary") local target_path="$INSTALL_DIR/$binary" mkdir -p "$(dirname "$target_path")" log INFO " - $binary" cp "$SRC_DIR/$binary" "$target_path" chmod +x "$target_path" create_symlink "$bin_name" done <<< "$bin_list" } # backend install install_backends() { local install_json="$1" local backend_list=$(echo "$install_json" | jq -r ".backends[]?" 2>/dev/null) if [[ -z "$backend_list" ]]; then log WARN "No backends found in configuration" return 0 fi log INFO "Installing backends..." cd "$BACKENDS_DIR" while IFS= read -r repo_url; do [[ -z "$repo_url" ]] && continue local repo_name=$(basename "$repo_url" .git) log INFO " - Processing: $repo_name" if [[ -d "$repo_name" ]]; then log INFO " Already exists, skipping clone" else log INFO " Cloning repository..." silent git clone "$repo_url" || { log ERROR " Failed to clone $repo_name" continue } fi cd "$repo_name" if [[ -d "src" ]]; then log INFO " Building..." cd src silent make clean silent make || { log ERROR " Build failed" cd "$BACKENDS_DIR" continue } log INFO " Build successful" cd .. else log WARN " No src directory, skipping build" fi cd "$BACKENDS_DIR" done <<< "$backend_list" cd "$INSTALL_DIR" } # component install (client, server, always) install_components() { local mode="$1" local install_json="$2" local target_kind="$3" local target_ref="$4" local sections=() # Show version info if [[ "$LOCAL_REPO" == true ]]; then log INFO "Installing from local repo" elif [[ "$target_kind" == "release" ]]; then log INFO "Installing release: $target_ref" else log WARN "Using latest commit (unreleased): ${target_ref:0:7}" fi # Determine sections to install if [[ "$mode" == "both" ]]; then sections=("client" "server" "always") else sections=("$mode" "always") fi # Install backends if client mode if [[ "$mode" == "client" || "$mode" == "both" ]]; then install_backends "$install_json" fi # Install each section for section in "${sections[@]}"; do log INFO "Processing section: $section" install_files "$section" "$install_json" install_requirements "$section" "$install_json" install_binaries "$section" "$install_json" done } # post install save_version_info() { local kind="$1" local ref="$2" log INFO "Saving version information..." if [[ "$LOCAL_REPO" == true ]]; then echo "local:$LOCAL_REPO_ROOT" > "$INSTALL_DIR/last_commit" echo "local:$LOCAL_REPO_ROOT" > "$INSTALL_DIR/last_release" return 0 fi if [[ "$kind" == "release" ]]; then echo "$ref" > "$INSTALL_DIR/last_release" else echo "$ref" > "$INSTALL_DIR/last_commit" fi } print_summary() { local mode="$1" log INFO "Installation complete!" log INFO "" log INFO "Installed components:" [[ "$mode" == "client" || "$mode" == "both" ]] && log INFO " - Client mode" [[ "$mode" == "server" || "$mode" == "both" ]] && log INFO " - Server mode" log INFO " - Common utilities" log INFO "" if [[ "$SETUP_ALSA" == true ]]; then log WARN "ALSA loopback card has been configured." log WARN "You must REBOOT for the changes to take effect!" fi log INFO "Installation directory: $INSTALL_DIR" log INFO "Log file: $LOG_FILE" } cleanup() { if [[ -n "$SRC_DIR" && "$SRC_DIR" == "$TMP_DIR/src" ]]; then rm -rf "$SRC_DIR" fi rm -f "$TMP_DIR/src.tar.gz" } # main main() { mkdir -p "$TMP_DIR" trap cleanup EXIT echo "$BANNER" # Parse command line arguments first parse_arguments "$@" # Pre-flight checks check_root_privileges log INFO "Full log transcript will be written in $LOG_FILE" detect_local_repo detect_package_manager # find installation mode local mode="$INSTALL_MODE" if [[ -z "$mode" ]] || [[ ! " ${VALID_MODES[*]} " == *" $mode "* ]]; then if [[ -n "$mode" ]]; then log WARN "Invalid installation mode: $mode" fi log INFO "Select installation type:" select_option "Client Unit" "Server Unit" "Both Units" mode="${VALID_MODES[$?]}" set -e fi log INFO "Installation mode: $mode" # Hardware validation validate_hardware "$mode" # Setup sound card prompt_alsa_setup "$mode" # System setup 1 install_system_dependencies # Find what to install local target_kind target_ref { read -r target_kind; read -r target_ref; } < <(resolve_target) || exit 1 # Fetch source tarball, or point SRC_DIR at the local repo checkout if [[ "$LOCAL_REPO" == true ]]; then SRC_DIR="$LOCAL_REPO_ROOT" else fetch_source_tarball "$target_kind" "$target_ref" fi # System setup 2 setup_directory_structure setup_python_environment setup_alsa_loopback # Fetch configuration and install local install_json=$(fetch_installation_config) validate_python_version "$install_json" install_components "$mode" "$install_json" "$target_kind" "$target_ref" # Finalize save_version_info "$target_kind" "$target_ref" print_summary "$mode" cd "$START_PWD" log INFO "" log INFO "Run 'botwave' in your terminal for a quick introduction :)" echo "Installation completed, exiting!" # avoid blocking exit 0 } main "$@"