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:
Sergey Silaev
2026-09-29 01:07:16 -07:00
committed by GitHub
co-authored by Jatin K Malik
parent 38e5be70ba
commit 244b62c8ad
14 changed files with 1925 additions and 1799 deletions
+1
View File
@@ -45,6 +45,7 @@ installer:
- changed-files:
- any-glob-to-any-file:
- "install.sh"
- "install.d/**/*"
- "uninstall.sh"
# Icons, sounds, screenshots, promo assets
+1
View File
@@ -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'
+3 -1
View File
@@ -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 |
|---|---|
+164
View File
@@ -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
}
+312
View File
@@ -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 ""
}
+451
View File
@@ -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
}
+872
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -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):
+3 -1
View File
@@ -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):
+71
View File
@@ -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
+10 -1
View File
@@ -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)
+3 -1
View File
@@ -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:
+10 -5
View File
@@ -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