diff --git a/AGENTS.md b/AGENTS.md index 59f9fb7..14896ab 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,6 +27,7 @@ Vocalinux is a voice dictation system for Linux. It uses: # Or manually: pip install -e ".[dev]" +# just: https://just.systems or the distro package `just` # Dependency lock files (see "Dependency Management" below) just lock # regenerate uv.lock + requirements/*.txt after changing deps just lock-check # fail if uv.lock is stale relative to pyproject.toml @@ -91,12 +92,14 @@ npm run test # Jest tests ## Dependency Management (uv + lockfiles) -All dependency versions are pinned. The source of truth is `uv.lock`; the -`requirements/*.txt` files are generated hash-pinned exports consumed by `install.sh`, -the AppImage build, and CI. **Never edit `requirements/*.txt` by hand** — change -`pyproject.toml` (or `requirements/whisper.in` for the whisper engine), run `just lock`, -and commit the result together with the manifest change. uv itself is version-pinned -via `[tool.uv]` in `pyproject.toml`. +The source of truth is `uv.lock`. `just lock` regenerates it and the +`requirements/*.txt` hash-pinned exports. Those exports exist for later +packaging work (`install.sh`, AppImage, CI; phases 2, 3, and 5 of #701) +and are unused until those phases land. Do not edit `requirements/*.txt` +by hand. Change `pyproject.toml` (or `requirements/whisper.in` for the +whisper engine), run `just lock`, and commit the lock plus the exports +with the manifest change. uv itself is version-pinned via `[tool.uv]` +in `pyproject.toml`. - **PyGObject always comes from the distro** (`python3-gi` through a `--system-site-packages` venv). It cannot be pip-installed on Ubuntu 24.04, and diff --git a/docs/DISTRO_COMPATIBILITY.md b/docs/DISTRO_COMPATIBILITY.md index caf2989..415f558 100644 --- a/docs/DISTRO_COMPATIBILITY.md +++ b/docs/DISTRO_COMPATIBILITY.md @@ -224,8 +224,8 @@ curl -fsSL https://raw.githubusercontent.com/VocaHQ/vocalinux/main/install.sh | ### Important: Install System Packages First -Before running `pip install vocalinux` or `pipx install vocalinux`, you **must** -install the required system packages: +Before running `pip install vocalinux`, `pip install "vocalinux[vosk]"`, or +`pipx install vocalinux`, you **must** install the required system packages: #### Ubuntu/Debian ```bash @@ -278,6 +278,7 @@ sudo zypper install "${PYVER}-gobject" "${PYVER}-gobject-cairo" gtk3 \ 3. **Install Vocalinux from PyPI**: ```bash pip install vocalinux + # vosk engine: pip install "vocalinux[vosk]" ``` 4. **Run Vocalinux**: @@ -307,6 +308,7 @@ sudo zypper install "${PYVER}-gobject" "${PYVER}-gobject-cairo" gtk3 \ 3. **Install Vocalinux**: ```bash pipx install vocalinux + # vosk engine: pipx install "vocalinux[vosk]" ``` 4. **Run Vocalinux**: diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 0ec6648..abe5ef0 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -64,6 +64,7 @@ python3 -m venv ~/.local/share/vocalinux-pypi/venv --system-site-packages source ~/.local/share/vocalinux-pypi/venv/bin/activate pip install --upgrade pip setuptools wheel pip install vocalinux +# vosk engine: pip install "vocalinux[vosk]" vocalinux ``` @@ -79,6 +80,7 @@ python3 -m venv ~/.local/share/vocalinux-pypi/venv --system-site-packages source ~/.local/share/vocalinux-pypi/venv/bin/activate pip install --upgrade pip setuptools wheel pip install vocalinux +# vosk engine: pip install "vocalinux[vosk]" vocalinux ``` @@ -99,6 +101,7 @@ python -m venv ~/.local/share/vocalinux-pypi/venv --system-site-packages source ~/.local/share/vocalinux-pypi/venv/bin/activate pip install --upgrade pip setuptools wheel pip install vocalinux +# vosk engine: pip install "vocalinux[vosk]" vocalinux ``` @@ -118,6 +121,7 @@ python3 -m venv ~/.local/share/vocalinux-pypi/venv --system-site-packages source ~/.local/share/vocalinux-pypi/venv/bin/activate pip install --upgrade pip setuptools wheel pip install vocalinux +# vosk engine: pip install "vocalinux[vosk]" vocalinux ``` @@ -431,7 +435,8 @@ pip install ".[vad]" pip install -e ".[dev,vad]" ``` -For PyPI instead of a local checkout, use `pip install vocalinux` after installing +For PyPI instead of a local checkout, use `pip install vocalinux` (or +`pip install "vocalinux[vosk]"` for the vosk engine) after installing the same system packages and creating the virtual environment with `--system-site-packages`. diff --git a/docs/RELEASE_PROCESS.md b/docs/RELEASE_PROCESS.md index 6275261..84e1fb4 100644 --- a/docs/RELEASE_PROCESS.md +++ b/docs/RELEASE_PROCESS.md @@ -37,6 +37,7 @@ Use this checklist for every release: - [ ] Confirm install docs/website mention **AppImage** when this release ships AppImages ### Testing & Verification +Install `just` from https://just.systems or the distro package `just`. - [ ] Run `just test` - All tests pass - [ ] Run `just lint` - No linting errors - [ ] Run `just typecheck` - No type errors @@ -258,7 +259,8 @@ Also align `web/package-lock.json` top-level `version` fields with `web/package. ### Step 5: Verify Everything Works -Run the full verification suite: +Run the full verification suite. Install `just` from +https://just.systems or the distro package `just`. ```bash # Run tests diff --git a/install.sh b/install.sh index 086c1de..48b8e5a 100755 --- a/install.sh +++ b/install.sh @@ -216,7 +216,7 @@ check_running_processes() { if [ -n "$FINAL_PIDS" ]; then print_error "Could not terminate all Vocalinux processes: $FINAL_PIDS" print_error "Please manually kill these processes and run the installer again." - exit 1 + exit "$EXIT_USER_ABORT" else print_success "All Vocalinux processes stopped" fi @@ -349,7 +349,7 @@ while [[ $# -gt 0 ]]; do echo " $0 --auto --engine=vosk # Auto-install VOSK only" echo " $0 --dev --test # Dev mode with tests" echo "" - echo "A full transcript of every run is saved to" + echo "During installation a full transcript is saved to" echo " ~/.local/state/vocalinux/install-.log" echo "" echo "Exit codes:" @@ -384,9 +384,11 @@ fi INSTALL_LOG_FILE="$INSTALL_LOG_DIR/install-$(date +%Y%m%d-%H%M%S).log" exec > >(tee -a "$INSTALL_LOG_FILE") 2>&1 -# Scratch directory for downloads and temporary build files. Removed on -# success; kept for inspection (with a notice) when the install fails. +# Scratch directory for pip logs, model downloads, and other temps. +# Removed on success; kept for inspection (with a notice) when the install fails. +# Exporting TMPDIR routes pip/wget/curl scratch files into this directory. VOCALINUX_TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/vocalinux-install.XXXXXXXX")" +export TMPDIR="$VOCALINUX_TMP_DIR" cleanup_on_exit() { local rc=$? @@ -594,7 +596,7 @@ else # Running remotely (e.g., via curl | bash) if [ -z "$INSTALL_TAG" ]; then print_error "No release tag available: the GitHub API was unreachable and no --tag was given." - print_error "Re-run with an explicit release tag, e.g.: --tag=v0.15.0" + print_error "Re-run with an explicit release tag, e.g.: --tag=" exit "$EXIT_NETWORK" fi print_info "Installing Vocalinux version: ${INSTALL_TAG}" @@ -1766,7 +1768,7 @@ install_system_dependencies() { if [ -n "$MISSING_PACKAGES" ]; then print_info "Installing missing packages:$MISSING_PACKAGES" - sudo apt update || { print_error "Failed to update package lists"; exit 1; } + 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 @@ -1776,16 +1778,16 @@ install_system_dependencies() { 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 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 1; } + 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 1; } + 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." @@ -1802,7 +1804,7 @@ install_system_dependencies() { UPDATE_CMD="sudo yum check-update" else print_error "No supported package manager found (dnf/yum)" - exit 1 + exit "$EXIT_MISSING_DEPS" fi # Check for missing packages @@ -1815,19 +1817,19 @@ install_system_dependencies() { 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 1; } + $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 1 + 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 1 + exit "$EXIT_MISSING_DEPS" fi # Check for missing packages @@ -1840,19 +1842,19 @@ install_system_dependencies() { 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 1; } + 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 1 + 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 1 + exit "$EXIT_MISSING_DEPS" fi sudo zypper refresh || true @@ -1872,7 +1874,7 @@ install_system_dependencies() { print_info "Installing missing packages: ${MISSING_ZYPPER_PACKAGES[*]}" sudo zypper install -y "${MISSING_ZYPPER_PACKAGES[@]}" || { print_error "Failed to install openSUSE base dependencies" - exit 1 + exit "$EXIT_MISSING_DEPS" } else print_info "All base openSUSE packages are already installed." @@ -1896,22 +1898,22 @@ install_system_dependencies() { if ! suse_install_first_available "Python pip" "${PY_PIP_CANDIDATES[@]}"; then print_error "Failed to install Python pip package (tried: ${PY_PIP_CANDIDATES[*]})" - exit 1 + 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 1 + 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 1 + 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 1 + exit "$EXIT_MISSING_DEPS" fi if ! suse_install_first_available "Python virtualenv/venv" "${PY_VIRTUALENV_CANDIDATES[@]}" "${PY_VENV_CANDIDATES[@]}"; then @@ -1922,7 +1924,7 @@ install_system_dependencies() { 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 1 + exit "$EXIT_MISSING_DEPS" fi if [[ "${SELECTED_ENGINE:-whisper_cpp}" == "whisper_cpp" && "${WHISPERCPP_BACKEND:-}" != "cpu" ]]; then @@ -1943,7 +1945,7 @@ install_system_dependencies() { # For Gentoo Linux if ! command_exists emerge; then print_error "Emerge package manager not found" - exit 1 + exit "$EXIT_MISSING_DEPS" fi print_info "Gentoo detected. Installing dependencies..." @@ -1961,9 +1963,9 @@ install_system_dependencies() { 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 1; } + 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 1; } + sudo emerge $MISSING_PACKAGES || { print_error "Failed to install dependencies"; exit "$EXIT_MISSING_DEPS"; } else print_info "All required packages are already installed." fi @@ -1973,7 +1975,7 @@ install_system_dependencies() { # For Alpine Linux if ! command_exists apk; then print_error "Apk package manager not found" - exit 1 + exit "$EXIT_MISSING_DEPS" fi print_info "Alpine Linux detected." @@ -1989,8 +1991,8 @@ install_system_dependencies() { if [ -n "$MISSING_PACKAGES" ]; then print_info "Installing packages:$MISSING_PACKAGES" - sudo apk update || { print_error "Failed to update package indexes"; exit 1; } - sudo apk add $MISSING_PACKAGES || { print_error "Failed to install dependencies"; exit 1; } + 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 @@ -2000,7 +2002,7 @@ install_system_dependencies() { # For Void Linux if ! command_exists xbps; then print_error "Xbps package manager not found" - exit 1 + exit "$EXIT_MISSING_DEPS" fi print_info "Void Linux detected." @@ -2015,7 +2017,7 @@ install_system_dependencies() { if [ -n "$MISSING_PACKAGES" ]; then print_info "Installing packages:$MISSING_PACKAGES" - sudo xbps-install -Sy $MISSING_PACKAGES || { print_error "Failed to install dependencies"; exit 1; } + 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 @@ -2025,7 +2027,7 @@ install_system_dependencies() { # For Solus if ! command_exists eopkg; then print_error "Eopkg package manager not found" - exit 1 + exit "$EXIT_MISSING_DEPS" fi print_info "Solus detected." @@ -2040,7 +2042,7 @@ install_system_dependencies() { if [ -n "$MISSING_PACKAGES" ]; then print_info "Installing packages:$MISSING_PACKAGES" - sudo eopkg install $MISSING_PACKAGES || { print_error "Failed to install dependencies"; exit 1; } + 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 @@ -2056,7 +2058,7 @@ install_system_dependencies() { UPDATE_CMD="sudo urpmi.update -a" else print_error "No supported package manager found (dnf/urpmi)" - exit 1 + exit "$EXIT_MISSING_DEPS" fi # Use similar packages to Fedora/RHEL @@ -2070,7 +2072,7 @@ install_system_dependencies() { 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 1; } + $INSTALL_CMD $MISSING_PACKAGES || { print_error "Failed to install dependencies"; exit "$EXIT_MISSING_DEPS"; } else print_info "All required packages are already installed." fi @@ -2135,14 +2137,15 @@ install_text_input_tools() { # Detect session type more robustly local SESSION_TYPE="unknown" - # Check XDG_SESSION_TYPE first - if [ -n "$XDG_SESSION_TYPE" ]; then - SESSION_TYPE="$XDG_SESSION_TYPE" + # 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 + 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 + elif [ -n "${DISPLAY:-}" ] && command_exists xset && xset q &>/dev/null; then SESSION_TYPE="x11" # Check loginctl if available elif command_exists loginctl; then @@ -2442,7 +2445,7 @@ setup_virtual_environment() { if [[ "$NON_INTERACTIVE" == "yes" ]]; then # In non-interactive mode, reuse existing venv print_info "Non-interactive mode: using existing virtual environment." - source "$VENV_DIR/bin/activate" || { print_error "Failed to activate virtual environment"; exit 1; } + source "$VENV_DIR/bin/activate" || { print_error "Failed to activate virtual environment"; exit "$EXIT_MISSING_DEPS"; } return 0 else read -p "Do you want to recreate it? (y/n) " -n 1 -r @@ -2452,7 +2455,7 @@ setup_virtual_environment() { rm -rf "$VENV_DIR" else print_info "Using existing virtual environment." - source "$VENV_DIR/bin/activate" || { print_error "Failed to activate virtual environment"; exit 1; } + source "$VENV_DIR/bin/activate" || { print_error "Failed to activate virtual environment"; exit "$EXIT_MISSING_DEPS"; } return 0 fi fi @@ -2465,16 +2468,16 @@ setup_virtual_environment() { print_warning "python3 -m venv failed, trying python3 -m virtualenv..." python3 -m virtualenv --system-site-packages "$VENV_DIR" || { print_error "Failed to create virtual environment. Please check your Python installation." - exit 1 + exit "$EXIT_MISSING_DEPS" } } # Activate virtual environment - source "$VENV_DIR/bin/activate" || { print_error "Failed to activate virtual environment"; exit 1; } + source "$VENV_DIR/bin/activate" || { print_error "Failed to activate virtual environment"; exit "$EXIT_MISSING_DEPS"; } # Update pip and setuptools print_info "Updating pip, setuptools, and wheel..." - pip install --upgrade pip setuptools wheel || { print_error "Failed to update pip, setuptools, and wheel"; exit 1; } + pip install --upgrade pip setuptools wheel || { print_error "Failed to update pip, setuptools, and wheel"; exit "$EXIT_NETWORK"; } print_info "Virtual environment activated successfully." } @@ -2931,7 +2934,7 @@ install_whispercpp_with_gpu_support() { # vosk is an optional extra; make sure it is importable before # falling back to it if ! "$VENV_DIR/bin/python" -c "import vosk" 2>/dev/null; then - pip install vosk --log "$PIP_LOG_FILE" || \ + pip_install_extras_skip_pygobject "$PIP_LOG_FILE" vosk || \ print_warning "Could not install vosk; install it manually or pick another engine in Settings." fi @@ -2973,10 +2976,88 @@ FALLBACK_VOSK_CONFIG echo "" } +# Distro python3-gi provides `gi`, but apt does not drop pip-visible +# PyGObject dist-info. `pip install .` then tries to build pygobject from +# sdist and dies (needs girepository-2.0). Same skip as uv export's +# --no-emit-package pygobject: install the other deps, then the project +# with --no-deps. Do not use requirements/*.txt hashes here (Phase 2). +write_pip_reqs_skip_pygobject() { + local dest="$1" + shift + "$VENV_DIR/bin/python" - "$dest" "$@" <<'PY' +from pathlib import Path +import re +import sys + +dest = Path(sys.argv[1]) +extras = sys.argv[2:] +text = Path("pyproject.toml").read_text() + + +def quoted_strings(block: str): + return re.findall(r'"([^"]+)"', block) + + +reqs = [] +if not extras: + match = re.search(r"^dependencies = \[(.*?)\]", text, re.M | re.S) + for req in quoted_strings(match.group(1) if match else ""): + pkg = re.split(r"[<>=!~;\[]", req, 1)[0].strip() + if pkg.lower() == "pygobject": + continue + reqs.append(req) +else: + opt = re.search( + r"^\[project\.optional-dependencies\](.*?)(\n\[|\Z)", text, re.M | re.S + ) + opt_text = opt.group(1) if opt else "" + for extra in extras: + match = re.search(rf"^{re.escape(extra)} = \[(.*?)\]", opt_text, re.M | re.S) + if match: + reqs.extend(quoted_strings(match.group(1))) + +dest.write_text("\n".join(reqs) + ("\n" if reqs else "")) +PY +} + +require_distro_gi() { + if ! "$VENV_DIR/bin/python" -c "import gi" 2>/dev/null; then + print_error "Distro PyGObject (python3-gi / python3-gobject) is not importable in the venv." + print_error "Install it with your package manager. Pip cannot build PyGObject here." + exit "$EXIT_MISSING_DEPS" + fi +} + +pip_install_reqs_file() { + local pip_log="$1" + local reqs_file="$2" + if [ ! -s "$reqs_file" ]; then + return 0 + fi + pip install -r "$reqs_file" --log "$pip_log" +} + +pip_install_project_skip_pygobject() { + local pip_log="$1" + shift + require_distro_gi + write_pip_reqs_skip_pygobject "$VOCALINUX_TMP_DIR/runtime-deps.txt" + pip_install_reqs_file "$pip_log" "$VOCALINUX_TMP_DIR/runtime-deps.txt" || return 1 + pip install --no-deps --log "$pip_log" "$@" +} + +pip_install_extras_skip_pygobject() { + local pip_log="$1" + shift + write_pip_reqs_skip_pygobject "$VOCALINUX_TMP_DIR/extra-deps.txt" "$@" + pip_install_reqs_file "$pip_log" "$VOCALINUX_TMP_DIR/extra-deps.txt" +} + # Function to install Python package with error handling and verification install_python_package() { - # Create a temporary directory for pip logs - local PIP_LOG_DIR=$(mktemp -d) + # Pip logs live in the install scratch dir so a failed run can keep them. + local PIP_LOG_DIR="$VOCALINUX_TMP_DIR/pip" + mkdir -p "$PIP_LOG_DIR" local PIP_LOG_FILE="$PIP_LOG_DIR/pip_log.txt" # Detect GI_TYPELIB_PATH early for cross-distro compatibility @@ -3009,10 +3090,8 @@ install_python_package() { print_info "Installing neural VAD support (Silero / ONNX Runtime)..." local VAD_INSTALL_SUCCESS=false - if [[ "$EDITABLE_MODE" == "yes" ]]; then - pip install -e ".[vad]" --log "$PIP_LOG_FILE" && VAD_INSTALL_SUCCESS=true - else - pip install ".[vad]" --log "$PIP_LOG_FILE" && VAD_INSTALL_SUCCESS=true + if pip_install_extras_skip_pygobject "$PIP_LOG_FILE" vad; then + VAD_INSTALL_SUCCESS=true fi if [[ "$VAD_INSTALL_SUCCESS" == "true" ]]; then @@ -3037,7 +3116,7 @@ PY print_info "Installing Vocalinux in development mode..." # Install in development mode with logging - pip install -e . --log "$PIP_LOG_FILE" || { + pip_install_project_skip_pygobject "$PIP_LOG_FILE" -e . || { print_error "Failed to install Vocalinux in development mode." print_error "Check the pip log for details: $PIP_LOG_FILE" return 1 @@ -3051,7 +3130,7 @@ PY # Install all optional dependencies for development print_info "Installing all optional dependencies for development..." - pip install -e ".[whisper,dev]" --log "$PIP_LOG_FILE" || { + pip_install_extras_skip_pygobject "$PIP_LOG_FILE" whisper dev || { print_warning "Failed to install some optional dependencies." print_warning "Some features may not work correctly." } @@ -3065,7 +3144,7 @@ PY print_info "Installing Vocalinux..." # Install the package with logging (includes pywhispercpp by default) - pip install . --log "$PIP_LOG_FILE" || { + pip_install_project_skip_pygobject "$PIP_LOG_FILE" . || { print_error "Failed to install Vocalinux." print_error "Check the pip log for details: $PIP_LOG_FILE" return 1 @@ -3208,7 +3287,7 @@ FALLBACK_CONFIG print_info "VOSK is fast and works well on older systems." # vosk is an optional extra; install it alongside the base package - pip install ".[vosk]" --log "$PIP_LOG_FILE" || { + pip_install_extras_skip_pygobject "$PIP_LOG_FILE" vosk || { print_error "Failed to install the vosk engine" return 1 } @@ -3325,8 +3404,8 @@ REMOTE_CONFIG # Verify installation if verify_package_installed; then print_success "Vocalinux package installed successfully!" - # Clean up log file if installation was successful - rm -rf "$PIP_LOG_DIR" + # Pip logs stay under VOCALINUX_TMP_DIR; the EXIT trap removes that + # directory on success and keeps it when a later step fails. # GI_TYPELIB_PATH was already detected at the start of install_python_package @@ -3441,7 +3520,7 @@ WRAPPER_EOF # Install Python package if ! install_python_package; then print_error "Failed to install Vocalinux package. Installation cannot continue." - exit 1 + exit "$EXIT_NETWORK" fi # Function to download and install Whisper tiny model @@ -3479,7 +3558,7 @@ install_whisper_model() { print_info "Downloading Whisper tiny model..." print_info "This may take a few minutes depending on your internet connection." - local TEMP_FILE="$TINY_MODEL_PATH.tmp" + local TEMP_FILE="$VOCALINUX_TMP_DIR/tiny.pt" # Download the model if command -v wget >/dev/null 2>&1; then @@ -3558,7 +3637,7 @@ install_vosk_models() { print_info "Downloading small VOSK model (approximately 40MB)..." print_info "This may take a few minutes depending on your internet connection." - local TEMP_ZIP="$MODELS_DIR/$(basename $SMALL_MODEL_URL)" + local TEMP_ZIP="$VOCALINUX_TMP_DIR/$(basename $SMALL_MODEL_URL)" # Download the model if command -v wget >/dev/null 2>&1; then @@ -3653,7 +3732,7 @@ install_whispercpp_model() { print_info "Downloading whisper.cpp tiny model..." print_info "This may take a few minutes depending on your internet connection." - local TEMP_FILE="$TINY_MODEL_PATH.tmp" + local TEMP_FILE="$VOCALINUX_TMP_DIR/ggml-tiny.bin" # Download the model if command -v wget >/dev/null 2>&1; then diff --git a/src/vocalinux/ui/settings_dialog.py b/src/vocalinux/ui/settings_dialog.py index dd54022..9ded10d 100644 --- a/src/vocalinux/ui/settings_dialog.py +++ b/src/vocalinux/ui/settings_dialog.py @@ -5005,7 +5005,7 @@ class SettingsDialog(Gtk.Dialog): Installation Options: 1. Using the installation script: - ./install.sh --with-whisper + ./install.sh --engine=whisper 2. Manual installation in virtual environment: source venv/bin/activate diff --git a/tests/test_installer_review_notes.py b/tests/test_installer_review_notes.py new file mode 100644 index 0000000..b17c774 --- /dev/null +++ b/tests/test_installer_review_notes.py @@ -0,0 +1,175 @@ +"""Source checks for leftover installer review notes from #700 and #705.""" + +import os +import subprocess +import sys +from pathlib import Path + +INSTALLER = Path(__file__).resolve().parents[1] / "install.sh" +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") + + +def test_help_places_transcript_under_installation() -> None: + result = subprocess.run( + ["bash", str(INSTALLER), "--help"], + check=False, + capture_output=True, + text=True, + ) + assert result.returncode == 0 + assert "During installation a full transcript is saved to" in result.stdout + assert "every run" not in result.stdout + + +def test_help_does_not_create_install_log_or_scratch_dir(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("XDG_STATE_HOME", str(tmp_path / "state")) + monkeypatch.setenv("TMPDIR", str(tmp_path / "tmp")) + (tmp_path / "tmp").mkdir() + + result = subprocess.run( + ["bash", str(INSTALLER), "--help"], + check=False, + capture_output=True, + text=True, + ) + assert result.returncode == 0 + assert not list((tmp_path / "state").rglob("install-*.log")) + assert not list((tmp_path / "tmp").glob("vocalinux-install.*")) + + +def test_scratch_dir_owns_pip_logs_and_model_temps() -> None: + source = _installer_source() + assert 'PIP_LOG_DIR="$VOCALINUX_TMP_DIR/pip"' in source + assert 'export TMPDIR="$VOCALINUX_TMP_DIR"' in source + assert 'TEMP_FILE="$VOCALINUX_TMP_DIR/tiny.pt"' in source + assert 'TEMP_FILE="$VOCALINUX_TMP_DIR/ggml-tiny.bin"' in source + assert 'TEMP_ZIP="$VOCALINUX_TMP_DIR/$(basename $SMALL_MODEL_URL)"' in source + + +def test_system_dep_and_venv_failures_use_structured_exit_codes() -> None: + source = _installer_source() + assert 'print_error "Failed to install dependencies"; exit "$EXIT_MISSING_DEPS"' in source + assert 'print_error "Failed to update package lists"; exit "$EXIT_NETWORK"' in source + assert ( + 'print_error "Failed to create virtual environment. Please check your Python installation."' + in source + ) + assert 'exit "$EXIT_MISSING_DEPS"' in source + assert ( + 'print_error "Failed to update pip, setuptools, and wheel"; exit "$EXIT_NETWORK"' in source + ) + assert ( + 'print_error "Failed to install Vocalinux package. Installation cannot continue."' in source + ) + assert 'exit "$EXIT_NETWORK"' in source + assert "Could not terminate all Vocalinux processes" in source + assert 'exit "$EXIT_USER_ABORT"' in source + + +def test_release_tag_hint_is_not_hardcoded() -> None: + source = _installer_source() + assert "--tag=" in source + assert "--tag=v0.15.0" not in source + + +def test_whispercpp_fallback_installs_vosk_extra() -> None: + source = _installer_source() + assert 'pip_install_extras_skip_pygobject "$PIP_LOG_FILE" vosk' in source + assert "pip install vosk --log" not in source + assert 'pip install ".[vosk]"' not in source + + +def test_project_pip_install_skips_pygobject() -> None: + source = _installer_source() + assert "write_pip_reqs_skip_pygobject()" in source + assert "pip_install_project_skip_pygobject()" in source + assert "pip install --no-deps" in source + assert "--no-emit-package pygobject" in source + assert 'pip install . --log "$PIP_LOG_FILE"' not in source + assert 'pip install -e . --log "$PIP_LOG_FILE"' not in source + assert 'pip install ".[vad]"' not in source + assert 'pip install -e ".[vad]"' not in source + assert 'pip install -e ".[whisper,dev]"' not in source + + +def _run_reqs_writer(dest: Path, *extras: str) -> None: + source = _installer_source() + start = source.index("from pathlib import Path\nimport re\nimport sys\n") + end = source.index("\nPY\n}", start) + result = subprocess.run( + [sys.executable, "-", str(dest), *extras], + check=False, + cwd=INSTALLER.parent, + input=source[start:end], + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stderr + + +def test_write_pip_reqs_skip_pygobject_runtime(tmp_path) -> None: + dest = tmp_path / "runtime.txt" + _run_reqs_writer(dest) + reqs = dest.read_text().splitlines() + assert reqs + assert all("pygobject" not in req.lower() for req in reqs) + assert any(req.startswith("pyaudio") for req in reqs) + assert any(req.startswith("pywhispercpp") for req in reqs) + + +def test_write_pip_reqs_skip_pygobject_vosk_extra(tmp_path) -> None: + dest = tmp_path / "vosk.txt" + _run_reqs_writer(dest, "vosk") + reqs = dest.read_text().splitlines() + assert reqs == ["vosk>=0.3.45"] + + +def test_settings_uses_engine_flag_not_removed_with_whisper() -> None: + text = SETTINGS.read_text(encoding="utf-8") + assert "./install.sh --engine=whisper" in text + assert "--with-whisper" not in text + + +def test_session_type_env_reads_use_default_expansion() -> None: + source = _installer_source() + start = source.index("install_text_input_tools()") + end = source.index('print_info "Detected session type:', start) + block = source[start:end] + assert '[ -n "${XDG_SESSION_TYPE:-}" ]' in block + assert '[ -n "${WAYLAND_DISPLAY:-}" ]' in block + assert '[ -n "${DISPLAY:-}" ]' in block + assert '"$XDG_SESSION_TYPE"' not in block + assert '"$WAYLAND_DISPLAY"' not in block + assert '"$DISPLAY"' not in block + + env = os.environ.copy() + env.pop("XDG_SESSION_TYPE", None) + env.pop("WAYLAND_DISPLAY", None) + env["DISPLAY"] = ":1" + result = subprocess.run( + [ + "bash", + "-c", + 'set -u; [ -n "${XDG_SESSION_TYPE:-}" ] && echo xdg; ' + '[ -n "${DISPLAY:-}" ] && echo x11', + ], + check=False, + env=env, + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == "x11" + + +def test_agents_does_not_claim_requirements_are_consumed() -> None: + text = AGENTS.read_text(encoding="utf-8") + assert "are unused until those phases land" in text + assert "consumed by `install.sh`" not in text + assert "https://just.systems" in text