Files
voculinux/install.d/models.sh
T
244b62c8ad 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>
2026-09-29 01:07:16 -07:00

452 lines
18 KiB
Bash

#!/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
}