diff --git a/.github/labeler.yml b/.github/labeler.yml index ddce3ad..da0f44d 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -45,6 +45,7 @@ installer: - changed-files: - any-glob-to-any-file: - "install.sh" + - "install.d/**/*" - "uninstall.sh" # Icons, sounds, screenshots, promo assets diff --git a/.github/workflows/unified-pipeline.yml b/.github/workflows/unified-pipeline.yml index fff04e0..7d33ec3 100644 --- a/.github/workflows/unified-pipeline.yml +++ b/.github/workflows/unified-pipeline.yml @@ -48,6 +48,7 @@ jobs: # pywhispercpp pin out of install.sh and installs the desktop # entry and the icon into the AppDir. - 'install.sh' + - 'install.d/**' - 'vocalinux.desktop' - 'resources/**' - 'justfile' diff --git a/AGENTS.md b/AGENTS.md index 363ac4e..f4c1399 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -160,7 +160,9 @@ src/vocalinux/ └── resources/ # SVG icons + WAV cues (also repo resources/) ``` -Also: `tests/`, `docs/`, `packaging/` (AppImage, AUR, Flatpak), `scripts/`, `install.sh`, `uninstall.sh`, `web/`. +Also: `tests/`, `docs/`, `packaging/` (AppImage, AUR, Flatpak), `scripts/`, +`install.sh` (bootstrap and orchestration), `install.d/` (sourced installer modules), +`uninstall.sh`, `web/`. | Task | Start here | |---|---| diff --git a/install.d/desktop.sh b/install.d/desktop.sh new file mode 100644 index 0000000..05a3e3f --- /dev/null +++ b/install.d/desktop.sh @@ -0,0 +1,164 @@ +#!/bin/bash +# Sourced by install.sh after it has resolved the local or tagged repository. +# Keep this file declarative: function definitions only, with no top-level actions. + +# Function to install desktop entry with error handling +install_desktop_entry() { + print_info "Installing desktop entry..." + + # Check if desktop entry file exists + if [ ! -f "vocalinux.desktop" ]; then + print_error "Desktop entry file not found: vocalinux.desktop" + return 1 + fi + + # Create desktop directory if it doesn't exist + mkdir -p "$DESKTOP_DIR" || { + print_error "Failed to create desktop directory: $DESKTOP_DIR" + return 1 + } + + # Copy desktop entry + cp vocalinux.desktop "$DESKTOP_DIR/" || { + print_error "Failed to copy desktop entry to $DESKTOP_DIR" + return 1 + } + + # Update the desktop entry to use the wrapper script with GI_TYPELIB_PATH + WRAPPER_SCRIPT="$HOME/.local/bin/vocalinux-gui" + if [ ! -f "$WRAPPER_SCRIPT" ]; then + print_warning "Wrapper script not found at $WRAPPER_SCRIPT" + print_warning "Desktop entry may not work correctly" + else + # Update Exec line to include GI_TYPELIB_PATH for PyGObject + # Use the detected path for cross-distro compatibility + sed -i "s|^Exec=vocalinux|Exec=env GI_TYPELIB_PATH=$GI_TYPELIB_DETECTED $WRAPPER_SCRIPT|" "$DESKTOP_DIR/vocalinux.desktop" || { + print_warning "Failed to update desktop entry path" + } + print_info "Updated desktop entry to use wrapper script with GI_TYPELIB_PATH" + fi + + # Make desktop entry executable + chmod +x "$DESKTOP_DIR/vocalinux.desktop" || { + print_warning "Failed to make desktop entry executable" + } + + return 0 +} + +# Function to install icons with error handling +install_icons() { + print_info "Installing application icons..." + + # Create icon directory if it doesn't exist + mkdir -p "$ICON_DIR" || { + print_error "Failed to create icon directory: $ICON_DIR" + return 1 + } + + # Check if icons directory exists + if [ ! -d "resources/icons/scalable" ]; then + print_warning "Custom icons not found in resources/icons/scalable directory" + return 1 + fi + + # List of icons to install + local ICONS=( + "vocalinux.svg" + "vocalinux-microphone.svg" + "vocalinux-microphone-off.svg" + "vocalinux-microphone-process.svg" + ) + + # Install each icon + local INSTALLED_COUNT=0 + for icon in "${ICONS[@]}"; do + if [ -f "resources/icons/scalable/$icon" ]; then + cp "resources/icons/scalable/$icon" "$ICON_DIR/" || { + print_warning "Failed to copy icon: $icon" + continue + } + ((INSTALLED_COUNT++)) + else + print_warning "Icon not found: resources/icons/scalable/$icon" + fi + done + + if [ "$INSTALLED_COUNT" -eq "${#ICONS[@]}" ]; then + print_success "Installed all custom Vocalinux icons" + return 0 + elif [ "$INSTALLED_COUNT" -gt 0 ]; then + print_warning "Installed $INSTALLED_COUNT/${#ICONS[@]} custom Vocalinux icons" + return 0 + else + print_error "Failed to install any icons" + return 1 + fi +} + +# Function to update icon cache and desktop database +update_icon_cache() { + print_info "Updating icon cache..." + + # Check if gtk-update-icon-cache command exists + if command_exists gtk-update-icon-cache; then + gtk-update-icon-cache -f -t "${XDG_DATA_HOME:-$HOME/.local/share}/icons/hicolor" 2>/dev/null || { + print_warning "Failed to update icon cache" + } + else + print_warning "gtk-update-icon-cache command not found, skipping icon cache update" + fi + + # Update desktop database so the app appears in application menus immediately + print_info "Updating desktop database..." + if command_exists update-desktop-database; then + update-desktop-database "${XDG_DATA_HOME:-$HOME/.local/share}/applications" 2>/dev/null || { + print_warning "Failed to update desktop database" + } + else + print_warning "update-desktop-database command not found - app may not appear in menu until next login" + fi +} + +# Function to install resources (icons, sounds) to the virtual environment +# so the resource_manager can find them at runtime +install_resources_to_venv() { + print_info "Installing resources to virtual environment..." + + # Target directory: $VENV_DIR/share/vocalinux/resources + local VENV_RESOURCES_DIR="$VENV_DIR/share/vocalinux/resources" + + # Create directories + mkdir -p "$VENV_RESOURCES_DIR/icons/scalable" || { + print_warning "Failed to create venv resources directory" + return 1 + } + mkdir -p "$VENV_RESOURCES_DIR/sounds" || { + print_warning "Failed to create venv sounds directory" + return 1 + } + + # Copy icons if available + if [ -d "resources/icons/scalable" ]; then + cp resources/icons/scalable/*.svg "$VENV_RESOURCES_DIR/icons/scalable/" 2>/dev/null || { + print_warning "Failed to copy icons to venv resources" + } + fi + + # Copy sounds if available + if [ -d "resources/sounds" ]; then + cp resources/sounds/*.wav "$VENV_RESOURCES_DIR/sounds/" 2>/dev/null || { + print_warning "Failed to copy sounds to venv resources" + } + fi + + # Verify + local ICON_COUNT=$(ls "$VENV_RESOURCES_DIR/icons/scalable/"*.svg 2>/dev/null | wc -l) + local SOUND_COUNT=$(ls "$VENV_RESOURCES_DIR/sounds/"*.wav 2>/dev/null | wc -l) + + if [ "$ICON_COUNT" -gt 0 ] && [ "$SOUND_COUNT" -gt 0 ]; then + print_success "Installed resources to venv ($ICON_COUNT icons, $SOUND_COUNT sounds)" + else + print_warning "Some resources may be missing from venv ($ICON_COUNT icons, $SOUND_COUNT sounds)" + fi +} diff --git a/install.d/interactive.sh b/install.d/interactive.sh new file mode 100644 index 0000000..595d805 --- /dev/null +++ b/install.d/interactive.sh @@ -0,0 +1,312 @@ +#!/bin/bash +# Sourced by install.sh after it has resolved the local or tagged repository. +# Keep this file declarative: function definitions only, with no top-level actions. + +# Print section header for interactive mode +clear_screen() { + if [ -t 1 ] && command -v clear >/dev/null 2>&1 && [ -n "${TERM:-}" ]; then + clear >/dev/null 2>&1 || true + fi +} + +print_header() { + local title="$1" + echo "" + echo "============================================================" + echo " $title" + echo "============================================================" +} + +# Function to run interactive guided installation +run_interactive_install() { + clear_screen + cat << "EOF" + + Interactive Installation Guide + =============================== + +EOF + + echo "Welcome! This guided installation will help you set up Vocalinux" + echo "with the best options for your system." + echo "" + echo "Local engines (whisper.cpp, Whisper, Faster Whisper, VOSK) process audio on-device after you download a model." + echo "Optional Remote API is off unless you choose it; then audio goes only to the server you configure." + echo "" + + # Step 1: Detect and display system info + print_header "Step 1: Your System" + echo "Detected: $DISTRO_NAME $DISTRO_VERSION" + + # Get hardware recommendation + local RECOMMENDATION=$(get_engine_recommendation) + local RECOMMENDED_ENGINE=$(echo "$RECOMMENDATION" | cut -d':' -f1) + local RECOMMENDED_ICON=$(echo "$RECOMMENDATION" | cut -d':' -f2) + local RECOMMENDED_REASON=$(echo "$RECOMMENDATION" | cut -d':' -f3-) + + echo "Hardware: $RECOMMENDED_REASON" + echo "" + + # Step 2: Choose speech recognition engine + print_header "Step 2: Choose Speech Recognition Engine" + echo "" + echo " ┌─────────────────────────────────────────────────────────────┐" + echo " │ 1. WHISPER.CPP * RECOMMENDED │" + echo " │ • Local default engine; CUDA, Vulkan, or CPU │" + echo " │ • Supports NVIDIA (CUDA), AMD, Intel (Vulkan) │" + echo " │ • CPU-only mode available for older systems │" + echo " │ • Models: tiny (39MB) to large (1.5GB) │" + echo " │ • ~33 languages plus Auto-detect │" + echo " └─────────────────────────────────────────────────────────────┘" + echo "" + echo " ┌─────────────────────────────────────────────────────────────┐" + echo " │ 2. WHISPER (OpenAI) │" + echo " │ • PyTorch-based, high accuracy │" + echo " │ • Only supports NVIDIA GPUs (CUDA) │" + echo " │ • Larger download (~2GB with CUDA) │" + echo " │ • Good for development/research │" + echo " └─────────────────────────────────────────────────────────────┘" + echo "" + echo " ┌─────────────────────────────────────────────────────────────┐" + echo " │ 3. VOSK │" + echo " │ • Lightweight and fast │" + echo " │ • Works on older/low-RAM systems │" + echo " │ • ~40MB download │" + echo " │ • Good for basic dictation needs │" + echo " └─────────────────────────────────────────────────────────────┘" + echo "" + echo " ┌─────────────────────────────────────────────────────────────┐" + echo " │ 4. FASTER-WHISPER │" + echo " │ • Optional CTranslate2 Whisper backend │" + echo " │ • Fast on CPU with INT8 quantization │" + echo " │ • Checksum-verified Hugging Face models │" + echo " └─────────────────────────────────────────────────────────────┘" + echo "" + echo " ┌─────────────────────────────────────────────────────────────┐" + echo " │ 5. REMOTE API (ADVANCED) │" + echo " │ • Offload processing to a GPU server on your network │" + echo " │ • Ideal for laptops without GPU │" + echo " │ • Supports whisper.cpp server & OpenAI-compatible APIs │" + echo " │ • Minimal local resources needed │" + echo " │ • Requires a remote server to be running │" + echo " └─────────────────────────────────────────────────────────────┘" + echo "" + + # Show recommendation + case "$RECOMMENDED_ENGINE" in + whisper_cpp) + echo " → Recommendation: whisper.cpp (recommended for your hardware)" + DEFAULT_CHOICE="1" + ;; + vosk) + echo " → Recommendation: VOSK (lightweight option for your system)" + DEFAULT_CHOICE="3" + ;; + *) + echo " → Recommendation: whisper.cpp (recommended default)" + DEFAULT_CHOICE="1" + ;; + esac + echo "" + + read -p "Choose engine [1-5] (default: $DEFAULT_CHOICE): " ENGINE_CHOICE + ENGINE_CHOICE=${ENGINE_CHOICE:-$DEFAULT_CHOICE} + + case "$ENGINE_CHOICE" in + 1) + SELECTED_ENGINE="whisper_cpp" + ENGINE_DISPLAY="Whisper.cpp (Recommended)" + ;; + 2) + SELECTED_ENGINE="whisper" + ENGINE_DISPLAY="Whisper (OpenAI)" + ;; + 3) + SELECTED_ENGINE="vosk" + ENGINE_DISPLAY="VOSK (Lightweight)" + ;; + 4) + SELECTED_ENGINE="faster_whisper" + ENGINE_DISPLAY="Faster-Whisper" + ;; + 5) + SELECTED_ENGINE="remote_api" + ENGINE_DISPLAY="Remote API" + ;; + *) + SELECTED_ENGINE="whisper_cpp" + ENGINE_DISPLAY="Whisper.cpp (Recommended)" + ;; + esac + + # Step 3: Whisper.cpp backend selection (if whisper.cpp chosen) + if [[ "$SELECTED_ENGINE" == "whisper_cpp" ]]; then + print_header "Step 3: Choose Whisper.cpp Backend" + echo "" + + # Detect available backends + local BACKEND_INFO=$(detect_whispercpp_backends) + local RECOMMENDED_BACKEND=$(echo "$BACKEND_INFO" | cut -d':' -f1) + local RECOMMENDED_REASON=$(echo "$BACKEND_INFO" | cut -d':' -f2) + local CAN_BUILD_GPU=$(echo "$BACKEND_INFO" | cut -d':' -f3) + local HAS_VULKAN=$(echo "$BACKEND_INFO" | cut -d':' -f4) + local HAS_NVIDIA=$(echo "$BACKEND_INFO" | cut -d':' -f5) + local HAS_VULKAN_DEV=$(echo "$BACKEND_INFO" | cut -d':' -f6) + local HAS_CUDA_DEV=$(echo "$BACKEND_INFO" | cut -d':' -f7) + local VULKAN_COMPAT=$(echo "$BACKEND_INFO" | cut -d':' -f8) + local VULKAN_COMPAT_REASON=$(echo "$BACKEND_INFO" | cut -d':' -f9) + + # Show warning for incompatible GPUs + if [[ "$VULKAN_COMPAT" == "incompatible" ]]; then + echo "" + print_warning "═══════════════════════════════════════════════════════════════" + print_warning " ⚠️ INCOMPATIBLE GPU DETECTED" + print_warning "═══════════════════════════════════════════════════════════════" + print_warning "" + print_warning " Your GPU: $VULKAN_COMPAT_REASON" + print_warning "" + print_warning " This Intel GPU lacks VK_KHR_16bit_storage support, which is" + print_warning " required for whisper.cpp Vulkan acceleration." + print_warning "" + print_warning " The CPU backend will be used instead, which is still fast!" + print_warning "" + print_warning "═══════════════════════════════════════════════════════════════" + echo "" + fi + + echo "Whisper.cpp can use different backends for speech recognition:" + echo "" + + if [[ "$CAN_BUILD_GPU" == "true" ]]; then + echo " ┌─────────────────────────────────────────────────────────────┐" + echo " │ 1. GPU (Vulkan/CUDA) * RECOMMENDED │" + echo " │ • Fastest performance with GPU acceleration │" + printf " │ • %-*s│\n" 54 "$RECOMMENDED_REASON" + echo " │ • Requires building from source (takes ~2-5 min) │" + echo " └─────────────────────────────────────────────────────────────┘" + echo "" + echo " ┌─────────────────────────────────────────────────────────────┐" + echo " │ 2. CPU (Pre-built) │" + echo " │ • Works on all systems │" + echo " │ • Faster installation (no compilation) │" + echo " │ • Good performance on modern CPUs │" + echo " └─────────────────────────────────────────────────────────────┘" + echo "" + echo " → Recommendation: GPU backend for best performance" + local DEFAULT_BACKEND="1" + else + echo " ┌─────────────────────────────────────────────────────────────┐" + echo " │ 1. GPU (Vulkan/CUDA) │" + echo " │ • ⚠️ GPU libraries not detected │" + echo " │ • Requires: libvulkan-dev, glslc/glslang-tools (Vulkan) │" + echo " │ or: CUDA toolkit (NVIDIA) │" + echo " └─────────────────────────────────────────────────────────────┘" + echo "" + echo " ┌─────────────────────────────────────────────────────────────┐" + echo " │ 2. CPU (Pre-built) * RECOMMENDED │" + echo " │ • Works on all systems │" + echo " │ • Fast installation (no compilation) │" + echo " │ • Good performance on modern CPUs │" + echo " └─────────────────────────────────────────────────────────────┘" + echo "" + + if [[ "$HAS_VULKAN" == "yes" && "$HAS_VULKAN_DEV" != "true" ]]; then + echo " 💡 Tip: Install 'libvulkan-dev' and a shader compiler for GPU support:" + echo " sudo apt install libvulkan-dev glslc 2>/dev/null || sudo apt install libvulkan-dev glslang-tools" + echo "" + elif [[ "$HAS_NVIDIA" == "yes" && "$HAS_CUDA_DEV" != "true" ]]; then + echo " 💡 Tip: Install CUDA toolkit for NVIDIA GPU support:" + echo " https://developer.nvidia.com/cuda-downloads" + echo "" + fi + + echo " → Recommendation: CPU backend (GPU libraries not detected)" + local DEFAULT_BACKEND="2" + fi + + read -p "Choose backend [1-2] (default: $DEFAULT_BACKEND): " BACKEND_CHOICE + BACKEND_CHOICE=${BACKEND_CHOICE:-$DEFAULT_BACKEND} + + if [[ "$BACKEND_CHOICE" == "1" ]]; then + WHISPERCPP_BACKEND="gpu" + BACKEND_DISPLAY="GPU (Vulkan/CUDA)" + else + WHISPERCPP_BACKEND="cpu" + BACKEND_DISPLAY="CPU (Pre-built)" + fi + + echo "" + fi + + # Step 3 for Remote API: Configure server URL + if [[ "$SELECTED_ENGINE" == "remote_api" ]]; then + print_header "Step 3: Configure Remote Server" + echo "" + print_info "You need a speech recognition server running on your local network." + echo "" + echo " Supported servers:" + echo " • whisper.cpp server: ./server -m model.bin --host 0.0.0.0 --port 8080" + echo " • LocalAI: docker run -p 8080:8080 localai/localai" + echo " • Faster Whisper: faster-whisper-server --host 0.0.0.0 --port 8080" + echo " • Any OpenAI-compatible speech API" + echo "" + read -p "Enter remote server URL (or leave blank to set later): " REMOTE_API_URL_INPUT + if [ -n "$REMOTE_API_URL_INPUT" ]; then + REMOTE_API_URL="$REMOTE_API_URL_INPUT" + REMOTE_DISPLAY="$REMOTE_API_URL" + else + REMOTE_API_URL="" + REMOTE_DISPLAY="(configure later in Settings)" + fi + echo "" + fi + + # Step 4: Model download preference (skip for remote_api) + if [[ "$SELECTED_ENGINE" != "remote_api" ]]; then + print_header "Step 4: Model Download" + echo "" + echo "Speech recognition models can be downloaded now or later." + echo "" + echo " 1. Download now (recommended)" + echo " • Faster first run - ready to use immediately" + echo " • Offline capable right after install" + echo "" + echo " 2. Download later" + echo " • Smaller initial install" + echo " • Models download automatically on first use" + echo "" + + read -p "Download models now? [1-2] (default: 1): " MODELS_CHOICE + MODELS_CHOICE=${MODELS_CHOICE:-1} + + if [[ "$MODELS_CHOICE" == "2" ]]; then + SKIP_MODELS="yes" + MODELS_DISPLAY="Download on first use" + else + MODELS_DISPLAY="Download now (recommended)" + fi + else + # Remote API: No need to download model + SKIP_MODELS="yes" + MODELS_DISPLAY="Not needed (remote processing)" + fi + + # Summary + print_header "Installation Summary" + echo "" + echo " Speech Engine: $ENGINE_DISPLAY" + if [[ "$SELECTED_ENGINE" == "whisper_cpp" ]]; then + echo " Backend: ${BACKEND_DISPLAY:-CPU (Pre-built)}" + if [[ "${WHISPERCPP_BACKEND}" == "gpu" ]]; then + echo " Note: GPU build will compile from source (2-5 minutes)" + fi + fi + if [[ "$SELECTED_ENGINE" == "remote_api" ]]; then + echo " Remote Server: $REMOTE_DISPLAY" + fi + echo " Models: $MODELS_DISPLAY" + echo " Install Location: ${INSTALL_DIR:-\$HOME/.local/share/vocalinux}" + echo "" + read -p "Press Enter to continue with installation, or Ctrl+C to cancel..." + echo "" +} diff --git a/install.d/models.sh b/install.d/models.sh new file mode 100644 index 0000000..ebd6549 --- /dev/null +++ b/install.d/models.sh @@ -0,0 +1,451 @@ +#!/bin/bash +# Sourced by install.sh after it has resolved the local or tagged repository. +# Keep this file declarative: function definitions only, with no top-level actions. + +# Model integrity verification +# +# Models are 40MB-2GB downloads that end up being loaded by native code, so no +# model is installed without matching a digest pinned in this repository. +# src/vocalinux/utils/model_checksums.txt is the same manifest the application +# uses at runtime; it is regenerated by scripts/generate-model-checksums.py. +# +# A model that cannot be verified is deleted and the function returns 1, which +# callers already treat as "leave it for first run" rather than aborting the +# install. The app re-downloads and re-verifies it later. +# --------------------------------------------------------------------------- + +# Print the digest of $1 using algorithm $2, trying the tools most likely present. +compute_file_digest() { + local file="$1" algo="$2" + + case "$algo" in + sha256) + if command_exists sha256sum; then sha256sum "$file" | cut -d' ' -f1 + elif command_exists shasum; then shasum -a 256 "$file" | cut -d' ' -f1 + elif command_exists openssl; then openssl dgst -sha256 "$file" | awk '{print $NF}' + else return 1; fi + ;; + *) + return 1 + ;; + esac +} + +# Compare $1 against algorithm $2 and digest $3. $4 labels the file in messages. +verify_digest() { + local file="$1" algo="$2" expected="$3" label="$4" actual + + if ! actual=$(compute_file_digest "$file" "$algo") || [ -z "$actual" ]; then + print_error "Cannot compute the $algo digest of $label: no sha256sum, shasum or openssl found." + return 1 + fi + + if [ "$actual" != "$expected" ]; then + print_error "$label failed $algo verification." + print_error " expected: $expected" + print_error " actual: $actual" + print_error "The file does not match the digest pinned in this release." + return 1 + fi + + print_success "$label verified ($algo)" + return 0 +} + +# Verify $1 against the manifest entry named $2 (defaults to $1's basename). +verify_model_checksum() { + local file="$1" + local key="${2:-$(basename "$file")}" + local algo expected size actual_size + + if [ ! -f "$MODEL_CHECKSUMS_FILE" ]; then + print_error "Checksum manifest not found at $MODEL_CHECKSUMS_FILE." + print_error "Cannot verify $key; refusing to install an unverified model." + return 1 + fi + + # Manifest columns: filename algorithm digest size-in-bytes + algo=$(awk -v k="$key" '$1==k {print $2; exit}' "$MODEL_CHECKSUMS_FILE") + expected=$(awk -v k="$key" '$1==k {print $3; exit}' "$MODEL_CHECKSUMS_FILE") + size=$(awk -v k="$key" '$1==k {print $4; exit}' "$MODEL_CHECKSUMS_FILE") + + if [ -z "$algo" ] || [ -z "$expected" ]; then + print_error "No checksum is pinned for $key in $MODEL_CHECKSUMS_FILE." + print_error "Regenerate it with scripts/generate-model-checksums.py." + return 1 + fi + + # Size first: it is free, and it reports a truncated download as truncation + # rather than as a digest mismatch that reads like tampering. + if [ -n "$size" ] && [ "$size" != "0" ]; then + actual_size=$(stat -c%s "$file" 2>/dev/null || stat -f%z "$file" 2>/dev/null || echo "") + if [ -n "$actual_size" ] && [ "$actual_size" != "$size" ]; then + print_error "$key is $actual_size bytes, expected $size (truncated download?)." + return 1 + fi + fi + + verify_digest "$file" "$algo" "$expected" "$key" +} + +# Verify an OpenAI Whisper checkpoint against the sha256 embedded in its own URL +# (they publish each file under a path segment that is its digest). +verify_openai_model_checksum() { + local file="$1" url="$2" label="$3" expected + + expected=$(printf '%s\n' "$url" | grep -oE '/[0-9a-f]{64}/' | tr -d '/' | head -n1) + if [ -z "$expected" ]; then + print_error "$url carries no sha256 path segment; refusing to install an unverified model." + return 1 + fi + + verify_digest "$file" "sha256" "$expected" "$label" +} + +# Download $1 to $2, preferring wget and falling back to curl. $3 labels the +# model in error messages. Leaves no partial file behind on failure. +download_model_file() { + local url="$1" dest="$2" label="$3" + + if command_exists wget; then + if ! wget --progress=bar:force:noscroll --tries=3 --timeout=60 -O "$dest" "$url" 2>&1; then + print_error "Failed to download $label with wget" + rm -f "$dest" + return 1 + fi + elif command_exists curl; then + # -f: fail on HTTP errors instead of saving the error page as the model + if ! curl -fL --progress-bar --retry 3 --retry-delay 2 -o "$dest" "$url"; then + print_error "Failed to download $label with curl" + rm -f "$dest" + return 1 + fi + else + print_error "Neither wget nor curl is available to download $label" + return 1 + fi + + if [ ! -s "$dest" ]; then + print_error "Downloaded $label is empty or missing" + rm -f "$dest" + return 1 + fi +} + +# Print the digest pinned for manifest entry $1, or nothing when unpinned. +pinned_digest_for() { + [ -f "$MODEL_CHECKSUMS_FILE" ] || return 1 + awk -v k="$1" '$1==k {print $3; exit}' "$MODEL_CHECKSUMS_FILE" +} + +# The Hugging Face commit the whisper.cpp digests were taken at. +whispercpp_pinned_revision() { + [ -f "$MODEL_CHECKSUMS_FILE" ] || return 1 + awk '/^#[[:space:]]*whispercpp-revision:/ {print $3; exit}' "$MODEL_CHECKSUMS_FILE" +} + +# False when the cloned release predates the manifest. That app has no runtime +# verification either, so refusing to pre-download would protect nothing. +model_verification_available() { + [ -f "$MODEL_CHECKSUMS_FILE" ] +} + +# Function to download and install Whisper tiny model +install_whisper_model() { + print_info "Installing Whisper tiny model (~75MB)..." + + # Create whisper models directory + local WHISPER_DIR="$DATA_DIR/models/whisper" + mkdir -p "$WHISPER_DIR" + + # Whisper tiny model URL and path + local TINY_MODEL_URL="https://openaipublic.azureedge.net/main/whisper/models/65147644a518d12f04e32d6f3b26facc3f8dd46e5390956a9424a650c0ce22b9/tiny.pt" + local TINY_MODEL_PATH="$WHISPER_DIR/tiny.pt" + + # An existing file is not a verified file: it may predate checksum + # verification, or have been replaced since. Hash it before trusting it, + # and re-download rather than keep something that does not match. + if [ -f "$TINY_MODEL_PATH" ]; then + if verify_openai_model_checksum "$TINY_MODEL_PATH" "$TINY_MODEL_URL" "Whisper tiny model"; then + print_info "Whisper tiny model already exists at $TINY_MODEL_PATH" + return 0 + fi + print_warning "The existing Whisper tiny model does not match its pinned digest; replacing it." + rm -f "$TINY_MODEL_PATH" + fi + + # Check internet connectivity + if ! command -v wget >/dev/null 2>&1 && ! command -v curl >/dev/null 2>&1; then + print_warning "Neither wget nor curl found. Cannot download Whisper model." + print_warning "Model will be downloaded on first application run." + return 1 + fi + + # Test internet connectivity (HTTP probes; ICMP ping is often blocked) + if ! check_connectivity; then + print_warning "No internet connection detected (HTTP probes to pypi.org/api.github.com/huggingface.co failed)." + print_warning "Whisper model will be downloaded on first application run." + return 1 + fi + + print_info "Downloading Whisper tiny model..." + print_info "This may take a few minutes depending on your internet connection." + + local TEMP_FILE="$VOCALINUX_TMP_DIR/tiny.pt" + + if ! download_model_file "$TINY_MODEL_URL" "$TEMP_FILE" "Whisper model"; then + return 1 + fi + + # Verify before the file reaches its final location, so a failed download is + # never installed. (The "already exists" path above hashes what it finds.) + if ! verify_openai_model_checksum "$TEMP_FILE" "$TINY_MODEL_URL" "Whisper tiny model"; then + rm -f "$TEMP_FILE" + print_warning "Whisper model will be downloaded and verified on first application run." + return 1 + fi + + # Move to final location + mv "$TEMP_FILE" "$TINY_MODEL_PATH" + + # Verify the model file + if [ -f "$TINY_MODEL_PATH" ]; then + local MODEL_SIZE=$(du -h "$TINY_MODEL_PATH" | cut -f1) + print_success "Whisper tiny model installed successfully ($MODEL_SIZE)" + + # Create a marker file to indicate this model was pre-installed + echo "$(date)" > "$WHISPER_DIR/.vocalinux_preinstalled" + + return 0 + else + print_error "Whisper model installation failed" + return 1 + fi +} + +# Function to download and install VOSK models +install_vosk_models() { + print_info "Installing VOSK speech recognition models..." + + # Create models directory + local MODELS_DIR="$DATA_DIR/models" + mkdir -p "$MODELS_DIR" + + # Define model information + local SMALL_MODEL_URL="https://alphacephei.com/vosk/models/vosk-model-small-en-us-0.15.zip" + local SMALL_MODEL_NAME="vosk-model-small-en-us-0.15" + local SMALL_MODEL_PATH="$MODELS_DIR/$SMALL_MODEL_NAME" + + local SMALL_MODEL_ARCHIVE="$SMALL_MODEL_NAME.zip" + # The extracted tree has no digest of its own — the pin covers the zip, which + # is deleted after unpacking. So whoever unpacks it records the verified zip + # digest in a stamp (here, and in the app's own downloader), and the directory + # is trusted only while that stamp matches what we pin today. + local SMALL_MODEL_STAMP="$SMALL_MODEL_PATH/.vocalinux_verified" + local EXPECTED_ZIP_DIGEST + EXPECTED_ZIP_DIGEST=$(pinned_digest_for "$SMALL_MODEL_ARCHIVE" || true) + if [ -d "$SMALL_MODEL_PATH" ]; then + if [ -n "$EXPECTED_ZIP_DIGEST" ] && [ -f "$SMALL_MODEL_STAMP" ] && + [ "$(cat "$SMALL_MODEL_STAMP" 2>/dev/null)" = "$EXPECTED_ZIP_DIGEST" ]; then + print_info "Small VOSK model already exists at $SMALL_MODEL_PATH (verified)" + return 0 + fi + # Unverified is not the same as known-bad, and this runs on every install: + # a tree unpacked before stamps existed, or by the app itself, simply has + # no stamp. Deleting it here would trade a working model for no model + # whenever the download, unzip or network that follows fails. Fetch and + # verify the replacement first; the swap below is what removes this tree. + print_warning "The existing VOSK model carries no matching verification stamp; re-downloading it." + fi + + # Refuse before spending the download, not after verification rejects it. + if [ -z "$EXPECTED_ZIP_DIGEST" ]; then + print_error "No checksum is pinned for $SMALL_MODEL_ARCHIVE in $MODEL_CHECKSUMS_FILE." + print_warning "VOSK model will be downloaded and verified on first application run." + return 1 + fi + + # Check internet connectivity + if ! command -v wget >/dev/null 2>&1 && ! command -v curl >/dev/null 2>&1; then + print_warning "Neither wget nor curl found. Cannot download VOSK models." + print_warning "Models will be downloaded on first application run." + return 1 + fi + + # Test internet connectivity (HTTP probes; ICMP ping is often blocked) + if ! check_connectivity; then + print_warning "No internet connection detected (HTTP probes to pypi.org/api.github.com/huggingface.co failed)." + print_warning "VOSK models will be downloaded on first application run." + return 1 + fi + + print_info "Downloading small VOSK model (approximately 40MB)..." + print_info "This may take a few minutes depending on your internet connection." + + local TEMP_ZIP="$VOCALINUX_TMP_DIR/$(basename $SMALL_MODEL_URL)" + + if ! download_model_file "$SMALL_MODEL_URL" "$TEMP_ZIP" "VOSK model"; then + return 1 + fi + + # Verify before extracting: a zip that fails its pinned digest must not get + # as far as writing files into the models directory. + if ! verify_model_checksum "$TEMP_ZIP"; then + rm -f "$TEMP_ZIP" + print_warning "VOSK model will be downloaded and verified on first application run." + return 1 + fi + + print_info "Extracting VOSK model..." + + if ! command -v unzip >/dev/null 2>&1; then + print_error "unzip command not found. Cannot extract VOSK model." + rm -f "$TEMP_ZIP" + return 1 + fi + + # Unpack beside the model rather than over it: any model already installed + # stays usable until a complete, verified replacement exists, and staging in + # MODELS_DIR keeps the swap a same-filesystem rename. + local STAGING_DIR="$MODELS_DIR/.vosk-staging.$$" + # A run killed mid-swap leaves its scratch directories behind; they are named + # so they can be recognised and are of no use to a later run. + find "$MODELS_DIR" -maxdepth 1 -type d \ + \( -name '.vosk-staging.*' -o -name '.*.replaced.*' \) \ + -exec rm -rf {} + 2>/dev/null || true + if ! mkdir -p "$STAGING_DIR"; then + print_error "Failed to create a staging directory under $MODELS_DIR" + rm -f "$TEMP_ZIP" + return 1 + fi + + if ! unzip -q "$TEMP_ZIP" -d "$STAGING_DIR"; then + print_error "Failed to extract VOSK model" + rm -f "$TEMP_ZIP" + rm -rf "$STAGING_DIR" + return 1 + fi + rm -f "$TEMP_ZIP" + + if [ ! -d "$STAGING_DIR/$SMALL_MODEL_NAME" ]; then + print_error "VOSK model extraction failed - $SMALL_MODEL_NAME not found in the archive" + rm -rf "$STAGING_DIR" + return 1 + fi + + chmod -R 755 "$STAGING_DIR/$SMALL_MODEL_NAME" + echo "$(date)" > "$STAGING_DIR/$SMALL_MODEL_NAME/.vocalinux_preinstalled" + + # Record the digest this tree was extracted from, so a later run can tell a + # verified model from one that merely exists. Written before the swap: an + # unstamped tree is one this function would download all over again. + if [ -z "$EXPECTED_ZIP_DIGEST" ] || + ! printf '%s\n' "$EXPECTED_ZIP_DIGEST" > "$STAGING_DIR/$SMALL_MODEL_NAME/.vocalinux_verified"; then + print_error "Could not record the verified digest for $SMALL_MODEL_NAME" + rm -rf "$STAGING_DIR" + return 1 + fi + + # Swap. The old tree is moved aside rather than deleted, so a failed rename + # can put it back. + local REPLACED_DIR="$MODELS_DIR/.$SMALL_MODEL_NAME.replaced.$$" + if [ -d "$SMALL_MODEL_PATH" ] && ! mv "$SMALL_MODEL_PATH" "$REPLACED_DIR"; then + print_error "Could not move the existing VOSK model aside; keeping it" + rm -rf "$STAGING_DIR" + return 1 + fi + if ! mv "$STAGING_DIR/$SMALL_MODEL_NAME" "$SMALL_MODEL_PATH"; then + print_error "Failed to install the verified VOSK model" + if [ -d "$REPLACED_DIR" ]; then + mv "$REPLACED_DIR" "$SMALL_MODEL_PATH" + fi + rm -rf "$STAGING_DIR" + return 1 + fi + rm -rf "$REPLACED_DIR" "$STAGING_DIR" + + print_success "VOSK small model installed successfully at $SMALL_MODEL_PATH" + return 0 +} + +# Function to download and install whisper.cpp tiny model +install_whispercpp_model() { + print_info "Installing whisper.cpp tiny model (~39MB)..." + + # Create whisper.cpp models directory + local WHISPERCPP_DIR="$DATA_DIR/models/whispercpp" + mkdir -p "$WHISPERCPP_DIR" + + # whisper.cpp tiny model URL and path. The Hugging Face revision is pinned to + # the one the digests in model_checksums.txt were taken at, so upstream + # replacing ggml-tiny.bin cannot turn every install into a checksum failure. + local WHISPERCPP_REVISION + WHISPERCPP_REVISION=$(whispercpp_pinned_revision) + if [ -z "$WHISPERCPP_REVISION" ]; then + print_error "No whisper.cpp revision is pinned in $MODEL_CHECKSUMS_FILE." + print_warning "whisper.cpp model will be downloaded and verified on first application run." + return 1 + fi + local TINY_MODEL_URL="https://huggingface.co/ggerganov/whisper.cpp/resolve/$WHISPERCPP_REVISION/ggml-tiny.bin" + local TINY_MODEL_PATH="$WHISPERCPP_DIR/ggml-tiny.bin" + + # An existing file is not a verified file: it may predate checksum + # verification, or have been replaced since. Hash it before trusting it, + # and re-download rather than keep something that does not match. + if [ -f "$TINY_MODEL_PATH" ]; then + if verify_model_checksum "$TINY_MODEL_PATH" "ggml-tiny.bin"; then + print_info "whisper.cpp tiny model already exists at $TINY_MODEL_PATH" + return 0 + fi + print_warning "The existing whisper.cpp tiny model does not match its pinned digest; replacing it." + rm -f "$TINY_MODEL_PATH" + fi + + # Check internet connectivity + if ! command -v wget >/dev/null 2>&1 && ! command -v curl >/dev/null 2>&1; then + print_warning "Neither wget nor curl found. Cannot download whisper.cpp model." + print_warning "Model will be downloaded on first application run." + return 1 + fi + + # Test internet connectivity (HTTP probes; ICMP ping is often blocked) + if ! check_connectivity; then + print_warning "No internet connection detected (HTTP probes to pypi.org/api.github.com/huggingface.co failed)." + print_warning "whisper.cpp model will be downloaded on first application run." + return 1 + fi + + print_info "Downloading whisper.cpp tiny model..." + print_info "This may take a few minutes depending on your internet connection." + + local TEMP_FILE="$VOCALINUX_TMP_DIR/ggml-tiny.bin" + + if ! download_model_file "$TINY_MODEL_URL" "$TEMP_FILE" "whisper.cpp model"; then + return 1 + fi + + # Verify before the file reaches its final location, so a failed download is + # never installed. (The "already exists" path above hashes what it finds.) + if ! verify_model_checksum "$TEMP_FILE" "ggml-tiny.bin"; then + rm -f "$TEMP_FILE" + print_warning "whisper.cpp model will be downloaded and verified on first application run." + return 1 + fi + + # Move to final location + mv "$TEMP_FILE" "$TINY_MODEL_PATH" + + # Verify the model file + if [ -f "$TINY_MODEL_PATH" ]; then + local MODEL_SIZE=$(du -h "$TINY_MODEL_PATH" | cut -f1) + print_success "whisper.cpp tiny model installed successfully ($MODEL_SIZE)" + + # Create a marker file to indicate this model was pre-installed + echo "$(date)" > "$WHISPERCPP_DIR/.vocalinux_preinstalled" + + return 0 + else + print_error "whisper.cpp model installation failed" + return 1 + fi +} diff --git a/install.d/system_dependencies.sh b/install.d/system_dependencies.sh new file mode 100644 index 0000000..a08ee24 --- /dev/null +++ b/install.d/system_dependencies.sh @@ -0,0 +1,872 @@ +#!/bin/bash +# Sourced by install.sh after it has resolved the local or tagged repository. +# Keep this file declarative: function definitions only, with no top-level actions. + +# Function to check if a command exists +command_exists() { + command -v "$1" >/dev/null 2>&1 +} + +# Function to check if a package is installed (for apt-based systems) +apt_package_installed() { + dpkg -s "$1" >/dev/null 2>&1 +} + +# Function to check if a package is installed (for dnf-based systems) +dnf_package_installed() { + rpm -q "$1" >/dev/null 2>&1 +} + +# Function to check if a package is installed (for pacman-based systems) +pacman_package_installed() { + pacman -Q "$1" >/dev/null 2>&1 +} + +# Install an AppIndicator/StatusNotifierItem provider, preferring the +# actively maintained Ayatana fork over the legacy Canonical package +# (unmaintained since ~2013). The legacy package can install and import +# without error yet silently fail to register a tray icon with KDE's +# StatusNotifierWatcher, leaving no icon and no logged error. $1 is the +# package-manager install command (e.g. "sudo dnf install -y"), $2/$3 are +# the Ayatana/legacy package names for that package manager, $4 is the +# "is this package installed" checker function name. +install_preferred_appindicator() { + local install_cmd="$1" + local ayatana_pkg="$2" + local legacy_pkg="$3" + local checker="$4" + + if "$checker" "$ayatana_pkg"; then + return 0 + fi + + if $install_cmd "$ayatana_pkg" 2>/dev/null; then + print_info "Installed $ayatana_pkg (Ayatana AppIndicator; required for a working KDE tray icon)" + return 0 + fi + + if "$checker" "$legacy_pkg"; then + print_info "$ayatana_pkg not available; $legacy_pkg is already installed but may not show a tray icon on KDE Plasma" + return 0 + fi + + print_info "$ayatana_pkg not available, falling back to $legacy_pkg (may not show a tray icon on KDE Plasma)..." + if $install_cmd "$legacy_pkg"; then + return 0 + fi + + print_error "Failed to install an AppIndicator package (tried $ayatana_pkg and $legacy_pkg)" + return 1 +} + +suse_python_package_prefix() { + python3 -c 'import sys; print(f"python{sys.version_info.major}{sys.version_info.minor}")' 2>/dev/null || echo "python3" +} + +suse_python_package_candidates() { + local suffix="$1" + local PY_PREFIX + PY_PREFIX=$(suse_python_package_prefix) + + if [[ "$PY_PREFIX" != "python3" ]]; then + echo "${PY_PREFIX}-${suffix} python3-${suffix}" + else + echo "python3-${suffix}" + fi +} + +suse_package_installed() { + rpm -q "$1" >/dev/null 2>&1 +} + +suse_install_first_available() { + local DESCRIPTION="$1" + shift + + local PKG + for PKG in "$@"; do + [ -z "$PKG" ] && continue + + if suse_package_installed "$PKG"; then + print_info "$DESCRIPTION is already installed ($PKG)." + return 0 + fi + + if sudo zypper install -y "$PKG" 2>/dev/null; then + print_success "Installed $DESCRIPTION ($PKG)." + return 0 + fi + + print_info "$DESCRIPTION package '$PKG' not available, trying next option..." + done + + return 1 +} + +suse_appindicator_gi_available() { + python3 - <<'PY' >/dev/null 2>&1 +import importlib +import gi + +for namespace in ("AppIndicator3", "AyatanaAppIndicator3", "AyatanaAppindicator3"): + try: + gi.require_version(namespace, "0.1") + importlib.import_module(f"gi.repository.{namespace}") + raise SystemExit(0) + except (ImportError, ValueError): + pass + +raise SystemExit(1) +PY +} + +suse_install_appindicator_runtime() { + local APPINDICATOR_PACKAGES=( + "typelib-1_0-AyatanaAppIndicator3-0_1" + "typelib-1_0-AppIndicator3-0_1" + "typelib-1_0-AyatanaAppIndicator-0_1" + "libayatana-appindicator3-1" + "libappindicator3-1" + "libappindicator-gtk3" + ) + + if suse_appindicator_gi_available; then + print_info "AppIndicator/Ayatana GI namespace is already available." + return 0 + fi + + local PKG + for PKG in "${APPINDICATOR_PACKAGES[@]}"; do + if suse_package_installed "$PKG"; then + print_info "AppIndicator/Ayatana package is already installed ($PKG); verifying GI namespace..." + elif sudo zypper install -y "$PKG" 2>/dev/null; then + print_success "Installed AppIndicator/Ayatana package ($PKG)." + # Refresh the shared-library cache so the GI typelib is discoverable + sudo ldconfig 2>/dev/null || true + else + print_info "AppIndicator/Ayatana package '$PKG' not available, trying next option..." + continue + fi + + if suse_appindicator_gi_available; then + print_success "AppIndicator/Ayatana GI namespace is available." + return 0 + fi + done + + return 1 +} + +suse_shader_compiler_available() { + command_exists glslc || command_exists glslangValidator +} + +# Function to install system dependencies based on the detected distribution +install_system_dependencies() { + print_info "Installing system dependencies..." + + # Determine which Vulkan shader package is available (glslc for Ubuntu 24.04+, glslang-tools for 22.04) + local VULKAN_SHADER_PKG="glslang-tools" # Default fallback + if apt-cache show glslc &>/dev/null 2>&1; then + VULKAN_SHADER_PKG="glslc" + fi + + # Define package names for different distributions + # GObject Introspection / GLib headers for building PyGObject and friends. + # Prefer libgirepository-2.0-dev when available (Ubuntu 24.04+, Pop!_OS Cosmic+, + # Debian 13+). When both 1.0 and 2.0 packages exist, install both: 2.0 provides + # the modern GLib GI headers that pip builds need, while 1.0 still pulls + # gobject-introspection tooling. Older distros that only ship 1.0 keep that. + # See #571 (installer previously kept 1.0 whenever apt-cache still listed it). + local GI_DEV_PKG="libgirepository1.0-dev" + if apt-cache show libgirepository-2.0-dev &>/dev/null 2>&1; then + if apt-cache show libgirepository1.0-dev &>/dev/null 2>&1; then + GI_DEV_PKG="libgirepository-2.0-dev libgirepository1.0-dev" + else + GI_DEV_PKG="libgirepository-2.0-dev" + fi + fi + + # libssl-dev, autoconf, automake, libtool, patchelf are required for pywhispercpp source + # builds on Debian. On Ubuntu these are typically pulled in transitively, but on a clean + # Debian install they are absent and cause CMake's bootstrap to fail (Hurdle 2 from + # https://medium.com/@cslev/talking-to-my-linux-box-without-talking-to-the-cloud-vocalinux-on-debian-without-the-tears-10bf053ea21b). + local PYWHISPERCPP_BUILD_DEPS="libssl-dev autoconf automake libtool patchelf" + local APT_PACKAGES_UBUNTU="python3-pip python3-gi python3-gi-cairo gir1.2-gtk-3.0 gir1.2-appindicator3-0.1 gir1.2-ibus-1.0 $GI_DEV_PKG libcairo2-dev cmake python3-dev build-essential portaudio19-dev python3-venv pkg-config wget curl unzip vulkan-tools libvulkan-dev $VULKAN_SHADER_PKG xclip xsel wl-clipboard $PYWHISPERCPP_BUILD_DEPS" + local APT_PACKAGES_DEBIAN_BASE="python3-pip python3-gi python3-gi-cairo gir1.2-gtk-3.0 gir1.2-ibus-1.0 libcairo2-dev cmake python3-dev build-essential portaudio19-dev python3-venv pkg-config wget curl unzip vulkan-tools libvulkan-dev $VULKAN_SHADER_PKG xclip xsel wl-clipboard $PYWHISPERCPP_BUILD_DEPS" + local APT_PACKAGES_DEBIAN_11_12="$APT_PACKAGES_DEBIAN_BASE libgirepository1.0-dev gir1.2-ayatanaappindicator3-0.1" + local APT_PACKAGES_DEBIAN_13_PLUS="$APT_PACKAGES_DEBIAN_BASE libgirepository-2.0-dev gir1.2-ayatanaappindicator3-0.1" + # cairo-devel: the pinned pycairo builds from source; Fedora splits its headers/.pc out. + local DNF_PACKAGES="python3-pip python3-gobject gtk3 ibus-devel gobject-introspection-devel python3-devel portaudio-devel cairo-devel python3-virtualenv pkg-config cmake wget curl unzip vulkan-tools vulkan-loader-devel glslc patchelf xclip xsel wl-clipboard" + local PACMAN_PACKAGES="python-pip python-gobject gtk3 ibus gobject-introspection python-cairo portaudio python-virtualenv pkg-config cmake wget curl unzip base-devel vulkan-tools vulkan-headers shaderc patchelf xclip xsel wl-clipboard" + # ibus + typelib-1_0-IBus-1_0, not ibus-devel: the headers are not needed + # (IBus is reached through GI at runtime), and requiring them pulls gtk-doc, + # which zypper cannot satisfy when awk resolves to busybox-gawk. The runtime + # package is needed: ibus_engine.py spawns `ibus-daemon -x -d -r` and shells + # out to `ibus engine`, and ibus-devel used to pull it in transitively + # (Requires: ibus = %version). The typelib is named as well as implied by + # ibus's own typelib(IBus) require, so the GI dependency stays visible here. + # gcc/gcc-c++/make: the counterpart of build-essential and base-devel above. + # python3-devel supplies headers, not a compiler, and evdev and pyaudio ship + # no wheels, so without these they have nothing to build with. + local ZYPPER_PACKAGES="gtk3 ibus typelib-1_0-IBus-1_0 gobject-introspection-devel portaudio-devel pkg-config cmake gcc gcc-c++ make wget curl unzip xclip xsel wl-clipboard typelib-1_0-Notify-0_7 libnotify4 patchelf" + # Gentoo uses Portage and different package naming convention + local EMERGE_PACKAGES="dev-python/pygobject:3 x11-libs/gtk+:3 dev-libs/libayatana-appindicator media-libs/portaudio dev-lang/python:3.11 pkgconf cmake media-libs/shaderc dev-util/patchelf x11-misc/xclip x11-misc/xsel gui-apps/wl-clipboard" + # Alpine Linux uses apk and has musl libc + local APK_PACKAGES="py3-gobject3 py3-pip gtk+3.0 py3-cairo portaudio-dev py3-virtualenv pkgconf cmake wget curl unzip shaderc patchelf vulkan-tools xclip xsel wl-clipboard" + # Void Linux uses xbps + local XBPS_PACKAGES="python3-pip python3-gobject gtk+3 libappindicator-gtk3 gobject-introspection portaudio-devel python3-devel pkg-config cmake wget curl unzip shaderc patchelf Vulkan-Tools xclip xsel wl-clipboard" + # Solus uses eopkg + local EOPKG_PACKAGES="python3-pip python3-gobject gtk3 libappindicator gobject-introspection-devel portaudio-devel python3-virtualenv pkg-config cmake wget curl unzip shaderc patchelf vulkan-tools xclip xsel wl-clipboard" + + local MISSING_PACKAGES="" + local INSTALL_CMD="" + local UPDATE_CMD="" + + case "$DISTRO_FAMILY" in + ubuntu|debian) + local APT_PACKAGES="$APT_PACKAGES_UBUNTU" + if [[ "$DISTRO_FAMILY" == "debian" ]]; then + local DEBIAN_MAJOR="${DISTRO_VERSION%%.*}" + if [[ "$DEBIAN_MAJOR" =~ ^[0-9]+$ ]] && [ "$DEBIAN_MAJOR" -ge 13 ]; then + APT_PACKAGES="$APT_PACKAGES_DEBIAN_13_PLUS" + else + APT_PACKAGES="$APT_PACKAGES_DEBIAN_11_12" + fi + fi + + # util-linux-extra only exists on newer Ubuntu/Debian (24.04+, Debian 13+). + # On 22.04 those tools ship in the main util-linux package (always installed). + # apt-cache probe handles Ubuntu derivatives (Mint, Zorin) whose VERSION_ID + # does not track the Ubuntu base release. + if apt-cache show util-linux-extra &>/dev/null 2>&1; then + APT_PACKAGES="$APT_PACKAGES util-linux-extra" + fi + + # Check for missing packages + for pkg in $APT_PACKAGES; do + if ! apt_package_installed "$pkg"; then + MISSING_PACKAGES="$MISSING_PACKAGES $pkg" + fi + done + + if [ -n "$MISSING_PACKAGES" ]; then + print_info "Installing missing packages:$MISSING_PACKAGES" + sudo apt update || { print_error "Failed to update package lists"; exit "$EXIT_NETWORK"; } + + # Handle appindicator package for Ubuntu (old package deprecated in newer releases) + if echo "$MISSING_PACKAGES" | grep -q "gir1.2-appindicator3-0.1"; then + FILTERED_PACKAGES=$(echo "$MISSING_PACKAGES" | sed 's/gir1.2-appindicator3-0.1//' | xargs) + + if ! DEBIAN_FRONTEND=noninteractive sudo apt install -y gir1.2-appindicator3-0.1 2>/dev/null; then + print_info "gir1.2-appindicator3-0.1 not available, trying gir1.2-ayatanaappindicator3-0.1..." + if ! DEBIAN_FRONTEND=noninteractive sudo apt install -y gir1.2-ayatanaappindicator3-0.1; then + print_error "Failed to install appindicator package (tried both gir1.2-appindicator3-0.1 and gir1.2-ayatanaappindicator3-0.1)" + exit "$EXIT_MISSING_DEPS" + fi + print_info "Successfully installed gir1.2-ayatanaappindicator3-0.1 (modern replacement)" + fi + + if [ -n "$FILTERED_PACKAGES" ]; then + DEBIAN_FRONTEND=noninteractive sudo apt install -y $FILTERED_PACKAGES || { print_error "Failed to install dependencies"; exit "$EXIT_MISSING_DEPS"; } + fi + else + DEBIAN_FRONTEND=noninteractive sudo apt install -y $MISSING_PACKAGES || { print_error "Failed to install dependencies"; exit "$EXIT_MISSING_DEPS"; } + fi + else + print_info "All required packages are already installed." + fi + ;; + + fedora) + # For Fedora/RHEL-based systems + if command_exists dnf; then + INSTALL_CMD="sudo dnf install -y" + UPDATE_CMD="sudo dnf check-update" + elif command_exists yum; then + INSTALL_CMD="sudo yum install -y" + UPDATE_CMD="sudo yum check-update" + else + print_error "No supported package manager found (dnf/yum)" + exit "$EXIT_MISSING_DEPS" + fi + + # Check for missing packages + for pkg in $DNF_PACKAGES; do + if ! dnf_package_installed "$pkg"; then + MISSING_PACKAGES="$MISSING_PACKAGES $pkg" + fi + done + + if [ -n "$MISSING_PACKAGES" ]; then + print_info "Installing missing packages:$MISSING_PACKAGES" + $UPDATE_CMD || true # dnf check-update returns 100 if updates available + $INSTALL_CMD $MISSING_PACKAGES || { print_error "Failed to install dependencies"; exit "$EXIT_MISSING_DEPS"; } + else + print_info "All required packages are already installed." + fi + + install_preferred_appindicator "$INSTALL_CMD" "libayatana-appindicator-gtk3" "libappindicator-gtk3" dnf_package_installed || exit "$EXIT_MISSING_DEPS" + ;; + + arch) + # For Arch-based systems + if ! command_exists pacman; then + print_error "Pacman package manager not found" + exit "$EXIT_MISSING_DEPS" + fi + + # Check for missing packages + for pkg in $PACMAN_PACKAGES; do + if ! pacman_package_installed "$pkg"; then + MISSING_PACKAGES="$MISSING_PACKAGES $pkg" + fi + done + + if [ -n "$MISSING_PACKAGES" ]; then + print_info "Installing missing packages:$MISSING_PACKAGES" + sudo pacman -Sy + sudo pacman -S --noconfirm $MISSING_PACKAGES || { print_error "Failed to install dependencies"; exit "$EXIT_MISSING_DEPS"; } + else + print_info "All required packages are already installed." + fi + + install_preferred_appindicator "sudo pacman -S --noconfirm" "libayatana-appindicator" "libappindicator-gtk3" pacman_package_installed || exit "$EXIT_MISSING_DEPS" + ;; + + suse) + # For openSUSE + if ! command_exists zypper; then + print_error "Zypper package manager not found" + exit "$EXIT_MISSING_DEPS" + fi + + sudo zypper refresh || true + + if [[ "${SELECTED_ENGINE:-whisper_cpp}" == "whisper_cpp" && "${WHISPERCPP_BACKEND:-}" != "cpu" ]]; then + ZYPPER_PACKAGES="$ZYPPER_PACKAGES vulkan-tools vulkan-devel" + fi + + local MISSING_ZYPPER_PACKAGES=() + for pkg in $ZYPPER_PACKAGES; do + if ! suse_package_installed "$pkg"; then + MISSING_ZYPPER_PACKAGES+=("$pkg") + fi + done + + if [ "${#MISSING_ZYPPER_PACKAGES[@]}" -gt 0 ]; then + print_info "Installing missing packages: ${MISSING_ZYPPER_PACKAGES[*]}" + sudo zypper install -y "${MISSING_ZYPPER_PACKAGES[@]}" || { + print_error "Failed to install openSUSE base dependencies" + exit "$EXIT_MISSING_DEPS" + } + else + print_info "All base openSUSE packages are already installed." + fi + + local PY_PIP_CANDIDATES=() + local PY_GOBJECT_CANDIDATES=() + local PY_GOBJECT_CAIRO_CANDIDATES=() + local PY_DEVEL_CANDIDATES=() + local PY_VIRTUALENV_CANDIDATES=() + local PY_VENV_CANDIDATES=() + + read -r -a PY_PIP_CANDIDATES <<< "$(suse_python_package_candidates "pip")" + read -r -a PY_GOBJECT_CANDIDATES <<< "$(suse_python_package_candidates "gobject")" + read -r -a PY_GOBJECT_CAIRO_CANDIDATES <<< "$(suse_python_package_candidates "gobject-cairo")" + read -r -a PY_DEVEL_CANDIDATES <<< "$(suse_python_package_candidates "devel")" + read -r -a PY_VIRTUALENV_CANDIDATES <<< "$(suse_python_package_candidates "virtualenv")" + read -r -a PY_VENV_CANDIDATES <<< "$(suse_python_package_candidates "venv")" + + print_info "Resolving openSUSE Python packages for $(suse_python_package_prefix)..." + + if ! suse_install_first_available "Python pip" "${PY_PIP_CANDIDATES[@]}"; then + print_error "Failed to install Python pip package (tried: ${PY_PIP_CANDIDATES[*]})" + exit "$EXIT_MISSING_DEPS" + fi + + if ! suse_install_first_available "PyGObject bindings" "${PY_GOBJECT_CANDIDATES[@]}"; then + print_error "Failed to install PyGObject package (tried: ${PY_GOBJECT_CANDIDATES[*]})" + exit "$EXIT_MISSING_DEPS" + fi + + if ! suse_install_first_available "PyGObject Cairo bindings" "${PY_GOBJECT_CAIRO_CANDIDATES[@]}"; then + print_error "Failed to install PyGObject Cairo package (tried: ${PY_GOBJECT_CAIRO_CANDIDATES[*]})" + exit "$EXIT_MISSING_DEPS" + fi + + if ! suse_install_first_available "Python development headers" "${PY_DEVEL_CANDIDATES[@]}"; then + print_error "Failed to install Python development headers (tried: ${PY_DEVEL_CANDIDATES[*]})" + exit "$EXIT_MISSING_DEPS" + fi + + if ! suse_install_first_available "Python virtualenv/venv" "${PY_VIRTUALENV_CANDIDATES[@]}" "${PY_VENV_CANDIDATES[@]}"; then + print_warning "Python virtualenv/venv package was not found (tried: ${PY_VIRTUALENV_CANDIDATES[*]} ${PY_VENV_CANDIDATES[*]})" + print_warning "Continuing because python3 -m venv may still be available." + fi + + if ! suse_install_appindicator_runtime; then + print_error "Failed to install a working AppIndicator/Ayatana GI runtime on openSUSE." + print_error "Try manually: sudo zypper install typelib-1_0-AyatanaAppIndicator3-0_1 libayatana-appindicator3-1" + exit "$EXIT_MISSING_DEPS" + fi + + if [[ "${SELECTED_ENGINE:-whisper_cpp}" == "whisper_cpp" && "${WHISPERCPP_BACKEND:-}" != "cpu" ]]; then + if ! suse_shader_compiler_available; then + if ! suse_install_first_available "Vulkan shader compiler" shaderc glslang-devel glslang; then + print_warning "No Vulkan shader compiler found - whisper.cpp Vulkan build may fail" + print_warning "Install shaderc manually for glslc support if you want GPU acceleration." + fi + fi + + if ! suse_shader_compiler_available; then + print_warning "glslc/glslangValidator is still unavailable; CPU fallback will be used if Vulkan build fails." + fi + fi + ;; + + gentoo) + # For Gentoo Linux + if ! command_exists emerge; then + print_error "Emerge package manager not found" + exit "$EXIT_MISSING_DEPS" + fi + + print_info "Gentoo detected. Installing dependencies..." + print_warning "Gentoo uses emerge. This may take longer as packages are compiled from source." + + # Check for missing packages + MISSING_PACKAGES="" + for pkg in $EMERGE_PACKAGES; do + # Gentoo uses qlist to check if packages are installed + if ! qlist -I "$pkg" >/dev/null 2>&1; then + MISSING_PACKAGES="$MISSING_PACKAGES $pkg" + fi + done + + if [ -n "$MISSING_PACKAGES" ]; then + print_info "Installing packages:$MISSING_PACKAGES" + # Update Portage tree first + sudo emerge --sync || { print_error "Failed to sync Portage tree"; exit "$EXIT_NETWORK"; } + # Install missing packages + sudo emerge $MISSING_PACKAGES || { print_error "Failed to install dependencies"; exit "$EXIT_MISSING_DEPS"; } + else + print_info "All required packages are already installed." + fi + ;; + + alpine) + # For Alpine Linux + if ! command_exists apk; then + print_error "Apk package manager not found" + exit "$EXIT_MISSING_DEPS" + fi + + print_info "Alpine Linux detected." + print_warning "Alpine uses musl libc. Some Python packages may not have pre-built wheels." + + # Check for missing packages + MISSING_PACKAGES="" + for pkg in $APK_PACKAGES; do + if ! apk info -e "$pkg" >/dev/null 2>&1; then + MISSING_PACKAGES="$MISSING_PACKAGES $pkg" + fi + done + + if [ -n "$MISSING_PACKAGES" ]; then + print_info "Installing packages:$MISSING_PACKAGES" + sudo apk update || { print_error "Failed to update package indexes"; exit "$EXIT_NETWORK"; } + sudo apk add $MISSING_PACKAGES || { print_error "Failed to install dependencies"; exit "$EXIT_MISSING_DEPS"; } + else + print_info "All required packages are already installed." + fi + ;; + + void) + # For Void Linux + if ! command_exists xbps; then + print_error "Xbps package manager not found" + exit "$EXIT_MISSING_DEPS" + fi + + print_info "Void Linux detected." + + # Check for missing packages + MISSING_PACKAGES="" + for pkg in $XBPS_PACKAGES; do + if ! xbps-query "$pkg" >/dev/null 2>&1; then + MISSING_PACKAGES="$MISSING_PACKAGES $pkg" + fi + done + + if [ -n "$MISSING_PACKAGES" ]; then + print_info "Installing packages:$MISSING_PACKAGES" + sudo xbps-install -Sy $MISSING_PACKAGES || { print_error "Failed to install dependencies"; exit "$EXIT_MISSING_DEPS"; } + else + print_info "All required packages are already installed." + fi + ;; + + solus) + # For Solus + if ! command_exists eopkg; then + print_error "Eopkg package manager not found" + exit "$EXIT_MISSING_DEPS" + fi + + print_info "Solus detected." + + # Check for missing packages + MISSING_PACKAGES="" + for pkg in $EOPKG_PACKAGES; do + if ! eopkg list-installed | grep -qw "$pkg"; then + MISSING_PACKAGES="$MISSING_PACKAGES $pkg" + fi + done + + if [ -n "$MISSING_PACKAGES" ]; then + print_info "Installing packages:$MISSING_PACKAGES" + sudo eopkg install $MISSING_PACKAGES || { print_error "Failed to install dependencies"; exit "$EXIT_MISSING_DEPS"; } + else + print_info "All required packages are already installed." + fi + ;; + + mageia) + # For Mageia + if command_exists dnf; then + INSTALL_CMD="sudo dnf install -y" + UPDATE_CMD="sudo dnf check-update" + elif command_exists urpmi; then + INSTALL_CMD="sudo urpmi --force" + UPDATE_CMD="sudo urpmi.update -a" + else + print_error "No supported package manager found (dnf/urpmi)" + exit "$EXIT_MISSING_DEPS" + fi + + # Use similar packages to Fedora/RHEL + for pkg in $DNF_PACKAGES; do + # Mageia uses rpm like Fedora + if ! rpm -q "$pkg" >/dev/null 2>&1; then + MISSING_PACKAGES="$MISSING_PACKAGES $pkg" + fi + done + + if [ -n "$MISSING_PACKAGES" ]; then + print_info "Installing missing packages:$MISSING_PACKAGES" + $UPDATE_CMD 2>/dev/null || true + $INSTALL_CMD $MISSING_PACKAGES || { print_error "Failed to install dependencies"; exit "$EXIT_MISSING_DEPS"; } + else + print_info "All required packages are already installed." + fi + ;; + + *) + print_error "Unsupported distribution family: $DISTRO_FAMILY" + print_info "" + print_info "Your distribution ($DISTRO_NAME) is not officially supported." + print_info "However, you can still install Vocalinux manually:" + print_info "" + print_info "1. Run the dependency checker:" + print_info " bash scripts/check-system-deps.sh" + print_info "" + print_info "2. Install missing dependencies using your package manager" + print_info "" + print_info "3. Run the installer with --skip-system-deps:" + print_info " ./install.sh --skip-system-deps" + print_info "" + print_info "4. Or install from source in a virtual environment:" + print_info " /usr/bin/python3 -m venv --system-site-packages venv" + print_info " source venv/bin/activate" + print_info " pip install -e .[whisper,vad]" + print_info "" + print_info "For more information, see the project wiki:" + print_info " https://github.com/VocaHQ/vocalinux/wiki" + print_info "" + if [[ "$NON_INTERACTIVE" != "yes" ]]; then + read -p "Continue anyway? (y/n) " -n 1 -r + echo + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + exit "$EXIT_USER_ABORT" + fi + else + print_info "Non-interactive mode: continuing (dependencies may be missing)..." + fi + ;; + esac +} + +# Function to detect and install text input tools +install_text_input_tools() { + if [[ "$SKIP_SYSTEM_DEPS" == "yes" ]]; then + print_warning "Skipping text input tool installation (--skip-system-deps specified)." + return 0 + fi + + # Detect session type more robustly + local SESSION_TYPE="unknown" + + # Check XDG_SESSION_TYPE first. These are often unset without a login + # session even when DISPLAY is set; ${var:-} keeps set -u from aborting. + if [ -n "${XDG_SESSION_TYPE:-}" ]; then + SESSION_TYPE="${XDG_SESSION_TYPE:-}" + # Check for Wayland-specific environment variables + elif [ -n "${WAYLAND_DISPLAY:-}" ]; then + SESSION_TYPE="wayland" + # Check if X server is running + elif [ -n "${DISPLAY:-}" ] && command_exists xset && xset q &>/dev/null; then + SESSION_TYPE="x11" + # Check loginctl if available + elif command_exists loginctl; then + SESSION_TYPE=$(loginctl show-session $(loginctl | grep $(whoami) | awk '{print $1}') -p Type | cut -d= -f2 || true) + fi + + print_info "Detected session type: $SESSION_TYPE" + if [[ "$SESSION_TYPE" == "wayland" ]] && is_kde_plasma_session; then + print_kde_wayland_ibus_hint + fi + + # Install appropriate tools based on session type and distribution + case "$SESSION_TYPE" in + wayland) + print_info "Installing Wayland text input tools..." + case "$DISTRO_FAMILY" in + ubuntu|debian) + if ! apt_package_installed "wtype"; then + DEBIAN_FRONTEND=noninteractive sudo apt install -y wtype || { print_warning "Failed to install wtype. Text injection may not work properly."; } + else + print_info "wtype is already installed." + fi + ;; + fedora) + if command_exists dnf && ! dnf_package_installed "wtype"; then + sudo dnf install -y wtype || { print_warning "Failed to install wtype. Text injection may not work properly."; } + elif command_exists yum && ! rpm -q wtype &>/dev/null; then + sudo yum install -y wtype || { print_warning "Failed to install wtype. Text injection may not work properly."; } + else + print_info "wtype is already installed." + fi + ;; + arch) + if ! pacman_package_installed "wtype"; then + sudo pacman -S --noconfirm wtype || { print_warning "Failed to install wtype. Text injection may not work properly."; } + else + print_info "wtype is already installed." + fi + ;; + suse) + sudo zypper install -y wtype || { print_warning "Failed to install wtype. Text injection may not work properly."; } + ;; + gentoo) + if ! qlist -I wtype >/dev/null 2>&1; then + sudo emerge wtype || { print_warning "Failed to install wtype. Text injection may not work properly."; } + else + print_info "wtype is already installed." + fi + ;; + alpine) + if ! apk info -e wtype >/dev/null 2>&1; then + sudo apk add wtype || { print_warning "Failed to install wtype. Text injection may not work properly."; } + else + print_info "wtype is already installed." + fi + ;; + void) + if ! xbps-query wtype >/dev/null 2>&1; then + sudo xbps-install -Sy wtype || { print_warning "Failed to install wtype. Text injection may not work properly."; } + else + print_info "wtype is already installed." + fi + ;; + solus) + if ! eopkg list-installed | grep -qw wtype; then + sudo eopkg install wtype || { print_warning "Failed to install wtype. Text injection may not work properly."; } + else + print_info "wtype is already installed." + fi + ;; + mageia) + if command_exists dnf && ! rpm -q wtype >/dev/null 2>&1; then + sudo dnf install -y wtype || { print_warning "Failed to install wtype. Text injection may not work properly."; } + elif command_exists urpmi && ! rpm -q wtype >/dev/null 2>&1; then + sudo urpmi -y wtype || { print_warning "Failed to install wtype. Text injection may not work properly."; } + else + print_info "wtype is already installed." + fi + ;; + *) + print_warning "Unsupported distribution for Wayland text input tools." + print_warning "Please install 'wtype' manually for Wayland text input support." + ;; + esac + + # Try to install ydotool as additional fallback for Wayland + # ydotool works better with some compositors (like GNOME) where wtype may fail + print_info "Attempting to install ydotool for better Wayland compatibility..." + case "$DISTRO_FAMILY" in + ubuntu|debian) + if ! apt_package_installed "ydotool"; then + if ! DEBIAN_FRONTEND=noninteractive sudo apt install -y ydotool 2>/dev/null; then + if [[ "$DISTRO_FAMILY" == "debian" ]]; then + print_warning "ydotool is not packaged in Debian's standard repos." + print_info "For full Wayland input support, you can compile ydotool from source:" + print_info " sudo apt install -y git cmake libevdev-dev" + print_info " git clone https://github.com/ReimuNotMoe/ydotool.git /tmp/ydotool" + print_info " cmake -S /tmp/ydotool -B /tmp/ydotool/build && sudo cmake --build /tmp/ydotool/build --target install" + print_info " sudo systemctl enable --now ydotoold" + print_info "Alternatively, wtype (already installed) will handle most Wayland compositors." + else + print_info "ydotool not available in repos (optional)" + fi + fi + fi + ;; + fedora) + if command_exists dnf; then + sudo dnf install -y ydotool 2>/dev/null || print_info "ydotool not available in repos (optional)" + fi + ;; + arch) + if ! pacman_package_installed "ydotool"; then + sudo pacman -S --noconfirm ydotool 2>/dev/null || print_info "ydotool not available in repos (optional)" + fi + ;; + esac + + # Add user to input group for ydotool/dotool support + if ! groups | grep -q '\binput\b'; then + print_info "Adding $USER to 'input' group for text injection..." + sudo usermod -aG input "$USER" || print_warning "Failed to add user to input group" + print_warning "You will need to LOG OUT and back in for text injection to work with ydotool/dotool" + fi + + # Install udev rule for ydotool/dotool + if [ ! -f /etc/udev/rules.d/80-dotool.rules ]; then + print_info "Installing udev rule for input device access..." + echo 'KERNEL=="uinput", GROUP="input", MODE="0620", OPTIONS+="static_node=uinput"' \ + | sudo tee /etc/udev/rules.d/80-dotool.rules >/dev/null 2>&1 || print_warning "Failed to install udev rule" + sudo udevadm control --reload 2>/dev/null || true + sudo udevadm trigger 2>/dev/null || true + fi + ;; + + x11|"") + print_info "Installing X11 text input tools..." + case "$DISTRO_FAMILY" in + ubuntu|debian) + if ! apt_package_installed "xdotool"; then + DEBIAN_FRONTEND=noninteractive sudo apt install -y xdotool || { print_warning "Failed to install xdotool. Text injection may not work properly."; } + else + print_info "xdotool is already installed." + fi + ;; + fedora) + if command_exists dnf && ! dnf_package_installed "xdotool"; then + sudo dnf install -y xdotool || { print_warning "Failed to install xdotool. Text injection may not work properly."; } + elif command_exists yum && ! rpm -q xdotool &>/dev/null; then + sudo yum install -y xdotool || { print_warning "Failed to install xdotool. Text injection may not work properly."; } + else + print_info "xdotool is already installed." + fi + ;; + arch) + if ! pacman_package_installed "xdotool"; then + sudo pacman -S --noconfirm xdotool || { print_warning "Failed to install xdotool. Text injection may not work properly."; } + else + print_info "xdotool is already installed." + fi + ;; + suse) + sudo zypper install -y xdotool || { print_warning "Failed to install xdotool. Text injection may not work properly."; } + ;; + gentoo) + if ! qlist -I xdotool >/dev/null 2>&1; then + sudo emerge xdotool || { print_warning "Failed to install xdotool. Text injection may not work properly."; } + else + print_info "xdotool is already installed." + fi + ;; + alpine) + if ! apk info -e xdotool >/dev/null 2>&1; then + sudo apk add xdotool || { print_warning "Failed to install xdotool. Text injection may not work properly."; } + else + print_info "xdotool is already installed." + fi + ;; + void) + if ! xbps-query xdotool >/dev/null 2>&1; then + sudo xbps-install -Sy xdotool || { print_warning "Failed to install xdotool. Text injection may not work properly."; } + else + print_info "xdotool is already installed." + fi + ;; + solus) + if ! eopkg list-installed | grep -qw xdotool; then + sudo eopkg install xdotool || { print_warning "Failed to install xdotool. Text injection may not work properly."; } + else + print_info "xdotool is already installed." + fi + ;; + mageia) + if command_exists dnf && ! rpm -q xdotool >/dev/null 2>&1; then + sudo dnf install -y xdotool || { print_warning "Failed to install xdotool. Text injection may not work properly."; } + elif command_exists urpmi && ! rpm -q xdotool >/dev/null 2>&1; then + sudo urpmi -y xdotool || { print_warning "Failed to install xdotool. Text injection may not work properly."; } + else + print_info "xdotool is already installed." + fi + ;; + *) + print_warning "Unsupported distribution for X11 text input tools." + print_warning "Please install 'xdotool' manually for X11 text input support." + ;; + esac + ;; + + *) + print_warning "Unknown session type: $SESSION_TYPE" + print_warning "Installing both Wayland and X11 text input tools for compatibility..." + + # Install both tools based on distribution + case "$DISTRO_FAMILY" in + ubuntu|debian) + DEBIAN_FRONTEND=noninteractive sudo apt install -y xdotool wtype || { print_warning "Failed to install text input tools. Text injection may not work properly."; } + ;; + fedora|mageia) + if command_exists dnf; then + sudo dnf install -y xdotool wtype || { print_warning "Failed to install text input tools. Text injection may not work properly."; } + elif command_exists yum; then + sudo yum install -y xdotool wtype || { print_warning "Failed to install text input tools. Text injection may not work properly."; } + fi + # Mageia also supports urpmi + if [[ "$DISTRO_FAMILY" == "mageia" ]] && command_exists urpmi; then + sudo urpmi -y xdotool wtype || { print_warning "Failed to install text input tools. Text injection may not work properly."; } + fi + ;; + arch) + sudo pacman -S --noconfirm xdotool wtype || { print_warning "Failed to install text input tools. Text injection may not work properly."; } + ;; + suse) + sudo zypper install -y xdotool wtype || { print_warning "Failed to install text input tools. Text injection may not work properly."; } + ;; + gentoo) + sudo emerge xdotool wtype || { print_warning "Failed to install text input tools. Text injection may not work properly."; } + ;; + alpine) + sudo apk add xdotool wtype || { print_warning "Failed to install text input tools. Text injection may not work properly."; } + ;; + void) + sudo xbps-install -Sy xdotool wtype || { print_warning "Failed to install text input tools. Text injection may not work properly."; } + ;; + solus) + sudo eopkg install xdotool wtype || { print_warning "Failed to install text input tools. Text injection may not work properly."; } + ;; + *) + print_warning "Unsupported distribution for text input tools." + print_warning "Please install 'xdotool' and 'wtype' manually for text input support." + ;; + esac + ;; + esac +} diff --git a/install.sh b/install.sh index 8fd3b13..2ab29a4 100755 --- a/install.sh +++ b/install.sh @@ -691,6 +691,27 @@ fi # Change to install directory cd "$INSTALL_DIR" +# The bootstrap above must stay self-contained because it is also served by the +# curl installer. Once the local or tagged repository is available, load the +# implementation modules from that same revision. +source_installer_module() { + local module="$INSTALL_DIR/install.d/$1" + if [ ! -r "$module" ]; then + print_error "Installer module is missing or unreadable: $module" + return 1 + fi + # shellcheck source=/dev/null + source "$module" +} + +# Shared by the model installer module and the runtime downloader. +MODEL_CHECKSUMS_FILE="$INSTALL_DIR/src/vocalinux/utils/model_checksums.txt" + +for INSTALLER_MODULE in interactive.sh system_dependencies.sh models.sh desktop.sh; do + source_installer_module "$INSTALLER_MODULE" || exit 1 +done +unset INSTALLER_MODULE + print_info "Using virtual environment: $VENV_DIR" [[ "$DEV_MODE" == "yes" ]] && print_info "Installing in development mode" [[ "$RUN_TESTS" == "yes" ]] && print_info "Tests will be run after installation" @@ -1209,315 +1230,6 @@ detect_typelib_path() { return 1 } -# Print section header for interactive mode -clear_screen() { - if [ -t 1 ] && command -v clear >/dev/null 2>&1 && [ -n "${TERM:-}" ]; then - clear >/dev/null 2>&1 || true - fi -} - -print_header() { - local title="$1" - echo "" - echo "============================================================" - echo " $title" - echo "============================================================" -} - -# Function to run interactive guided installation -run_interactive_install() { - clear_screen - cat << "EOF" - - Interactive Installation Guide - =============================== - -EOF - - echo "Welcome! This guided installation will help you set up Vocalinux" - echo "with the best options for your system." - echo "" - echo "All speech engines are 100% offline, local, and private." - echo "Your voice data never leaves your computer." - echo "" - - # Step 1: Detect and display system info - print_header "Step 1: Your System" - echo "Detected: $DISTRO_NAME $DISTRO_VERSION" - - # Get hardware recommendation - local RECOMMENDATION=$(get_engine_recommendation) - local RECOMMENDED_ENGINE=$(echo "$RECOMMENDATION" | cut -d':' -f1) - local RECOMMENDED_ICON=$(echo "$RECOMMENDATION" | cut -d':' -f2) - local RECOMMENDED_REASON=$(echo "$RECOMMENDATION" | cut -d':' -f3-) - - echo "Hardware: $RECOMMENDED_REASON" - echo "" - - # Step 2: Choose speech recognition engine - print_header "Step 2: Choose Speech Recognition Engine" - echo "" - echo " ┌─────────────────────────────────────────────────────────────┐" - echo " │ 1. WHISPER.CPP * RECOMMENDED │" - echo " │ • Fastest, most accurate, works with any GPU │" - echo " │ • Supports NVIDIA (CUDA), AMD, Intel (Vulkan) │" - echo " │ • CPU-only mode available for older systems │" - echo " │ • Models: tiny (39MB) to large (1.5GB) │" - echo " │ • 99+ languages with auto-detection │" - echo " └─────────────────────────────────────────────────────────────┘" - echo "" - echo " ┌─────────────────────────────────────────────────────────────┐" - echo " │ 2. WHISPER (OpenAI) │" - echo " │ • PyTorch-based, high accuracy │" - echo " │ • Only supports NVIDIA GPUs (CUDA) │" - echo " │ • Larger download (~2GB with CUDA) │" - echo " │ • Good for development/research │" - echo " └─────────────────────────────────────────────────────────────┘" - echo "" - echo " ┌─────────────────────────────────────────────────────────────┐" - echo " │ 3. VOSK │" - echo " │ • Lightweight and fast │" - echo " │ • Works on older/low-RAM systems │" - echo " │ • ~40MB download │" - echo " │ • Good for basic dictation needs │" - echo " └─────────────────────────────────────────────────────────────┘" - echo "" - echo " ┌─────────────────────────────────────────────────────────────┐" - echo " │ 4. FASTER-WHISPER │" - echo " │ • Optional CTranslate2 Whisper backend │" - echo " │ • Fast on CPU with INT8 quantization │" - echo " │ • Checksum-verified Hugging Face models │" - echo " └─────────────────────────────────────────────────────────────┘" - echo "" - echo " ┌─────────────────────────────────────────────────────────────┐" - echo " │ 5. REMOTE API (ADVANCED) │" - echo " │ • Offload processing to a GPU server on your network │" - echo " │ • Ideal for laptops without GPU │" - echo " │ • Supports whisper.cpp server & OpenAI-compatible APIs │" - echo " │ • Minimal local resources needed │" - echo " │ • Requires a remote server to be running │" - echo " └─────────────────────────────────────────────────────────────┘" - echo "" - - # Show recommendation - case "$RECOMMENDED_ENGINE" in - whisper_cpp) - echo " → Recommendation: whisper.cpp (best performance for your hardware)" - DEFAULT_CHOICE="1" - ;; - vosk) - echo " → Recommendation: VOSK (lightweight option for your system)" - DEFAULT_CHOICE="3" - ;; - *) - echo " → Recommendation: whisper.cpp (best overall experience)" - DEFAULT_CHOICE="1" - ;; - esac - echo "" - - read -p "Choose engine [1-5] (default: $DEFAULT_CHOICE): " ENGINE_CHOICE - ENGINE_CHOICE=${ENGINE_CHOICE:-$DEFAULT_CHOICE} - - case "$ENGINE_CHOICE" in - 1) - SELECTED_ENGINE="whisper_cpp" - ENGINE_DISPLAY="Whisper.cpp (Recommended)" - ;; - 2) - SELECTED_ENGINE="whisper" - ENGINE_DISPLAY="Whisper (OpenAI)" - ;; - 3) - SELECTED_ENGINE="vosk" - ENGINE_DISPLAY="VOSK (Lightweight)" - ;; - 4) - SELECTED_ENGINE="faster_whisper" - ENGINE_DISPLAY="Faster-Whisper" - ;; - 5) - SELECTED_ENGINE="remote_api" - ENGINE_DISPLAY="Remote API" - ;; - *) - SELECTED_ENGINE="whisper_cpp" - ENGINE_DISPLAY="Whisper.cpp (Recommended)" - ;; - esac - - # Step 3: Whisper.cpp backend selection (if whisper.cpp chosen) - if [[ "$SELECTED_ENGINE" == "whisper_cpp" ]]; then - print_header "Step 3: Choose Whisper.cpp Backend" - echo "" - - # Detect available backends - local BACKEND_INFO=$(detect_whispercpp_backends) - local RECOMMENDED_BACKEND=$(echo "$BACKEND_INFO" | cut -d':' -f1) - local RECOMMENDED_REASON=$(echo "$BACKEND_INFO" | cut -d':' -f2) - local CAN_BUILD_GPU=$(echo "$BACKEND_INFO" | cut -d':' -f3) - local HAS_VULKAN=$(echo "$BACKEND_INFO" | cut -d':' -f4) - local HAS_NVIDIA=$(echo "$BACKEND_INFO" | cut -d':' -f5) - local HAS_VULKAN_DEV=$(echo "$BACKEND_INFO" | cut -d':' -f6) - local HAS_CUDA_DEV=$(echo "$BACKEND_INFO" | cut -d':' -f7) - local VULKAN_COMPAT=$(echo "$BACKEND_INFO" | cut -d':' -f8) - local VULKAN_COMPAT_REASON=$(echo "$BACKEND_INFO" | cut -d':' -f9) - - # Show warning for incompatible GPUs - if [[ "$VULKAN_COMPAT" == "incompatible" ]]; then - echo "" - print_warning "═══════════════════════════════════════════════════════════════" - print_warning " ⚠️ INCOMPATIBLE GPU DETECTED" - print_warning "═══════════════════════════════════════════════════════════════" - print_warning "" - print_warning " Your GPU: $VULKAN_COMPAT_REASON" - print_warning "" - print_warning " This Intel GPU lacks VK_KHR_16bit_storage support, which is" - print_warning " required for whisper.cpp Vulkan acceleration." - print_warning "" - print_warning " The CPU backend will be used instead, which is still fast!" - print_warning "" - print_warning "═══════════════════════════════════════════════════════════════" - echo "" - fi - - echo "Whisper.cpp can use different backends for speech recognition:" - echo "" - - if [[ "$CAN_BUILD_GPU" == "true" ]]; then - echo " ┌─────────────────────────────────────────────────────────────┐" - echo " │ 1. GPU (Vulkan/CUDA) * RECOMMENDED │" - echo " │ • Fastest performance with GPU acceleration │" - printf " │ • %-*s│\n" 54 "$RECOMMENDED_REASON" - echo " │ • Requires building from source (takes ~2-5 min) │" - echo " └─────────────────────────────────────────────────────────────┘" - echo "" - echo " ┌─────────────────────────────────────────────────────────────┐" - echo " │ 2. CPU (Pre-built) │" - echo " │ • Works on all systems │" - echo " │ • Faster installation (no compilation) │" - echo " │ • Good performance on modern CPUs │" - echo " └─────────────────────────────────────────────────────────────┘" - echo "" - echo " → Recommendation: GPU backend for best performance" - local DEFAULT_BACKEND="1" - else - echo " ┌─────────────────────────────────────────────────────────────┐" - echo " │ 1. GPU (Vulkan/CUDA) │" - echo " │ • ⚠️ GPU libraries not detected │" - echo " │ • Requires: libvulkan-dev, glslc/glslang-tools (Vulkan) │" - echo " │ or: CUDA toolkit (NVIDIA) │" - echo " └─────────────────────────────────────────────────────────────┘" - echo "" - echo " ┌─────────────────────────────────────────────────────────────┐" - echo " │ 2. CPU (Pre-built) * RECOMMENDED │" - echo " │ • Works on all systems │" - echo " │ • Fast installation (no compilation) │" - echo " │ • Good performance on modern CPUs │" - echo " └─────────────────────────────────────────────────────────────┘" - echo "" - - if [[ "$HAS_VULKAN" == "yes" && "$HAS_VULKAN_DEV" != "true" ]]; then - echo " 💡 Tip: Install 'libvulkan-dev' and a shader compiler for GPU support:" - echo " sudo apt install libvulkan-dev glslc 2>/dev/null || sudo apt install libvulkan-dev glslang-tools" - echo "" - elif [[ "$HAS_NVIDIA" == "yes" && "$HAS_CUDA_DEV" != "true" ]]; then - echo " 💡 Tip: Install CUDA toolkit for NVIDIA GPU support:" - echo " https://developer.nvidia.com/cuda-downloads" - echo "" - fi - - echo " → Recommendation: CPU backend (GPU libraries not detected)" - local DEFAULT_BACKEND="2" - fi - - read -p "Choose backend [1-2] (default: $DEFAULT_BACKEND): " BACKEND_CHOICE - BACKEND_CHOICE=${BACKEND_CHOICE:-$DEFAULT_BACKEND} - - if [[ "$BACKEND_CHOICE" == "1" ]]; then - WHISPERCPP_BACKEND="gpu" - BACKEND_DISPLAY="GPU (Vulkan/CUDA)" - else - WHISPERCPP_BACKEND="cpu" - BACKEND_DISPLAY="CPU (Pre-built)" - fi - - echo "" - fi - - # Step 3 for Remote API: Configure server URL - if [[ "$SELECTED_ENGINE" == "remote_api" ]]; then - print_header "Step 3: Configure Remote Server" - echo "" - print_info "You need a speech recognition server running on your local network." - echo "" - echo " Supported servers:" - echo " • whisper.cpp server: ./server -m model.bin --host 0.0.0.0 --port 8080" - echo " • LocalAI: docker run -p 8080:8080 localai/localai" - echo " • Faster Whisper: faster-whisper-server --host 0.0.0.0 --port 8080" - echo " • Any OpenAI-compatible speech API" - echo "" - read -p "Enter remote server URL (or leave blank to set later): " REMOTE_API_URL_INPUT - if [ -n "$REMOTE_API_URL_INPUT" ]; then - REMOTE_API_URL="$REMOTE_API_URL_INPUT" - REMOTE_DISPLAY="$REMOTE_API_URL" - else - REMOTE_API_URL="" - REMOTE_DISPLAY="(configure later in Settings)" - fi - echo "" - fi - - # Step 4: Model download preference (skip for remote_api) - if [[ "$SELECTED_ENGINE" != "remote_api" ]]; then - print_header "Step 4: Model Download" - echo "" - echo "Speech recognition models can be downloaded now or later." - echo "" - echo " 1. Download now (recommended)" - echo " • Faster first run - ready to use immediately" - echo " • Offline capable right after install" - echo "" - echo " 2. Download later" - echo " • Smaller initial install" - echo " • Models download automatically on first use" - echo "" - - read -p "Download models now? [1-2] (default: 1): " MODELS_CHOICE - MODELS_CHOICE=${MODELS_CHOICE:-1} - - if [[ "$MODELS_CHOICE" == "2" ]]; then - SKIP_MODELS="yes" - MODELS_DISPLAY="Download on first use" - else - MODELS_DISPLAY="Download now (recommended)" - fi - else - # Remote API: No need to download model - SKIP_MODELS="yes" - MODELS_DISPLAY="Not needed (remote processing)" - fi - - # Summary - print_header "Installation Summary" - echo "" - echo " Speech Engine: $ENGINE_DISPLAY" - if [[ "$SELECTED_ENGINE" == "whisper_cpp" ]]; then - echo " Backend: ${BACKEND_DISPLAY:-CPU (Pre-built)}" - if [[ "${WHISPERCPP_BACKEND}" == "gpu" ]]; then - echo " Note: GPU build will compile from source (2-5 minutes)" - fi - fi - if [[ "$SELECTED_ENGINE" == "remote_api" ]]; then - echo " Remote Server: $REMOTE_DISPLAY" - fi - echo " Models: $MODELS_DISPLAY" - echo " Install Location: ${INSTALL_DIR:-\$HOME/.local/share/vocalinux}" - echo "" - read -p "Press Enter to continue with installation, or Ctrl+C to cancel..." - echo "" -} - # Detect distribution detect_distro @@ -1596,601 +1308,6 @@ if [[ "$NON_INTERACTIVE" == "yes" ]] && [[ -z "$SELECTED_ENGINE" ]]; then fi -# Function to check if a command exists -command_exists() { - command -v "$1" >/dev/null 2>&1 -} - -# Function to check if a package is installed (for apt-based systems) -apt_package_installed() { - dpkg -s "$1" >/dev/null 2>&1 -} - -# Function to check if a package is installed (for dnf-based systems) -dnf_package_installed() { - rpm -q "$1" >/dev/null 2>&1 -} - -# Function to check if a package is installed (for pacman-based systems) -pacman_package_installed() { - pacman -Q "$1" >/dev/null 2>&1 -} - -# Install an AppIndicator/StatusNotifierItem provider, preferring the -# actively maintained Ayatana fork over the legacy Canonical package -# (unmaintained since ~2013). The legacy package can install and import -# without error yet silently fail to register a tray icon with KDE's -# StatusNotifierWatcher, leaving no icon and no logged error. $1 is the -# package-manager install command (e.g. "sudo dnf install -y"), $2/$3 are -# the Ayatana/legacy package names for that package manager, $4 is the -# "is this package installed" checker function name. -install_preferred_appindicator() { - local install_cmd="$1" - local ayatana_pkg="$2" - local legacy_pkg="$3" - local checker="$4" - - if "$checker" "$ayatana_pkg"; then - return 0 - fi - - if $install_cmd "$ayatana_pkg" 2>/dev/null; then - print_info "Installed $ayatana_pkg (Ayatana AppIndicator; required for a working KDE tray icon)" - return 0 - fi - - if "$checker" "$legacy_pkg"; then - print_info "$ayatana_pkg not available; $legacy_pkg is already installed but may not show a tray icon on KDE Plasma" - return 0 - fi - - print_info "$ayatana_pkg not available, falling back to $legacy_pkg (may not show a tray icon on KDE Plasma)..." - if $install_cmd "$legacy_pkg"; then - return 0 - fi - - print_error "Failed to install an AppIndicator package (tried $ayatana_pkg and $legacy_pkg)" - return 1 -} - -suse_python_package_prefix() { - python3 -c 'import sys; print(f"python{sys.version_info.major}{sys.version_info.minor}")' 2>/dev/null || echo "python3" -} - -suse_python_package_candidates() { - local suffix="$1" - local PY_PREFIX - PY_PREFIX=$(suse_python_package_prefix) - - if [[ "$PY_PREFIX" != "python3" ]]; then - echo "${PY_PREFIX}-${suffix} python3-${suffix}" - else - echo "python3-${suffix}" - fi -} - -suse_package_installed() { - rpm -q "$1" >/dev/null 2>&1 -} - -suse_install_first_available() { - local DESCRIPTION="$1" - shift - - local PKG - for PKG in "$@"; do - [ -z "$PKG" ] && continue - - if suse_package_installed "$PKG"; then - print_info "$DESCRIPTION is already installed ($PKG)." - return 0 - fi - - if sudo zypper install -y "$PKG" 2>/dev/null; then - print_success "Installed $DESCRIPTION ($PKG)." - return 0 - fi - - print_info "$DESCRIPTION package '$PKG' not available, trying next option..." - done - - return 1 -} - -suse_appindicator_gi_available() { - python3 - <<'PY' >/dev/null 2>&1 -import importlib -import gi - -for namespace in ("AppIndicator3", "AyatanaAppIndicator3", "AyatanaAppindicator3"): - try: - gi.require_version(namespace, "0.1") - importlib.import_module(f"gi.repository.{namespace}") - raise SystemExit(0) - except (ImportError, ValueError): - pass - -raise SystemExit(1) -PY -} - -suse_install_appindicator_runtime() { - local APPINDICATOR_PACKAGES=( - "typelib-1_0-AyatanaAppIndicator3-0_1" - "typelib-1_0-AppIndicator3-0_1" - "typelib-1_0-AyatanaAppIndicator-0_1" - "libayatana-appindicator3-1" - "libappindicator3-1" - "libappindicator-gtk3" - ) - - if suse_appindicator_gi_available; then - print_info "AppIndicator/Ayatana GI namespace is already available." - return 0 - fi - - local PKG - for PKG in "${APPINDICATOR_PACKAGES[@]}"; do - if suse_package_installed "$PKG"; then - print_info "AppIndicator/Ayatana package is already installed ($PKG); verifying GI namespace..." - elif sudo zypper install -y "$PKG" 2>/dev/null; then - print_success "Installed AppIndicator/Ayatana package ($PKG)." - # Refresh the shared-library cache so the GI typelib is discoverable - sudo ldconfig 2>/dev/null || true - else - print_info "AppIndicator/Ayatana package '$PKG' not available, trying next option..." - continue - fi - - if suse_appindicator_gi_available; then - print_success "AppIndicator/Ayatana GI namespace is available." - return 0 - fi - done - - return 1 -} - -suse_shader_compiler_available() { - command_exists glslc || command_exists glslangValidator -} - -# Function to install system dependencies based on the detected distribution -install_system_dependencies() { - print_info "Installing system dependencies..." - - # Determine which Vulkan shader package is available (glslc for Ubuntu 24.04+, glslang-tools for 22.04) - local VULKAN_SHADER_PKG="glslang-tools" # Default fallback - if apt-cache show glslc &>/dev/null 2>&1; then - VULKAN_SHADER_PKG="glslc" - fi - - # Define package names for different distributions - # GObject Introspection / GLib headers for building PyGObject and friends. - # Prefer libgirepository-2.0-dev when available (Ubuntu 24.04+, Pop!_OS Cosmic+, - # Debian 13+). When both 1.0 and 2.0 packages exist, install both: 2.0 provides - # the modern GLib GI headers that pip builds need, while 1.0 still pulls - # gobject-introspection tooling. Older distros that only ship 1.0 keep that. - # See #571 (installer previously kept 1.0 whenever apt-cache still listed it). - local GI_DEV_PKG="libgirepository1.0-dev" - if apt-cache show libgirepository-2.0-dev &>/dev/null 2>&1; then - if apt-cache show libgirepository1.0-dev &>/dev/null 2>&1; then - GI_DEV_PKG="libgirepository-2.0-dev libgirepository1.0-dev" - else - GI_DEV_PKG="libgirepository-2.0-dev" - fi - fi - - # libssl-dev, autoconf, automake, libtool, patchelf are required for pywhispercpp source - # builds on Debian. On Ubuntu these are typically pulled in transitively, but on a clean - # Debian install they are absent and cause CMake's bootstrap to fail (Hurdle 2 from - # https://medium.com/@cslev/talking-to-my-linux-box-without-talking-to-the-cloud-vocalinux-on-debian-without-the-tears-10bf053ea21b). - local PYWHISPERCPP_BUILD_DEPS="libssl-dev autoconf automake libtool patchelf" - local APT_PACKAGES_UBUNTU="python3-pip python3-gi python3-gi-cairo gir1.2-gtk-3.0 gir1.2-appindicator3-0.1 gir1.2-ibus-1.0 $GI_DEV_PKG libcairo2-dev cmake python3-dev build-essential portaudio19-dev python3-venv pkg-config wget curl unzip vulkan-tools libvulkan-dev $VULKAN_SHADER_PKG xclip xsel wl-clipboard $PYWHISPERCPP_BUILD_DEPS" - local APT_PACKAGES_DEBIAN_BASE="python3-pip python3-gi python3-gi-cairo gir1.2-gtk-3.0 gir1.2-ibus-1.0 libcairo2-dev cmake python3-dev build-essential portaudio19-dev python3-venv pkg-config wget curl unzip vulkan-tools libvulkan-dev $VULKAN_SHADER_PKG xclip xsel wl-clipboard $PYWHISPERCPP_BUILD_DEPS" - local APT_PACKAGES_DEBIAN_11_12="$APT_PACKAGES_DEBIAN_BASE libgirepository1.0-dev gir1.2-ayatanaappindicator3-0.1" - local APT_PACKAGES_DEBIAN_13_PLUS="$APT_PACKAGES_DEBIAN_BASE libgirepository-2.0-dev gir1.2-ayatanaappindicator3-0.1" - # cairo-devel: the pinned pycairo builds from source; Fedora splits its headers/.pc out. - local DNF_PACKAGES="python3-pip python3-gobject gtk3 ibus-devel gobject-introspection-devel python3-devel portaudio-devel cairo-devel python3-virtualenv pkg-config cmake wget curl unzip vulkan-tools vulkan-loader-devel glslc patchelf xclip xsel wl-clipboard" - local PACMAN_PACKAGES="python-pip python-gobject gtk3 ibus gobject-introspection python-cairo portaudio python-virtualenv pkg-config cmake wget curl unzip base-devel vulkan-tools vulkan-headers shaderc patchelf xclip xsel wl-clipboard" - # ibus + typelib-1_0-IBus-1_0, not ibus-devel: the headers are not needed - # (IBus is reached through GI at runtime), and requiring them pulls gtk-doc, - # which zypper cannot satisfy when awk resolves to busybox-gawk. The runtime - # package is needed: ibus_engine.py spawns `ibus-daemon -x -d -r` and shells - # out to `ibus engine`, and ibus-devel used to pull it in transitively - # (Requires: ibus = %version). The typelib is named as well as implied by - # ibus's own typelib(IBus) require, so the GI dependency stays visible here. - # gcc/gcc-c++/make: the counterpart of build-essential and base-devel above. - # python3-devel supplies headers, not a compiler, and evdev and pyaudio ship - # no wheels, so without these they have nothing to build with. - local ZYPPER_PACKAGES="gtk3 ibus typelib-1_0-IBus-1_0 gobject-introspection-devel portaudio-devel pkg-config cmake gcc gcc-c++ make wget curl unzip xclip xsel wl-clipboard typelib-1_0-Notify-0_7 libnotify4 patchelf" - # Gentoo uses Portage and different package naming convention - local EMERGE_PACKAGES="dev-python/pygobject:3 x11-libs/gtk+:3 dev-libs/libayatana-appindicator media-libs/portaudio dev-lang/python:3.11 pkgconf cmake media-libs/shaderc dev-util/patchelf x11-misc/xclip x11-misc/xsel gui-apps/wl-clipboard" - # Alpine Linux uses apk and has musl libc - local APK_PACKAGES="py3-gobject3 py3-pip gtk+3.0 py3-cairo portaudio-dev py3-virtualenv pkgconf cmake wget curl unzip shaderc patchelf vulkan-tools xclip xsel wl-clipboard" - # Void Linux uses xbps - local XBPS_PACKAGES="python3-pip python3-gobject gtk+3 libappindicator-gtk3 gobject-introspection portaudio-devel python3-devel pkg-config cmake wget curl unzip shaderc patchelf Vulkan-Tools xclip xsel wl-clipboard" - # Solus uses eopkg - local EOPKG_PACKAGES="python3-pip python3-gobject gtk3 libappindicator gobject-introspection-devel portaudio-devel python3-virtualenv pkg-config cmake wget curl unzip shaderc patchelf vulkan-tools xclip xsel wl-clipboard" - - local MISSING_PACKAGES="" - local INSTALL_CMD="" - local UPDATE_CMD="" - - case "$DISTRO_FAMILY" in - ubuntu|debian) - local APT_PACKAGES="$APT_PACKAGES_UBUNTU" - if [[ "$DISTRO_FAMILY" == "debian" ]]; then - local DEBIAN_MAJOR="${DISTRO_VERSION%%.*}" - if [[ "$DEBIAN_MAJOR" =~ ^[0-9]+$ ]] && [ "$DEBIAN_MAJOR" -ge 13 ]; then - APT_PACKAGES="$APT_PACKAGES_DEBIAN_13_PLUS" - else - APT_PACKAGES="$APT_PACKAGES_DEBIAN_11_12" - fi - fi - - # util-linux-extra only exists on newer Ubuntu/Debian (24.04+, Debian 13+). - # On 22.04 those tools ship in the main util-linux package (always installed). - # apt-cache probe handles Ubuntu derivatives (Mint, Zorin) whose VERSION_ID - # does not track the Ubuntu base release. - if apt-cache show util-linux-extra &>/dev/null 2>&1; then - APT_PACKAGES="$APT_PACKAGES util-linux-extra" - fi - - # Check for missing packages - for pkg in $APT_PACKAGES; do - if ! apt_package_installed "$pkg"; then - MISSING_PACKAGES="$MISSING_PACKAGES $pkg" - fi - done - - if [ -n "$MISSING_PACKAGES" ]; then - print_info "Installing missing packages:$MISSING_PACKAGES" - sudo apt update || { print_error "Failed to update package lists"; exit "$EXIT_NETWORK"; } - - # Handle appindicator package for Ubuntu (old package deprecated in newer releases) - if echo "$MISSING_PACKAGES" | grep -q "gir1.2-appindicator3-0.1"; then - FILTERED_PACKAGES=$(echo "$MISSING_PACKAGES" | sed 's/gir1.2-appindicator3-0.1//' | xargs) - - if ! DEBIAN_FRONTEND=noninteractive sudo apt install -y gir1.2-appindicator3-0.1 2>/dev/null; then - print_info "gir1.2-appindicator3-0.1 not available, trying gir1.2-ayatanaappindicator3-0.1..." - if ! DEBIAN_FRONTEND=noninteractive sudo apt install -y gir1.2-ayatanaappindicator3-0.1; then - print_error "Failed to install appindicator package (tried both gir1.2-appindicator3-0.1 and gir1.2-ayatanaappindicator3-0.1)" - exit "$EXIT_MISSING_DEPS" - fi - print_info "Successfully installed gir1.2-ayatanaappindicator3-0.1 (modern replacement)" - fi - - if [ -n "$FILTERED_PACKAGES" ]; then - DEBIAN_FRONTEND=noninteractive sudo apt install -y $FILTERED_PACKAGES || { print_error "Failed to install dependencies"; exit "$EXIT_MISSING_DEPS"; } - fi - else - DEBIAN_FRONTEND=noninteractive sudo apt install -y $MISSING_PACKAGES || { print_error "Failed to install dependencies"; exit "$EXIT_MISSING_DEPS"; } - fi - else - print_info "All required packages are already installed." - fi - ;; - - fedora) - # For Fedora/RHEL-based systems - if command_exists dnf; then - INSTALL_CMD="sudo dnf install -y" - UPDATE_CMD="sudo dnf check-update" - elif command_exists yum; then - INSTALL_CMD="sudo yum install -y" - UPDATE_CMD="sudo yum check-update" - else - print_error "No supported package manager found (dnf/yum)" - exit "$EXIT_MISSING_DEPS" - fi - - # Check for missing packages - for pkg in $DNF_PACKAGES; do - if ! dnf_package_installed "$pkg"; then - MISSING_PACKAGES="$MISSING_PACKAGES $pkg" - fi - done - - if [ -n "$MISSING_PACKAGES" ]; then - print_info "Installing missing packages:$MISSING_PACKAGES" - $UPDATE_CMD || true # dnf check-update returns 100 if updates available - $INSTALL_CMD $MISSING_PACKAGES || { print_error "Failed to install dependencies"; exit "$EXIT_MISSING_DEPS"; } - else - print_info "All required packages are already installed." - fi - - install_preferred_appindicator "$INSTALL_CMD" "libayatana-appindicator-gtk3" "libappindicator-gtk3" dnf_package_installed || exit "$EXIT_MISSING_DEPS" - ;; - - arch) - # For Arch-based systems - if ! command_exists pacman; then - print_error "Pacman package manager not found" - exit "$EXIT_MISSING_DEPS" - fi - - # Check for missing packages - for pkg in $PACMAN_PACKAGES; do - if ! pacman_package_installed "$pkg"; then - MISSING_PACKAGES="$MISSING_PACKAGES $pkg" - fi - done - - if [ -n "$MISSING_PACKAGES" ]; then - print_info "Installing missing packages:$MISSING_PACKAGES" - sudo pacman -Sy - sudo pacman -S --noconfirm $MISSING_PACKAGES || { print_error "Failed to install dependencies"; exit "$EXIT_MISSING_DEPS"; } - else - print_info "All required packages are already installed." - fi - - install_preferred_appindicator "sudo pacman -S --noconfirm" "libayatana-appindicator" "libappindicator-gtk3" pacman_package_installed || exit "$EXIT_MISSING_DEPS" - ;; - - suse) - # For openSUSE - if ! command_exists zypper; then - print_error "Zypper package manager not found" - exit "$EXIT_MISSING_DEPS" - fi - - sudo zypper refresh || true - - if [[ "${SELECTED_ENGINE:-whisper_cpp}" == "whisper_cpp" && "${WHISPERCPP_BACKEND:-}" != "cpu" ]]; then - ZYPPER_PACKAGES="$ZYPPER_PACKAGES vulkan-tools vulkan-devel" - fi - - local MISSING_ZYPPER_PACKAGES=() - for pkg in $ZYPPER_PACKAGES; do - if ! suse_package_installed "$pkg"; then - MISSING_ZYPPER_PACKAGES+=("$pkg") - fi - done - - if [ "${#MISSING_ZYPPER_PACKAGES[@]}" -gt 0 ]; then - print_info "Installing missing packages: ${MISSING_ZYPPER_PACKAGES[*]}" - sudo zypper install -y "${MISSING_ZYPPER_PACKAGES[@]}" || { - print_error "Failed to install openSUSE base dependencies" - exit "$EXIT_MISSING_DEPS" - } - else - print_info "All base openSUSE packages are already installed." - fi - - local PY_PIP_CANDIDATES=() - local PY_GOBJECT_CANDIDATES=() - local PY_GOBJECT_CAIRO_CANDIDATES=() - local PY_DEVEL_CANDIDATES=() - local PY_VIRTUALENV_CANDIDATES=() - local PY_VENV_CANDIDATES=() - - read -r -a PY_PIP_CANDIDATES <<< "$(suse_python_package_candidates "pip")" - read -r -a PY_GOBJECT_CANDIDATES <<< "$(suse_python_package_candidates "gobject")" - read -r -a PY_GOBJECT_CAIRO_CANDIDATES <<< "$(suse_python_package_candidates "gobject-cairo")" - read -r -a PY_DEVEL_CANDIDATES <<< "$(suse_python_package_candidates "devel")" - read -r -a PY_VIRTUALENV_CANDIDATES <<< "$(suse_python_package_candidates "virtualenv")" - read -r -a PY_VENV_CANDIDATES <<< "$(suse_python_package_candidates "venv")" - - print_info "Resolving openSUSE Python packages for $(suse_python_package_prefix)..." - - if ! suse_install_first_available "Python pip" "${PY_PIP_CANDIDATES[@]}"; then - print_error "Failed to install Python pip package (tried: ${PY_PIP_CANDIDATES[*]})" - exit "$EXIT_MISSING_DEPS" - fi - - if ! suse_install_first_available "PyGObject bindings" "${PY_GOBJECT_CANDIDATES[@]}"; then - print_error "Failed to install PyGObject package (tried: ${PY_GOBJECT_CANDIDATES[*]})" - exit "$EXIT_MISSING_DEPS" - fi - - if ! suse_install_first_available "PyGObject Cairo bindings" "${PY_GOBJECT_CAIRO_CANDIDATES[@]}"; then - print_error "Failed to install PyGObject Cairo package (tried: ${PY_GOBJECT_CAIRO_CANDIDATES[*]})" - exit "$EXIT_MISSING_DEPS" - fi - - if ! suse_install_first_available "Python development headers" "${PY_DEVEL_CANDIDATES[@]}"; then - print_error "Failed to install Python development headers (tried: ${PY_DEVEL_CANDIDATES[*]})" - exit "$EXIT_MISSING_DEPS" - fi - - if ! suse_install_first_available "Python virtualenv/venv" "${PY_VIRTUALENV_CANDIDATES[@]}" "${PY_VENV_CANDIDATES[@]}"; then - print_warning "Python virtualenv/venv package was not found (tried: ${PY_VIRTUALENV_CANDIDATES[*]} ${PY_VENV_CANDIDATES[*]})" - print_warning "Continuing because python3 -m venv may still be available." - fi - - if ! suse_install_appindicator_runtime; then - print_error "Failed to install a working AppIndicator/Ayatana GI runtime on openSUSE." - print_error "Try manually: sudo zypper install typelib-1_0-AyatanaAppIndicator3-0_1 libayatana-appindicator3-1" - exit "$EXIT_MISSING_DEPS" - fi - - if [[ "${SELECTED_ENGINE:-whisper_cpp}" == "whisper_cpp" && "${WHISPERCPP_BACKEND:-}" != "cpu" ]]; then - if ! suse_shader_compiler_available; then - if ! suse_install_first_available "Vulkan shader compiler" shaderc glslang-devel glslang; then - print_warning "No Vulkan shader compiler found - whisper.cpp Vulkan build may fail" - print_warning "Install shaderc manually for glslc support if you want GPU acceleration." - fi - fi - - if ! suse_shader_compiler_available; then - print_warning "glslc/glslangValidator is still unavailable; CPU fallback will be used if Vulkan build fails." - fi - fi - ;; - - gentoo) - # For Gentoo Linux - if ! command_exists emerge; then - print_error "Emerge package manager not found" - exit "$EXIT_MISSING_DEPS" - fi - - print_info "Gentoo detected. Installing dependencies..." - print_warning "Gentoo uses emerge. This may take longer as packages are compiled from source." - - # Check for missing packages - MISSING_PACKAGES="" - for pkg in $EMERGE_PACKAGES; do - # Gentoo uses qlist to check if packages are installed - if ! qlist -I "$pkg" >/dev/null 2>&1; then - MISSING_PACKAGES="$MISSING_PACKAGES $pkg" - fi - done - - if [ -n "$MISSING_PACKAGES" ]; then - print_info "Installing packages:$MISSING_PACKAGES" - # Update Portage tree first - sudo emerge --sync || { print_error "Failed to sync Portage tree"; exit "$EXIT_NETWORK"; } - # Install missing packages - sudo emerge $MISSING_PACKAGES || { print_error "Failed to install dependencies"; exit "$EXIT_MISSING_DEPS"; } - else - print_info "All required packages are already installed." - fi - ;; - - alpine) - # For Alpine Linux - if ! command_exists apk; then - print_error "Apk package manager not found" - exit "$EXIT_MISSING_DEPS" - fi - - print_info "Alpine Linux detected." - print_warning "Alpine uses musl libc. Some Python packages may not have pre-built wheels." - - # Check for missing packages - MISSING_PACKAGES="" - for pkg in $APK_PACKAGES; do - if ! apk info -e "$pkg" >/dev/null 2>&1; then - MISSING_PACKAGES="$MISSING_PACKAGES $pkg" - fi - done - - if [ -n "$MISSING_PACKAGES" ]; then - print_info "Installing packages:$MISSING_PACKAGES" - sudo apk update || { print_error "Failed to update package indexes"; exit "$EXIT_NETWORK"; } - sudo apk add $MISSING_PACKAGES || { print_error "Failed to install dependencies"; exit "$EXIT_MISSING_DEPS"; } - else - print_info "All required packages are already installed." - fi - ;; - - void) - # For Void Linux - if ! command_exists xbps; then - print_error "Xbps package manager not found" - exit "$EXIT_MISSING_DEPS" - fi - - print_info "Void Linux detected." - - # Check for missing packages - MISSING_PACKAGES="" - for pkg in $XBPS_PACKAGES; do - if ! xbps-query "$pkg" >/dev/null 2>&1; then - MISSING_PACKAGES="$MISSING_PACKAGES $pkg" - fi - done - - if [ -n "$MISSING_PACKAGES" ]; then - print_info "Installing packages:$MISSING_PACKAGES" - sudo xbps-install -Sy $MISSING_PACKAGES || { print_error "Failed to install dependencies"; exit "$EXIT_MISSING_DEPS"; } - else - print_info "All required packages are already installed." - fi - ;; - - solus) - # For Solus - if ! command_exists eopkg; then - print_error "Eopkg package manager not found" - exit "$EXIT_MISSING_DEPS" - fi - - print_info "Solus detected." - - # Check for missing packages - MISSING_PACKAGES="" - for pkg in $EOPKG_PACKAGES; do - if ! eopkg list-installed | grep -qw "$pkg"; then - MISSING_PACKAGES="$MISSING_PACKAGES $pkg" - fi - done - - if [ -n "$MISSING_PACKAGES" ]; then - print_info "Installing packages:$MISSING_PACKAGES" - sudo eopkg install $MISSING_PACKAGES || { print_error "Failed to install dependencies"; exit "$EXIT_MISSING_DEPS"; } - else - print_info "All required packages are already installed." - fi - ;; - - mageia) - # For Mageia - if command_exists dnf; then - INSTALL_CMD="sudo dnf install -y" - UPDATE_CMD="sudo dnf check-update" - elif command_exists urpmi; then - INSTALL_CMD="sudo urpmi --force" - UPDATE_CMD="sudo urpmi.update -a" - else - print_error "No supported package manager found (dnf/urpmi)" - exit "$EXIT_MISSING_DEPS" - fi - - # Use similar packages to Fedora/RHEL - for pkg in $DNF_PACKAGES; do - # Mageia uses rpm like Fedora - if ! rpm -q "$pkg" >/dev/null 2>&1; then - MISSING_PACKAGES="$MISSING_PACKAGES $pkg" - fi - done - - if [ -n "$MISSING_PACKAGES" ]; then - print_info "Installing missing packages:$MISSING_PACKAGES" - $UPDATE_CMD 2>/dev/null || true - $INSTALL_CMD $MISSING_PACKAGES || { print_error "Failed to install dependencies"; exit "$EXIT_MISSING_DEPS"; } - else - print_info "All required packages are already installed." - fi - ;; - - *) - print_error "Unsupported distribution family: $DISTRO_FAMILY" - print_info "" - print_info "Your distribution ($DISTRO_NAME) is not officially supported." - print_info "However, you can still install Vocalinux manually:" - print_info "" - print_info "1. Run the dependency checker:" - print_info " bash scripts/check-system-deps.sh" - print_info "" - print_info "2. Install missing dependencies using your package manager" - print_info "" - print_info "3. Run the installer with --skip-system-deps:" - print_info " ./install.sh --skip-system-deps" - print_info "" - print_info "4. Or install from source in a virtual environment:" - print_info " /usr/bin/python3 -m venv --system-site-packages venv" - print_info " source venv/bin/activate" - print_info " pip install -e .[whisper,vad]" - print_info "" - print_info "For more information, see the project wiki:" - print_info " https://github.com/VocaHQ/vocalinux/wiki" - print_info "" - if [[ "$NON_INTERACTIVE" != "yes" ]]; then - read -p "Continue anyway? (y/n) " -n 1 -r - echo - if [[ ! $REPLY =~ ^[Yy]$ ]]; then - exit "$EXIT_USER_ABORT" - fi - else - print_info "Non-interactive mode: continuing (dependencies may be missing)..." - fi - ;; - esac -} - # Install system dependencies if [[ "$SKIP_SYSTEM_DEPS" == "yes" ]]; then print_warning "Skipping system dependency installation (--skip-system-deps specified)." @@ -2205,280 +1322,6 @@ DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/vocalinux" DESKTOP_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/applications" ICON_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/icons/hicolor/scalable/apps" -# Function to detect and install text input tools -install_text_input_tools() { - if [[ "$SKIP_SYSTEM_DEPS" == "yes" ]]; then - print_warning "Skipping text input tool installation (--skip-system-deps specified)." - return 0 - fi - - # Detect session type more robustly - local SESSION_TYPE="unknown" - - # Check XDG_SESSION_TYPE first. These are often unset without a login - # session even when DISPLAY is set; ${var:-} keeps set -u from aborting. - if [ -n "${XDG_SESSION_TYPE:-}" ]; then - SESSION_TYPE="${XDG_SESSION_TYPE:-}" - # Check for Wayland-specific environment variables - elif [ -n "${WAYLAND_DISPLAY:-}" ]; then - SESSION_TYPE="wayland" - # Check if X server is running - elif [ -n "${DISPLAY:-}" ] && command_exists xset && xset q &>/dev/null; then - SESSION_TYPE="x11" - # Check loginctl if available - elif command_exists loginctl; then - SESSION_TYPE=$(loginctl show-session $(loginctl | grep $(whoami) | awk '{print $1}') -p Type | cut -d= -f2 || true) - fi - - print_info "Detected session type: $SESSION_TYPE" - if [[ "$SESSION_TYPE" == "wayland" ]] && is_kde_plasma_session; then - print_kde_wayland_ibus_hint - fi - - # Install appropriate tools based on session type and distribution - case "$SESSION_TYPE" in - wayland) - print_info "Installing Wayland text input tools..." - case "$DISTRO_FAMILY" in - ubuntu|debian) - if ! apt_package_installed "wtype"; then - DEBIAN_FRONTEND=noninteractive sudo apt install -y wtype || { print_warning "Failed to install wtype. Text injection may not work properly."; } - else - print_info "wtype is already installed." - fi - ;; - fedora) - if command_exists dnf && ! dnf_package_installed "wtype"; then - sudo dnf install -y wtype || { print_warning "Failed to install wtype. Text injection may not work properly."; } - elif command_exists yum && ! rpm -q wtype &>/dev/null; then - sudo yum install -y wtype || { print_warning "Failed to install wtype. Text injection may not work properly."; } - else - print_info "wtype is already installed." - fi - ;; - arch) - if ! pacman_package_installed "wtype"; then - sudo pacman -S --noconfirm wtype || { print_warning "Failed to install wtype. Text injection may not work properly."; } - else - print_info "wtype is already installed." - fi - ;; - suse) - sudo zypper install -y wtype || { print_warning "Failed to install wtype. Text injection may not work properly."; } - ;; - gentoo) - if ! qlist -I wtype >/dev/null 2>&1; then - sudo emerge wtype || { print_warning "Failed to install wtype. Text injection may not work properly."; } - else - print_info "wtype is already installed." - fi - ;; - alpine) - if ! apk info -e wtype >/dev/null 2>&1; then - sudo apk add wtype || { print_warning "Failed to install wtype. Text injection may not work properly."; } - else - print_info "wtype is already installed." - fi - ;; - void) - if ! xbps-query wtype >/dev/null 2>&1; then - sudo xbps-install -Sy wtype || { print_warning "Failed to install wtype. Text injection may not work properly."; } - else - print_info "wtype is already installed." - fi - ;; - solus) - if ! eopkg list-installed | grep -qw wtype; then - sudo eopkg install wtype || { print_warning "Failed to install wtype. Text injection may not work properly."; } - else - print_info "wtype is already installed." - fi - ;; - mageia) - if command_exists dnf && ! rpm -q wtype >/dev/null 2>&1; then - sudo dnf install -y wtype || { print_warning "Failed to install wtype. Text injection may not work properly."; } - elif command_exists urpmi && ! rpm -q wtype >/dev/null 2>&1; then - sudo urpmi -y wtype || { print_warning "Failed to install wtype. Text injection may not work properly."; } - else - print_info "wtype is already installed." - fi - ;; - *) - print_warning "Unsupported distribution for Wayland text input tools." - print_warning "Please install 'wtype' manually for Wayland text input support." - ;; - esac - - # Try to install ydotool as additional fallback for Wayland - # ydotool works better with some compositors (like GNOME) where wtype may fail - print_info "Attempting to install ydotool for better Wayland compatibility..." - case "$DISTRO_FAMILY" in - ubuntu|debian) - if ! apt_package_installed "ydotool"; then - if ! DEBIAN_FRONTEND=noninteractive sudo apt install -y ydotool 2>/dev/null; then - if [[ "$DISTRO_FAMILY" == "debian" ]]; then - print_warning "ydotool is not packaged in Debian's standard repos." - print_info "For full Wayland input support, you can compile ydotool from source:" - print_info " sudo apt install -y git cmake libevdev-dev" - print_info " git clone https://github.com/ReimuNotMoe/ydotool.git /tmp/ydotool" - print_info " cmake -S /tmp/ydotool -B /tmp/ydotool/build && sudo cmake --build /tmp/ydotool/build --target install" - print_info " sudo systemctl enable --now ydotoold" - print_info "Alternatively, wtype (already installed) will handle most Wayland compositors." - else - print_info "ydotool not available in repos (optional)" - fi - fi - fi - ;; - fedora) - if command_exists dnf; then - sudo dnf install -y ydotool 2>/dev/null || print_info "ydotool not available in repos (optional)" - fi - ;; - arch) - if ! pacman_package_installed "ydotool"; then - sudo pacman -S --noconfirm ydotool 2>/dev/null || print_info "ydotool not available in repos (optional)" - fi - ;; - esac - - # Add user to input group for ydotool/dotool support - if ! groups | grep -q '\binput\b'; then - print_info "Adding $USER to 'input' group for text injection..." - sudo usermod -aG input "$USER" || print_warning "Failed to add user to input group" - print_warning "You will need to LOG OUT and back in for text injection to work with ydotool/dotool" - fi - - # Install udev rule for ydotool/dotool - if [ ! -f /etc/udev/rules.d/80-dotool.rules ]; then - print_info "Installing udev rule for input device access..." - echo 'KERNEL=="uinput", GROUP="input", MODE="0620", OPTIONS+="static_node=uinput"' \ - | sudo tee /etc/udev/rules.d/80-dotool.rules >/dev/null 2>&1 || print_warning "Failed to install udev rule" - sudo udevadm control --reload 2>/dev/null || true - sudo udevadm trigger 2>/dev/null || true - fi - ;; - - x11|"") - print_info "Installing X11 text input tools..." - case "$DISTRO_FAMILY" in - ubuntu|debian) - if ! apt_package_installed "xdotool"; then - DEBIAN_FRONTEND=noninteractive sudo apt install -y xdotool || { print_warning "Failed to install xdotool. Text injection may not work properly."; } - else - print_info "xdotool is already installed." - fi - ;; - fedora) - if command_exists dnf && ! dnf_package_installed "xdotool"; then - sudo dnf install -y xdotool || { print_warning "Failed to install xdotool. Text injection may not work properly."; } - elif command_exists yum && ! rpm -q xdotool &>/dev/null; then - sudo yum install -y xdotool || { print_warning "Failed to install xdotool. Text injection may not work properly."; } - else - print_info "xdotool is already installed." - fi - ;; - arch) - if ! pacman_package_installed "xdotool"; then - sudo pacman -S --noconfirm xdotool || { print_warning "Failed to install xdotool. Text injection may not work properly."; } - else - print_info "xdotool is already installed." - fi - ;; - suse) - sudo zypper install -y xdotool || { print_warning "Failed to install xdotool. Text injection may not work properly."; } - ;; - gentoo) - if ! qlist -I xdotool >/dev/null 2>&1; then - sudo emerge xdotool || { print_warning "Failed to install xdotool. Text injection may not work properly."; } - else - print_info "xdotool is already installed." - fi - ;; - alpine) - if ! apk info -e xdotool >/dev/null 2>&1; then - sudo apk add xdotool || { print_warning "Failed to install xdotool. Text injection may not work properly."; } - else - print_info "xdotool is already installed." - fi - ;; - void) - if ! xbps-query xdotool >/dev/null 2>&1; then - sudo xbps-install -Sy xdotool || { print_warning "Failed to install xdotool. Text injection may not work properly."; } - else - print_info "xdotool is already installed." - fi - ;; - solus) - if ! eopkg list-installed | grep -qw xdotool; then - sudo eopkg install xdotool || { print_warning "Failed to install xdotool. Text injection may not work properly."; } - else - print_info "xdotool is already installed." - fi - ;; - mageia) - if command_exists dnf && ! rpm -q xdotool >/dev/null 2>&1; then - sudo dnf install -y xdotool || { print_warning "Failed to install xdotool. Text injection may not work properly."; } - elif command_exists urpmi && ! rpm -q xdotool >/dev/null 2>&1; then - sudo urpmi -y xdotool || { print_warning "Failed to install xdotool. Text injection may not work properly."; } - else - print_info "xdotool is already installed." - fi - ;; - *) - print_warning "Unsupported distribution for X11 text input tools." - print_warning "Please install 'xdotool' manually for X11 text input support." - ;; - esac - ;; - - *) - print_warning "Unknown session type: $SESSION_TYPE" - print_warning "Installing both Wayland and X11 text input tools for compatibility..." - - # Install both tools based on distribution - case "$DISTRO_FAMILY" in - ubuntu|debian) - DEBIAN_FRONTEND=noninteractive sudo apt install -y xdotool wtype || { print_warning "Failed to install text input tools. Text injection may not work properly."; } - ;; - fedora|mageia) - if command_exists dnf; then - sudo dnf install -y xdotool wtype || { print_warning "Failed to install text input tools. Text injection may not work properly."; } - elif command_exists yum; then - sudo yum install -y xdotool wtype || { print_warning "Failed to install text input tools. Text injection may not work properly."; } - fi - # Mageia also supports urpmi - if [[ "$DISTRO_FAMILY" == "mageia" ]] && command_exists urpmi; then - sudo urpmi -y xdotool wtype || { print_warning "Failed to install text input tools. Text injection may not work properly."; } - fi - ;; - arch) - sudo pacman -S --noconfirm xdotool wtype || { print_warning "Failed to install text input tools. Text injection may not work properly."; } - ;; - suse) - sudo zypper install -y xdotool wtype || { print_warning "Failed to install text input tools. Text injection may not work properly."; } - ;; - gentoo) - sudo emerge xdotool wtype || { print_warning "Failed to install text input tools. Text injection may not work properly."; } - ;; - alpine) - sudo apk add xdotool wtype || { print_warning "Failed to install text input tools. Text injection may not work properly."; } - ;; - void) - sudo xbps-install -Sy xdotool wtype || { print_warning "Failed to install text input tools. Text injection may not work properly."; } - ;; - solus) - sudo eopkg install xdotool wtype || { print_warning "Failed to install text input tools. Text injection may not work properly."; } - ;; - *) - print_warning "Unsupported distribution for text input tools." - print_warning "Please install 'xdotool' and 'wtype' manually for text input support." - ;; - esac - ;; - esac -} - # Install text input tools based on session type install_text_input_tools @@ -3805,616 +2648,6 @@ if ! install_python_package; then fi # --------------------------------------------------------------------------- -# Model integrity verification -# -# Models are 40MB-2GB downloads that end up being loaded by native code, so no -# model is installed without matching a digest pinned in this repository. -# src/vocalinux/utils/model_checksums.txt is the same manifest the application -# uses at runtime; it is regenerated by scripts/generate-model-checksums.py. -# -# A model that cannot be verified is deleted and the function returns 1, which -# callers already treat as "leave it for first run" rather than aborting the -# install. The app re-downloads and re-verifies it later. -# --------------------------------------------------------------------------- -MODEL_CHECKSUMS_FILE="$INSTALL_DIR/src/vocalinux/utils/model_checksums.txt" - -# Print the digest of $1 using algorithm $2, trying the tools most likely present. -compute_file_digest() { - local file="$1" algo="$2" - - case "$algo" in - sha256) - if command_exists sha256sum; then sha256sum "$file" | cut -d' ' -f1 - elif command_exists shasum; then shasum -a 256 "$file" | cut -d' ' -f1 - elif command_exists openssl; then openssl dgst -sha256 "$file" | awk '{print $NF}' - else return 1; fi - ;; - *) - return 1 - ;; - esac -} - -# Compare $1 against algorithm $2 and digest $3. $4 labels the file in messages. -verify_digest() { - local file="$1" algo="$2" expected="$3" label="$4" actual - - if ! actual=$(compute_file_digest "$file" "$algo") || [ -z "$actual" ]; then - print_error "Cannot compute the $algo digest of $label: no sha256sum, shasum or openssl found." - return 1 - fi - - if [ "$actual" != "$expected" ]; then - print_error "$label failed $algo verification." - print_error " expected: $expected" - print_error " actual: $actual" - print_error "The file does not match the digest pinned in this release." - return 1 - fi - - print_success "$label verified ($algo)" - return 0 -} - -# Verify $1 against the manifest entry named $2 (defaults to $1's basename). -verify_model_checksum() { - local file="$1" - local key="${2:-$(basename "$file")}" - local algo expected size actual_size - - if [ ! -f "$MODEL_CHECKSUMS_FILE" ]; then - print_error "Checksum manifest not found at $MODEL_CHECKSUMS_FILE." - print_error "Cannot verify $key; refusing to install an unverified model." - return 1 - fi - - # Manifest columns: filename algorithm digest size-in-bytes - algo=$(awk -v k="$key" '$1==k {print $2; exit}' "$MODEL_CHECKSUMS_FILE") - expected=$(awk -v k="$key" '$1==k {print $3; exit}' "$MODEL_CHECKSUMS_FILE") - size=$(awk -v k="$key" '$1==k {print $4; exit}' "$MODEL_CHECKSUMS_FILE") - - if [ -z "$algo" ] || [ -z "$expected" ]; then - print_error "No checksum is pinned for $key in $MODEL_CHECKSUMS_FILE." - print_error "Regenerate it with scripts/generate-model-checksums.py." - return 1 - fi - - # Size first: it is free, and it reports a truncated download as truncation - # rather than as a digest mismatch that reads like tampering. - if [ -n "$size" ] && [ "$size" != "0" ]; then - actual_size=$(stat -c%s "$file" 2>/dev/null || stat -f%z "$file" 2>/dev/null || echo "") - if [ -n "$actual_size" ] && [ "$actual_size" != "$size" ]; then - print_error "$key is $actual_size bytes, expected $size (truncated download?)." - return 1 - fi - fi - - verify_digest "$file" "$algo" "$expected" "$key" -} - -# Verify an OpenAI Whisper checkpoint against the sha256 embedded in its own URL -# (they publish each file under a path segment that is its digest). -verify_openai_model_checksum() { - local file="$1" url="$2" label="$3" expected - - expected=$(printf '%s\n' "$url" | grep -oE '/[0-9a-f]{64}/' | tr -d '/' | head -n1) - if [ -z "$expected" ]; then - print_error "$url carries no sha256 path segment; refusing to install an unverified model." - return 1 - fi - - verify_digest "$file" "sha256" "$expected" "$label" -} - -# Download $1 to $2, preferring wget and falling back to curl. $3 labels the -# model in error messages. Leaves no partial file behind on failure. -download_model_file() { - local url="$1" dest="$2" label="$3" - - if command_exists wget; then - if ! wget --progress=bar:force:noscroll --tries=3 --timeout=60 -O "$dest" "$url" 2>&1; then - print_error "Failed to download $label with wget" - rm -f "$dest" - return 1 - fi - elif command_exists curl; then - # -f: fail on HTTP errors instead of saving the error page as the model - if ! curl -fL --progress-bar --retry 3 --retry-delay 2 -o "$dest" "$url"; then - print_error "Failed to download $label with curl" - rm -f "$dest" - return 1 - fi - else - print_error "Neither wget nor curl is available to download $label" - return 1 - fi - - if [ ! -s "$dest" ]; then - print_error "Downloaded $label is empty or missing" - rm -f "$dest" - return 1 - fi -} - -# Print the digest pinned for manifest entry $1, or nothing when unpinned. -pinned_digest_for() { - [ -f "$MODEL_CHECKSUMS_FILE" ] || return 1 - awk -v k="$1" '$1==k {print $3; exit}' "$MODEL_CHECKSUMS_FILE" -} - -# The Hugging Face commit the whisper.cpp digests were taken at. -whispercpp_pinned_revision() { - [ -f "$MODEL_CHECKSUMS_FILE" ] || return 1 - awk '/^#[[:space:]]*whispercpp-revision:/ {print $3; exit}' "$MODEL_CHECKSUMS_FILE" -} - -# False when the cloned release predates the manifest. That app has no runtime -# verification either, so refusing to pre-download would protect nothing. -model_verification_available() { - [ -f "$MODEL_CHECKSUMS_FILE" ] -} - -# Function to download and install Whisper tiny model -install_whisper_model() { - print_info "Installing Whisper tiny model (~75MB)..." - - # Create whisper models directory - local WHISPER_DIR="$DATA_DIR/models/whisper" - mkdir -p "$WHISPER_DIR" - - # Whisper tiny model URL and path - local TINY_MODEL_URL="https://openaipublic.azureedge.net/main/whisper/models/65147644a518d12f04e32d6f3b26facc3f8dd46e5390956a9424a650c0ce22b9/tiny.pt" - local TINY_MODEL_PATH="$WHISPER_DIR/tiny.pt" - - # An existing file is not a verified file: it may predate checksum - # verification, or have been replaced since. Hash it before trusting it, - # and re-download rather than keep something that does not match. - if [ -f "$TINY_MODEL_PATH" ]; then - if verify_openai_model_checksum "$TINY_MODEL_PATH" "$TINY_MODEL_URL" "Whisper tiny model"; then - print_info "Whisper tiny model already exists at $TINY_MODEL_PATH" - return 0 - fi - print_warning "The existing Whisper tiny model does not match its pinned digest; replacing it." - rm -f "$TINY_MODEL_PATH" - fi - - # Check internet connectivity - if ! command -v wget >/dev/null 2>&1 && ! command -v curl >/dev/null 2>&1; then - print_warning "Neither wget nor curl found. Cannot download Whisper model." - print_warning "Model will be downloaded on first application run." - return 1 - fi - - # Test internet connectivity (HTTP probes; ICMP ping is often blocked) - if ! check_connectivity; then - print_warning "No internet connection detected (HTTP probes to pypi.org/api.github.com/huggingface.co failed)." - print_warning "Whisper model will be downloaded on first application run." - return 1 - fi - - print_info "Downloading Whisper tiny model..." - print_info "This may take a few minutes depending on your internet connection." - - local TEMP_FILE="$VOCALINUX_TMP_DIR/tiny.pt" - - if ! download_model_file "$TINY_MODEL_URL" "$TEMP_FILE" "Whisper model"; then - return 1 - fi - - # Verify before the file reaches its final location, so a failed download is - # never installed. (The "already exists" path above hashes what it finds.) - if ! verify_openai_model_checksum "$TEMP_FILE" "$TINY_MODEL_URL" "Whisper tiny model"; then - rm -f "$TEMP_FILE" - print_warning "Whisper model will be downloaded and verified on first application run." - return 1 - fi - - # Move to final location - mv "$TEMP_FILE" "$TINY_MODEL_PATH" - - # Verify the model file - if [ -f "$TINY_MODEL_PATH" ]; then - local MODEL_SIZE=$(du -h "$TINY_MODEL_PATH" | cut -f1) - print_success "Whisper tiny model installed successfully ($MODEL_SIZE)" - - # Create a marker file to indicate this model was pre-installed - echo "$(date)" > "$WHISPER_DIR/.vocalinux_preinstalled" - - return 0 - else - print_error "Whisper model installation failed" - return 1 - fi -} - -# Function to download and install VOSK models -install_vosk_models() { - print_info "Installing VOSK speech recognition models..." - - # Create models directory - local MODELS_DIR="$DATA_DIR/models" - mkdir -p "$MODELS_DIR" - - # Define model information - local SMALL_MODEL_URL="https://alphacephei.com/vosk/models/vosk-model-small-en-us-0.15.zip" - local SMALL_MODEL_NAME="vosk-model-small-en-us-0.15" - local SMALL_MODEL_PATH="$MODELS_DIR/$SMALL_MODEL_NAME" - - local SMALL_MODEL_ARCHIVE="$SMALL_MODEL_NAME.zip" - # The extracted tree has no digest of its own — the pin covers the zip, which - # is deleted after unpacking. So whoever unpacks it records the verified zip - # digest in a stamp (here, and in the app's own downloader), and the directory - # is trusted only while that stamp matches what we pin today. - local SMALL_MODEL_STAMP="$SMALL_MODEL_PATH/.vocalinux_verified" - local EXPECTED_ZIP_DIGEST - EXPECTED_ZIP_DIGEST=$(pinned_digest_for "$SMALL_MODEL_ARCHIVE" || true) - if [ -d "$SMALL_MODEL_PATH" ]; then - if [ -n "$EXPECTED_ZIP_DIGEST" ] && [ -f "$SMALL_MODEL_STAMP" ] && - [ "$(cat "$SMALL_MODEL_STAMP" 2>/dev/null)" = "$EXPECTED_ZIP_DIGEST" ]; then - print_info "Small VOSK model already exists at $SMALL_MODEL_PATH (verified)" - return 0 - fi - # Unverified is not the same as known-bad, and this runs on every install: - # a tree unpacked before stamps existed, or by the app itself, simply has - # no stamp. Deleting it here would trade a working model for no model - # whenever the download, unzip or network that follows fails. Fetch and - # verify the replacement first; the swap below is what removes this tree. - print_warning "The existing VOSK model carries no matching verification stamp; re-downloading it." - fi - - # Refuse before spending the download, not after verification rejects it. - if [ -z "$EXPECTED_ZIP_DIGEST" ]; then - print_error "No checksum is pinned for $SMALL_MODEL_ARCHIVE in $MODEL_CHECKSUMS_FILE." - print_warning "VOSK model will be downloaded and verified on first application run." - return 1 - fi - - # Check internet connectivity - if ! command -v wget >/dev/null 2>&1 && ! command -v curl >/dev/null 2>&1; then - print_warning "Neither wget nor curl found. Cannot download VOSK models." - print_warning "Models will be downloaded on first application run." - return 1 - fi - - # Test internet connectivity (HTTP probes; ICMP ping is often blocked) - if ! check_connectivity; then - print_warning "No internet connection detected (HTTP probes to pypi.org/api.github.com/huggingface.co failed)." - print_warning "VOSK models will be downloaded on first application run." - return 1 - fi - - print_info "Downloading small VOSK model (approximately 40MB)..." - print_info "This may take a few minutes depending on your internet connection." - - local TEMP_ZIP="$VOCALINUX_TMP_DIR/$(basename $SMALL_MODEL_URL)" - - if ! download_model_file "$SMALL_MODEL_URL" "$TEMP_ZIP" "VOSK model"; then - return 1 - fi - - # Verify before extracting: a zip that fails its pinned digest must not get - # as far as writing files into the models directory. - if ! verify_model_checksum "$TEMP_ZIP"; then - rm -f "$TEMP_ZIP" - print_warning "VOSK model will be downloaded and verified on first application run." - return 1 - fi - - print_info "Extracting VOSK model..." - - if ! command -v unzip >/dev/null 2>&1; then - print_error "unzip command not found. Cannot extract VOSK model." - rm -f "$TEMP_ZIP" - return 1 - fi - - # Unpack beside the model rather than over it: any model already installed - # stays usable until a complete, verified replacement exists, and staging in - # MODELS_DIR keeps the swap a same-filesystem rename. - local STAGING_DIR="$MODELS_DIR/.vosk-staging.$$" - # A run killed mid-swap leaves its scratch directories behind; they are named - # so they can be recognised and are of no use to a later run. - find "$MODELS_DIR" -maxdepth 1 -type d \ - \( -name '.vosk-staging.*' -o -name '.*.replaced.*' \) \ - -exec rm -rf {} + 2>/dev/null || true - if ! mkdir -p "$STAGING_DIR"; then - print_error "Failed to create a staging directory under $MODELS_DIR" - rm -f "$TEMP_ZIP" - return 1 - fi - - if ! unzip -q "$TEMP_ZIP" -d "$STAGING_DIR"; then - print_error "Failed to extract VOSK model" - rm -f "$TEMP_ZIP" - rm -rf "$STAGING_DIR" - return 1 - fi - rm -f "$TEMP_ZIP" - - if [ ! -d "$STAGING_DIR/$SMALL_MODEL_NAME" ]; then - print_error "VOSK model extraction failed - $SMALL_MODEL_NAME not found in the archive" - rm -rf "$STAGING_DIR" - return 1 - fi - - chmod -R 755 "$STAGING_DIR/$SMALL_MODEL_NAME" - echo "$(date)" > "$STAGING_DIR/$SMALL_MODEL_NAME/.vocalinux_preinstalled" - - # Record the digest this tree was extracted from, so a later run can tell a - # verified model from one that merely exists. Written before the swap: an - # unstamped tree is one this function would download all over again. - if [ -z "$EXPECTED_ZIP_DIGEST" ] || - ! printf '%s\n' "$EXPECTED_ZIP_DIGEST" > "$STAGING_DIR/$SMALL_MODEL_NAME/.vocalinux_verified"; then - print_error "Could not record the verified digest for $SMALL_MODEL_NAME" - rm -rf "$STAGING_DIR" - return 1 - fi - - # Swap. The old tree is moved aside rather than deleted, so a failed rename - # can put it back. - local REPLACED_DIR="$MODELS_DIR/.$SMALL_MODEL_NAME.replaced.$$" - if [ -d "$SMALL_MODEL_PATH" ] && ! mv "$SMALL_MODEL_PATH" "$REPLACED_DIR"; then - print_error "Could not move the existing VOSK model aside; keeping it" - rm -rf "$STAGING_DIR" - return 1 - fi - if ! mv "$STAGING_DIR/$SMALL_MODEL_NAME" "$SMALL_MODEL_PATH"; then - print_error "Failed to install the verified VOSK model" - if [ -d "$REPLACED_DIR" ]; then - mv "$REPLACED_DIR" "$SMALL_MODEL_PATH" - fi - rm -rf "$STAGING_DIR" - return 1 - fi - rm -rf "$REPLACED_DIR" "$STAGING_DIR" - - print_success "VOSK small model installed successfully at $SMALL_MODEL_PATH" - return 0 -} - -# Function to download and install whisper.cpp tiny model -install_whispercpp_model() { - print_info "Installing whisper.cpp tiny model (~39MB)..." - - # Create whisper.cpp models directory - local WHISPERCPP_DIR="$DATA_DIR/models/whispercpp" - mkdir -p "$WHISPERCPP_DIR" - - # whisper.cpp tiny model URL and path. The Hugging Face revision is pinned to - # the one the digests in model_checksums.txt were taken at, so upstream - # replacing ggml-tiny.bin cannot turn every install into a checksum failure. - local WHISPERCPP_REVISION - WHISPERCPP_REVISION=$(whispercpp_pinned_revision) - if [ -z "$WHISPERCPP_REVISION" ]; then - print_error "No whisper.cpp revision is pinned in $MODEL_CHECKSUMS_FILE." - print_warning "whisper.cpp model will be downloaded and verified on first application run." - return 1 - fi - local TINY_MODEL_URL="https://huggingface.co/ggerganov/whisper.cpp/resolve/$WHISPERCPP_REVISION/ggml-tiny.bin" - local TINY_MODEL_PATH="$WHISPERCPP_DIR/ggml-tiny.bin" - - # An existing file is not a verified file: it may predate checksum - # verification, or have been replaced since. Hash it before trusting it, - # and re-download rather than keep something that does not match. - if [ -f "$TINY_MODEL_PATH" ]; then - if verify_model_checksum "$TINY_MODEL_PATH" "ggml-tiny.bin"; then - print_info "whisper.cpp tiny model already exists at $TINY_MODEL_PATH" - return 0 - fi - print_warning "The existing whisper.cpp tiny model does not match its pinned digest; replacing it." - rm -f "$TINY_MODEL_PATH" - fi - - # Check internet connectivity - if ! command -v wget >/dev/null 2>&1 && ! command -v curl >/dev/null 2>&1; then - print_warning "Neither wget nor curl found. Cannot download whisper.cpp model." - print_warning "Model will be downloaded on first application run." - return 1 - fi - - # Test internet connectivity (HTTP probes; ICMP ping is often blocked) - if ! check_connectivity; then - print_warning "No internet connection detected (HTTP probes to pypi.org/api.github.com/huggingface.co failed)." - print_warning "whisper.cpp model will be downloaded on first application run." - return 1 - fi - - print_info "Downloading whisper.cpp tiny model..." - print_info "This may take a few minutes depending on your internet connection." - - local TEMP_FILE="$VOCALINUX_TMP_DIR/ggml-tiny.bin" - - if ! download_model_file "$TINY_MODEL_URL" "$TEMP_FILE" "whisper.cpp model"; then - return 1 - fi - - # Verify before the file reaches its final location, so a failed download is - # never installed. (The "already exists" path above hashes what it finds.) - if ! verify_model_checksum "$TEMP_FILE" "ggml-tiny.bin"; then - rm -f "$TEMP_FILE" - print_warning "whisper.cpp model will be downloaded and verified on first application run." - return 1 - fi - - # Move to final location - mv "$TEMP_FILE" "$TINY_MODEL_PATH" - - # Verify the model file - if [ -f "$TINY_MODEL_PATH" ]; then - local MODEL_SIZE=$(du -h "$TINY_MODEL_PATH" | cut -f1) - print_success "whisper.cpp tiny model installed successfully ($MODEL_SIZE)" - - # Create a marker file to indicate this model was pre-installed - echo "$(date)" > "$WHISPERCPP_DIR/.vocalinux_preinstalled" - - return 0 - else - print_error "whisper.cpp model installation failed" - return 1 - fi -} - -# Function to install desktop entry with error handling -install_desktop_entry() { - print_info "Installing desktop entry..." - - # Check if desktop entry file exists - if [ ! -f "vocalinux.desktop" ]; then - print_error "Desktop entry file not found: vocalinux.desktop" - return 1 - fi - - # Create desktop directory if it doesn't exist - mkdir -p "$DESKTOP_DIR" || { - print_error "Failed to create desktop directory: $DESKTOP_DIR" - return 1 - } - - # Copy desktop entry - cp vocalinux.desktop "$DESKTOP_DIR/" || { - print_error "Failed to copy desktop entry to $DESKTOP_DIR" - return 1 - } - - # Update the desktop entry to use the wrapper script with GI_TYPELIB_PATH - WRAPPER_SCRIPT="$HOME/.local/bin/vocalinux-gui" - if [ ! -f "$WRAPPER_SCRIPT" ]; then - print_warning "Wrapper script not found at $WRAPPER_SCRIPT" - print_warning "Desktop entry may not work correctly" - else - # Update Exec line to include GI_TYPELIB_PATH for PyGObject - # Use the detected path for cross-distro compatibility - sed -i "s|^Exec=vocalinux|Exec=env GI_TYPELIB_PATH=$GI_TYPELIB_DETECTED $WRAPPER_SCRIPT|" "$DESKTOP_DIR/vocalinux.desktop" || { - print_warning "Failed to update desktop entry path" - } - print_info "Updated desktop entry to use wrapper script with GI_TYPELIB_PATH" - fi - - # Make desktop entry executable - chmod +x "$DESKTOP_DIR/vocalinux.desktop" || { - print_warning "Failed to make desktop entry executable" - } - - return 0 -} - -# Function to install icons with error handling -install_icons() { - print_info "Installing application icons..." - - # Create icon directory if it doesn't exist - mkdir -p "$ICON_DIR" || { - print_error "Failed to create icon directory: $ICON_DIR" - return 1 - } - - # Check if icons directory exists - if [ ! -d "resources/icons/scalable" ]; then - print_warning "Custom icons not found in resources/icons/scalable directory" - return 1 - fi - - # List of icons to install - local ICONS=( - "vocalinux.svg" - "vocalinux-microphone.svg" - "vocalinux-microphone-off.svg" - "vocalinux-microphone-process.svg" - ) - - # Install each icon - local INSTALLED_COUNT=0 - for icon in "${ICONS[@]}"; do - if [ -f "resources/icons/scalable/$icon" ]; then - cp "resources/icons/scalable/$icon" "$ICON_DIR/" || { - print_warning "Failed to copy icon: $icon" - continue - } - ((INSTALLED_COUNT++)) - else - print_warning "Icon not found: resources/icons/scalable/$icon" - fi - done - - if [ "$INSTALLED_COUNT" -eq "${#ICONS[@]}" ]; then - print_success "Installed all custom Vocalinux icons" - return 0 - elif [ "$INSTALLED_COUNT" -gt 0 ]; then - print_warning "Installed $INSTALLED_COUNT/${#ICONS[@]} custom Vocalinux icons" - return 0 - else - print_error "Failed to install any icons" - return 1 - fi -} - -# Function to update icon cache and desktop database -update_icon_cache() { - print_info "Updating icon cache..." - - # Check if gtk-update-icon-cache command exists - if command_exists gtk-update-icon-cache; then - gtk-update-icon-cache -f -t "${XDG_DATA_HOME:-$HOME/.local/share}/icons/hicolor" 2>/dev/null || { - print_warning "Failed to update icon cache" - } - else - print_warning "gtk-update-icon-cache command not found, skipping icon cache update" - fi - - # Update desktop database so the app appears in application menus immediately - print_info "Updating desktop database..." - if command_exists update-desktop-database; then - update-desktop-database "${XDG_DATA_HOME:-$HOME/.local/share}/applications" 2>/dev/null || { - print_warning "Failed to update desktop database" - } - else - print_warning "update-desktop-database command not found - app may not appear in menu until next login" - fi -} - -# Function to install resources (icons, sounds) to the virtual environment -# so the resource_manager can find them at runtime -install_resources_to_venv() { - print_info "Installing resources to virtual environment..." - - # Target directory: $VENV_DIR/share/vocalinux/resources - local VENV_RESOURCES_DIR="$VENV_DIR/share/vocalinux/resources" - - # Create directories - mkdir -p "$VENV_RESOURCES_DIR/icons/scalable" || { - print_warning "Failed to create venv resources directory" - return 1 - } - mkdir -p "$VENV_RESOURCES_DIR/sounds" || { - print_warning "Failed to create venv sounds directory" - return 1 - } - - # Copy icons if available - if [ -d "resources/icons/scalable" ]; then - cp resources/icons/scalable/*.svg "$VENV_RESOURCES_DIR/icons/scalable/" 2>/dev/null || { - print_warning "Failed to copy icons to venv resources" - } - fi - - # Copy sounds if available - if [ -d "resources/sounds" ]; then - cp resources/sounds/*.wav "$VENV_RESOURCES_DIR/sounds/" 2>/dev/null || { - print_warning "Failed to copy sounds to venv resources" - } - fi - - # Verify - local ICON_COUNT=$(ls "$VENV_RESOURCES_DIR/icons/scalable/"*.svg 2>/dev/null | wc -l) - local SOUND_COUNT=$(ls "$VENV_RESOURCES_DIR/sounds/"*.wav 2>/dev/null | wc -l) - - if [ "$ICON_COUNT" -gt 0 ] && [ "$SOUND_COUNT" -gt 0 ]; then - print_success "Installed resources to venv ($ICON_COUNT icons, $SOUND_COUNT sounds)" - else - print_warning "Some resources may be missing from venv ($ICON_COUNT icons, $SOUND_COUNT sounds)" - fi -} - # Install desktop entry install_desktop_entry || print_warning "Desktop entry installation failed" diff --git a/tests/test_cross_distro_compatibility.py b/tests/test_cross_distro_compatibility.py index ee99ea8..a5485b8 100644 --- a/tests/test_cross_distro_compatibility.py +++ b/tests/test_cross_distro_compatibility.py @@ -36,11 +36,12 @@ class TestCrossDistroCompatibility: @pytest.fixture def install_sh_content(self): - """Fixture to load install.sh content.""" + """Fixture to load the installer entry point and sourced modules.""" install_sh_path = REPO_ROOT / "install.sh" if not install_sh_path.exists(): pytest.skip(f"install.sh not found at {install_sh_path}") - return install_sh_path.read_text() + parts = [install_sh_path, *sorted((REPO_ROOT / "install.d").glob("*.sh"))] + return "\n".join(path.read_text(encoding="utf-8") for path in parts) @pytest.fixture def workflow_content(self): diff --git a/tests/test_installer_cuda_diagnostics.py b/tests/test_installer_cuda_diagnostics.py index f7ff692..5caaa0a 100644 --- a/tests/test_installer_cuda_diagnostics.py +++ b/tests/test_installer_cuda_diagnostics.py @@ -4,10 +4,12 @@ import unittest from pathlib import Path INSTALLER = Path(__file__).resolve().parents[1] / "install.sh" +INSTALLER_MODULES = Path(__file__).resolve().parents[1] / "install.d" def _installer_source() -> str: - return INSTALLER.read_text(encoding="utf-8") + parts = [INSTALLER, *sorted(INSTALLER_MODULES.glob("*.sh"))] + return "\n".join(path.read_text(encoding="utf-8") for path in parts) class InstallerCudaDiagnosticsTests(unittest.TestCase): diff --git a/tests/test_installer_modules.py b/tests/test_installer_modules.py new file mode 100644 index 0000000..4a65767 --- /dev/null +++ b/tests/test_installer_modules.py @@ -0,0 +1,71 @@ +"""Structural guards for the sourced install.sh modules.""" + +import subprocess +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +INSTALLER = REPO_ROOT / "install.sh" +MODULE_DIR = REPO_ROOT / "install.d" +PIPELINE = REPO_ROOT / ".github" / "workflows" / "unified-pipeline.yml" +LABELER = REPO_ROOT / ".github" / "labeler.yml" +EXPECTED_MODULES = { + "desktop.sh", + "interactive.sh", + "models.sh", + "system_dependencies.sh", +} + + +def test_installer_loads_every_module_from_the_resolved_tree() -> None: + source = INSTALLER.read_text(encoding="utf-8") + assert 'module="$INSTALL_DIR/install.d/$1"' in source + assert "source_installer_module()" in source + for module in EXPECTED_MODULES: + assert module in source + + remote_handoff = source.index("handoff_to_tagged_installer") + module_loader = source.index("source_installer_module()") + assert remote_handoff < module_loader + + +def test_modules_are_shell_syntax_valid_and_safe_to_source() -> None: + modules = sorted(MODULE_DIR.glob("*.sh")) + assert {path.name for path in modules} == EXPECTED_MODULES + for module in modules: + syntax = subprocess.run( + ["bash", "-n", str(module)], + capture_output=True, + text=True, + ) + assert syntax.returncode == 0, syntax.stderr + + sourced = subprocess.run( + ["bash", "-c", 'set -Eeuo pipefail; source "$1"', "bash", str(module)], + capture_output=True, + text=True, + ) + assert sourced.returncode == 0, sourced.stderr + assert sourced.stdout == "" + + +def test_entry_point_is_an_orchestrator_not_a_four_thousand_line_monolith() -> None: + assert len(INSTALLER.read_text(encoding="utf-8").splitlines()) < 3500 + + +def test_module_changes_reach_the_python_ci_jobs() -> None: + workflow = PIPELINE.read_text(encoding="utf-8") + assert "- 'install.d/**'" in workflow + + +def test_module_changes_receive_the_installer_label() -> None: + labeler = LABELER.read_text(encoding="utf-8") + installer_rules = labeler[labeler.index("installer:") : labeler.index("\n# Icons")] + assert '"install.d/**/*"' in installer_rules + + +def test_interactive_guide_does_not_invent_privacy_or_ranking_claims() -> None: + source = (MODULE_DIR / "interactive.sh").read_text(encoding="utf-8") + assert "100% offline" not in source + assert "never leaves your computer" not in source + assert "99+" not in source + assert "Fastest, most accurate" not in source diff --git a/tests/test_installer_pinned_dependencies.py b/tests/test_installer_pinned_dependencies.py index c63c56e..de3a352 100644 --- a/tests/test_installer_pinned_dependencies.py +++ b/tests/test_installer_pinned_dependencies.py @@ -19,6 +19,15 @@ from packaging.requirements import Requirement ROOT = Path(__file__).resolve().parents[1] INSTALLER = ROOT / "install.sh" +INSTALLER_MODULES = ROOT / "install.d" + + +def installer_source() -> str: + """Read the installer entry point and all sourced implementation modules.""" + parts = [INSTALLER, *sorted(INSTALLER_MODULES.glob("*.sh"))] + return "\n".join(path.read_text(encoding="utf-8") for path in parts) + + SELECTOR = ROOT / "scripts/installer_requirements.py" @@ -331,7 +340,7 @@ def test_source_built_runtime_packages_have_build_deps_on_split_devel_distros() """The pinned runtime builds these from source; distro lists that split -devel packages must carry their build deps.""" runtime = (ROOT / "requirements/runtime.txt").read_text(encoding="utf-8") - installer = INSTALLER.read_text(encoding="utf-8") + installer = installer_source() def package_line(prefix: str) -> str: return next(line for line in installer.splitlines() if f"local {prefix}=" in line) diff --git a/tests/test_installer_review_notes.py b/tests/test_installer_review_notes.py index 00f2b57..fd80f57 100644 --- a/tests/test_installer_review_notes.py +++ b/tests/test_installer_review_notes.py @@ -6,13 +6,15 @@ from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[1] INSTALLER = Path(__file__).resolve().parents[1] / "install.sh" +INSTALLER_MODULES = REPO_ROOT / "install.d" PYPROJECT = REPO_ROOT / "pyproject.toml" SETTINGS = Path(__file__).resolve().parents[1] / "src" / "vocalinux" / "ui" / "settings_dialog.py" AGENTS = Path(__file__).resolve().parents[1] / "AGENTS.md" def _installer_source() -> str: - return INSTALLER.read_text(encoding="utf-8") + parts = [INSTALLER, *sorted(INSTALLER_MODULES.glob("*.sh"))] + return "\n".join(path.read_text(encoding="utf-8") for path in parts) def test_help_places_transcript_under_installation() -> None: diff --git a/tests/test_model_checksums.py b/tests/test_model_checksums.py index 0139e53..1050094 100644 --- a/tests/test_model_checksums.py +++ b/tests/test_model_checksums.py @@ -43,6 +43,11 @@ from vocalinux.utils.whispercpp_model_info import ( REPO_ROOT = Path(__file__).resolve().parents[1] INSTALL_SH = REPO_ROOT / "install.sh" +INSTALLER_MODULES = REPO_ROOT / "install.d" +INSTALLER_SOURCE = "\n".join( + path.read_text(encoding="utf-8") + for path in (INSTALL_SH, *sorted(INSTALLER_MODULES.glob("*.sh"))) +) class TestManifestCoverage(unittest.TestCase): @@ -227,7 +232,7 @@ INSTALL_DIR="%s" ) def _source(self) -> str: - text = INSTALL_SH.read_text() + text = INSTALLER_SOURCE chunks = [] for name in self.FUNCTIONS: start = text.index(f"\n{name}() {{") @@ -353,7 +358,7 @@ class TestExistingModelsAreVerified(unittest.TestCase): every install predating checksum verification — kept an unverified model. """ - SOURCE = INSTALL_SH.read_text() + SOURCE = INSTALLER_SOURCE def _function_body(self, name: str) -> str: start = self.SOURCE.index(f"\n{name}() {{") @@ -427,7 +432,7 @@ class TestVerificationStamp(unittest.TestCase): """ def test_the_installer_uses_the_same_stamp_name(self): - self.assertIn(VERIFICATION_STAMP_NAME, INSTALL_SH.read_text()) + self.assertIn(VERIFICATION_STAMP_NAME, INSTALLER_SOURCE) def test_it_records_the_pinned_digest_of_the_archive(self): with TemporaryDirectory() as tree: @@ -524,7 +529,7 @@ download_model_file() { curl.chmod(0o755) def _source(self) -> str: - text = INSTALL_SH.read_text() + text = INSTALLER_SOURCE chunks = [] for name in self.FUNCTIONS: start = text.index(f"\n{name}() {{") @@ -614,7 +619,7 @@ class TestReleaseWithoutAManifest(unittest.TestCase): first run. A manifest that omits a model still fails closed. """ - SOURCE = INSTALL_SH.read_text() + SOURCE = INSTALLER_SOURCE PRELUDE = """ set -uo pipefail