refactor(installer): split implementation into sourced modules (#862)
* refactor(installer): split implementation into sourced modules * test(installer): harden module path coverage * fix(installer): qualify interactive privacy and engine claims The guided installer overstated local-only privacy and ranked whisper.cpp without evidence. Match PRODUCT.md: on-device local engines, optional Remote API, and the ~33 language catalog plus Auto-detect. --------- Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
This commit is contained in:
co-authored by
Jatin K Malik
parent
38e5be70ba
commit
244b62c8ad
@@ -45,6 +45,7 @@ installer:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "install.sh"
|
||||
- "install.d/**/*"
|
||||
- "uninstall.sh"
|
||||
|
||||
# Icons, sounds, screenshots, promo assets
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 |
|
||||
|---|---|
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
+21
-1788
File diff suppressed because it is too large
Load Diff
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user