From c0217a8aec91d1d78055ddf734f0b9fe1cce1b6c Mon Sep 17 00:00:00 2001 From: Sergey Silaev Date: Wed, 30 Sep 2026 06:16:03 +0200 Subject: [PATCH] refactor(installer): generate distro package map (#872) * refactor(installer): generate distro package map * fix(installer): handle Debian derivative package maps * docs(installer): document generated package map * fix(installer): validate package map and refresh derivative probes * fix(installer): name openSUSE package roles --- AGENTS.md | 5 +- CONTRIBUTING.md | 29 +- docs/DISTRO_COMPATIBILITY.md | 33 +- docs/INSTALL.md | 2 +- docs/INSTALL_MANUAL.md | 32 +- install.d/package_map.sh | 129 ++++++ install.d/system_dependencies.sh | 317 ++++++++------- install.sh | 2 +- justfile | 9 + pyproject.toml | 1 + requirements/dev.txt | 4 +- scripts/distro-package-map.yaml | 420 +++++++++----------- scripts/generate_distro_package_map.py | 246 ++++++++++++ tests/test_cross_distro_compatibility.py | 20 +- tests/test_distro_package_map.py | 204 ++++++++++ tests/test_installer_cuda_diagnostics.py | 50 +-- tests/test_installer_modules.py | 2 + tests/test_installer_pinned_dependencies.py | 15 +- uv.lock | 2 + 19 files changed, 1057 insertions(+), 465 deletions(-) create mode 100644 install.d/package_map.sh create mode 100644 scripts/generate_distro_package_map.py create mode 100644 tests/test_distro_package_map.py diff --git a/AGENTS.md b/AGENTS.md index dc9cd39..f86452b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -82,6 +82,8 @@ just deps # sync .venv with dev+vad extras and the lint group just deps-all # also whisper/vosk/docs; later recipes use --no-sync so they keep it just lock # regenerate uv.lock, requirements/*.txt and the Flatpak dep manifest just lock-check # fail if uv.lock is stale vs pyproject.toml +just distro-packages # regenerate install.d/package_map.sh from YAML +just distro-packages-check # fail if the generated package map is stale just flatpak-deps # regenerate the Flatpak dep manifest from requirements/runtime.txt (needs PyPI) just flatpak-deps-check # fail if that manifest is behind the export just model-checksums # refresh pinned model digests after adding a model @@ -164,7 +166,8 @@ src/vocalinux/ Also: `tests/`, `docs/`, `packaging/` (AppImage, AUR, Flatpak), `scripts/`, `install.sh` (bootstrap and orchestration), `install.d/` (sourced installer modules), -`uninstall.sh`, `web/`. +`uninstall.sh`, `web/`. `scripts/distro-package-map.yaml` owns installer package +names; `install.d/package_map.sh` is generated by `just distro-packages`. | Task | Start here | |---|---| diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2a1872a..24965f4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -37,21 +37,24 @@ This installs system dependencies, creates a venv from the system Python, instal ```bash sudo apt update sudo apt install -y python3-pip python3-gi python3-gi-cairo \ - gir1.2-gtk-3.0 libgirepository-2.0-dev libgirepository1.0-dev \ + gir1.2-gtk-3.0 gir1.2-ibus-1.0 libgirepository-2.0-dev libgirepository1.0-dev \ + build-essential pkg-config libcairo2-dev \ python3-dev portaudio19-dev python3-venv xdotool ``` **Debian 12** cannot pip-build PyGObject 3.56 (glib 2.74). Use `./install.sh --dev` for tests and running from source, then `venv/bin/pytest` / `venv/bin/python -m vocalinux.main --debug`. ```bash sudo apt install -y python3-pip python3-gi python3-gi-cairo \ - gir1.2-gtk-3.0 libgirepository1.0-dev libcairo2-dev \ + gir1.2-gtk-3.0 gir1.2-ibus-1.0 libgirepository1.0-dev libcairo2-dev \ + build-essential pkg-config \ python3-dev portaudio19-dev python3-venv xdotool ``` **Debian 13+:** ```bash sudo apt install -y python3-pip python3-gi python3-gi-cairo \ - gir1.2-gtk-3.0 libgirepository-2.0-dev libcairo2-dev \ + gir1.2-gtk-3.0 gir1.2-ibus-1.0 libgirepository-2.0-dev libcairo2-dev \ + build-essential pkg-config \ python3-dev portaudio19-dev python3-venv xdotool ``` @@ -138,7 +141,9 @@ vocalinux/ ├── tests/ ├── resources/ # Icons and sounds ├── docs/ +├── install.d/ # Installer modules, including generated package data ├── packaging/ # AppImage, AUR, Flatpak +├── scripts/ # Maintenance tools and distro package-map source ├── snap/ # Snap recipe └── web/ # Marketing site (Next.js) ``` @@ -150,6 +155,24 @@ vocalinux/ | Recognition engines | `src/vocalinux/speech_recognition/recognition_manager.py` | | Text injection | `src/vocalinux/text_injection/text_injector.py` | | Settings / config | `src/vocalinux/ui/config_manager.py`, `settings_dialog.py` | +| Installer package names | `scripts/distro-package-map.yaml` | + +### Installer package map + +`scripts/distro-package-map.yaml` is the source of truth for package names, +alternatives, and conditional package probes used by `install.sh`. Do not edit +the generated `install.d/package_map.sh` directly. After changing the YAML, run: + +```bash +just distro-packages +just distro-packages-check +``` + +Commit both the YAML and generated shell module. Package-manager control flow +and installation policy remain handwritten in +`install.d/system_dependencies.sh`; user systems never parse the YAML. The +remote bootstrap's pre-checkout `git` installation is the deliberate exception +because the generated module is not available yet. ## Testing diff --git a/docs/DISTRO_COMPATIBILITY.md b/docs/DISTRO_COMPATIBILITY.md index df4cd3c..1d25ce6 100644 --- a/docs/DISTRO_COMPATIBILITY.md +++ b/docs/DISTRO_COMPATIBILITY.md @@ -36,9 +36,10 @@ The `.desktop` entry is automatically configured with the detected `GI_TYPELIB_P ## Officially Supported Distributions These distributions are tested and known to work well with Vocalinux. A -derivative is judged by the Python it ships rather than by its own version -number, which rarely tracks the base release, and `install.sh` checks the -interpreter rather than the release label. +derivative is judged by the Python and package repositories it ships rather +than by its own version number, which rarely tracks the base release. In +particular, `install.sh` does not interpret a Debian-based derivative's product +version as a Debian release number. | Distribution | Version | Status | Notes | |--------------|---------|--------|-------| @@ -72,13 +73,20 @@ These distributions are known to be incompatible: | Distribution | Status | Reason | |--------------|--------|--------| +| Debian 11 and earlier | ❌ Not Supported | EOL and below the Python 3.11 floor; the installer requires Debian 12+ | | NixOS | ❌ Not Supported | Completely different filesystem layout (/nix/store), incompatible with standard installer | ## Requirements by Distribution +These are baseline commands for manual installations. The exact inventory used +by `install.sh`, including package alternatives and conditional dependencies, +lives in [`scripts/distro-package-map.yaml`](../scripts/distro-package-map.yaml). +For the complete manual setup sequence, including native build prerequisites, +use [INSTALL_MANUAL.md](INSTALL_MANUAL.md#system-packages-by-distribution). + ### Ubuntu/Debian-based ```bash -sudo apt install -y python3-gi gir1.2-gtk-3.0 gir1.2-gdkpixbuf-2.0 \ +sudo apt install -y python3-gi gir1.2-gtk-3.0 gir1.2-ibus-1.0 gir1.2-gdkpixbuf-2.0 \ portaudio19-dev python3-dev python3-venv pkg-config ``` @@ -134,13 +142,13 @@ the VOSK engine (`--engine=vosk`) as a fully-packaged alternative. ### Fedora/RHEL-based ```bash -sudo dnf install -y python3-gobject gtk3-devel gobject-introspection-devel \ +sudo dnf install -y python3-gobject gtk3-devel ibus-devel cairo-devel gobject-introspection-devel \ portaudio-devel python3-devel python3-virtualenv pkg-config ``` ### Arch Linux-based ```bash -sudo pacman -S --needed python-gobject gtk3 gobject-introspection \ +sudo pacman -S --needed python-gobject gtk3 ibus gobject-introspection \ portaudio python pkg-config ``` @@ -151,9 +159,11 @@ PYVER=$(python3 -c 'import sys; print(f"python{sys.version_info.major}{sys.versi sudo zypper install -y \ "${PYVER}-pip" "${PYVER}-gobject" "${PYVER}-gobject-cairo" \ "${PYVER}-devel" "${PYVER}-virtualenv" \ - gtk3 typelib-1_0-AyatanaAppIndicator3-0_1 libayatana-appindicator3-1 \ + gtk3 ibus typelib-1_0-IBus-1_0 \ + typelib-1_0-AyatanaAppIndicator3-0_1 libayatana-appindicator3-1 \ typelib-1_0-Notify-0_7 libnotify4 \ gobject-introspection-devel portaudio-devel pkg-config cmake wget curl unzip \ + gcc gcc-c++ make patchelf \ xdotool wtype # Optional: only needed for whisper.cpp Vulkan GPU builds @@ -224,7 +234,7 @@ Before running `pip install vocalinux`, `pip install "vocalinux[vosk]"`, or #### Ubuntu/Debian ```bash -sudo apt install python3-gi gir1.2-gtk-3.0 gir1.2-ayatanaappindicator3-0.1 \ +sudo apt install python3-gi gir1.2-gtk-3.0 gir1.2-ibus-1.0 gir1.2-ayatanaappindicator3-0.1 \ libgirepository1.0-dev libcairo2-dev portaudio19-dev python3-dev \ python3-venv pkg-config xdotool wtype wl-clipboard xclip xsel ``` @@ -234,14 +244,14 @@ On Ubuntu 24.04+ or Pop!_OS, install `libgirepository-2.0-dev` if #### Fedora ```bash -sudo dnf install python3-gobject gtk3 gtk3-devel libayatana-appindicator-gtk3 \ +sudo dnf install python3-gobject gtk3 gtk3-devel ibus-devel cairo-devel libayatana-appindicator-gtk3 \ gobject-introspection-devel portaudio-devel python3-devel \ python3-virtualenv pkg-config xdotool wtype wl-clipboard xclip xsel ``` #### Arch Linux ```bash -sudo pacman -S python-gobject gtk3 libayatana-appindicator gobject-introspection \ +sudo pacman -S python-gobject gtk3 ibus libayatana-appindicator gobject-introspection \ python-cairo portaudio python-virtualenv pkg-config xdotool wtype wl-clipboard xclip xsel ``` @@ -249,7 +259,8 @@ sudo pacman -S python-gobject gtk3 libayatana-appindicator gobject-introspection ```bash PYVER=$(python3 -c 'import sys; print(f"python{sys.version_info.major}{sys.version_info.minor}")') -sudo zypper install "${PYVER}-gobject" "${PYVER}-gobject-cairo" gtk3 \ +sudo zypper install "${PYVER}-gobject" "${PYVER}-gobject-cairo" gtk3 ibus typelib-1_0-IBus-1_0 \ + gcc gcc-c++ make patchelf \ typelib-1_0-AyatanaAppIndicator3-0_1 libayatana-appindicator3-1 \ typelib-1_0-Notify-0_7 libnotify4 \ portaudio-devel "${PYVER}-devel" "${PYVER}-virtualenv" pkg-config xdotool wtype wl-clipboard xclip xsel diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 7d8537f..885ec5e 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -87,7 +87,7 @@ chmod +x Vocalinux-*-x86_64.AppImage # or aarch64 ./Vocalinux-*-x86_64.AppImage ``` -Built against glibc 2.35, so it starts on Debian 12+, Ubuntu 22.04+, Fedora 36+, Arch, and Tumbleweed. Older bases (RHEL 9, Debian 11, Ubuntu 20.04) are below that floor; use the installer or PyPI there. +Built against glibc 2.35, so it starts on Debian 12+, Ubuntu 22.04+, Fedora 36+, Arch, and Tumbleweed. Older bases (RHEL 9, Debian 11, Ubuntu 20.04) are below that AppImage floor. The installer and PyPI package still require a distro that ships Python 3.11+, so they are not a workaround for Debian 11 or Ubuntu 20.04. Still needs host text-injection tools (`xdotool` on X11; `wtype` / `ydotool` / clipboard tools on Wayland). Current AppImages rebuild whisper.cpp with Vulkan and use the host GPU driver (`vulkaninfo --summary`). Prefer the installer when you want system deps, a CUDA build, and models set up automatically. diff --git a/docs/INSTALL_MANUAL.md b/docs/INSTALL_MANUAL.md index 23e3b27..1c09688 100644 --- a/docs/INSTALL_MANUAL.md +++ b/docs/INSTALL_MANUAL.md @@ -2,12 +2,21 @@ Use this when the [recommended installer](INSTALL.md) is not a fit, or you want an explicit package list. Prefer `./install.sh` for a normal desktop install. +The commands below are human-readable baselines for manual installs. The +installer's authoritative package inventory, including build helpers and +fallbacks, is [`scripts/distro-package-map.yaml`](../scripts/distro-package-map.yaml). +Its generated shell module is committed so installations do not need a YAML +parser. + Related: [DISTRO_COMPATIBILITY.md](DISTRO_COMPATIBILITY.md), [TROUBLESHOOTING.md](TROUBLESHOOTING.md). ## System packages by distribution Install desktop/system dependencies **before** creating a venv or running `pip install vocalinux`. Pip cannot install GTK typelibs, AppIndicator, PortAudio, or text-injection tools. +Debian 11 and earlier are unsupported; the Debian instructions start at Debian +12, which is the oldest release with the required Python 3.11. + ### Ubuntu ```bash @@ -15,8 +24,9 @@ sudo apt update sudo apt install -y \ python3-pip python3-venv python3-dev \ python3-gi python3-gi-cairo \ - gir1.2-gtk-3.0 \ - libgirepository1.0-dev portaudio19-dev \ + gir1.2-gtk-3.0 gir1.2-ibus-1.0 \ + libgirepository1.0-dev libcairo2-dev portaudio19-dev \ + build-essential pkg-config cmake libssl-dev autoconf automake libtool patchelf \ wget curl unzip # Tray: older Ubuntu → gir1.2-appindicator3-0.1 @@ -36,8 +46,9 @@ sudo apt update sudo apt install -y \ python3-pip python3-venv python3-dev \ python3-gi python3-gi-cairo \ - gir1.2-gtk-3.0 \ + gir1.2-gtk-3.0 gir1.2-ibus-1.0 \ libgirepository1.0-dev libcairo2-dev portaudio19-dev \ + build-essential pkg-config cmake libssl-dev autoconf automake libtool patchelf \ wget curl unzip \ gir1.2-ayatanaappindicator3-0.1 \ xdotool wtype wl-clipboard xclip xsel @@ -50,8 +61,9 @@ sudo apt update sudo apt install -y \ python3-pip python3-venv python3-dev \ python3-gi python3-gi-cairo \ - gir1.2-gtk-3.0 \ + gir1.2-gtk-3.0 gir1.2-ibus-1.0 \ libgirepository-2.0-dev libcairo2-dev portaudio19-dev \ + build-essential pkg-config cmake libssl-dev autoconf automake libtool patchelf \ wget curl unzip \ gir1.2-ayatanaappindicator3-0.1 \ xdotool wtype wl-clipboard xclip xsel @@ -64,8 +76,9 @@ Debian notes (OpenSSL build deps, ydotool, etc.): [DISTRO_COMPATIBILITY.md](DIST ```bash sudo dnf install -y \ python3-pip python3-devel python3-virtualenv \ - python3-gobject gtk3 libayatana-appindicator-gtk3 \ - gobject-introspection-devel portaudio-devel \ + python3-gobject gtk3 ibus-devel libayatana-appindicator-gtk3 \ + gobject-introspection-devel portaudio-devel cairo-devel \ + gcc gcc-c++ make pkg-config cmake patchelf \ wget curl unzip xdotool wtype wl-clipboard xclip xsel ``` @@ -75,9 +88,10 @@ Prefer the AUR package (`yay -S vocalinux`). Manual packages: ```bash sudo pacman -S --needed \ - python-pip python-gobject gtk3 \ + python-pip python-gobject gtk3 ibus \ libayatana-appindicator gobject-introspection \ python-cairo portaudio python-virtualenv \ + base-devel pkg-config cmake patchelf \ wget curl unzip xdotool wtype wl-clipboard xclip xsel ``` @@ -89,9 +103,11 @@ PYVER=$(python3 -c 'import sys; print(f"python{sys.version_info.major}{sys.versi sudo zypper install -y \ "${PYVER}-pip" "${PYVER}-gobject" "${PYVER}-gobject-cairo" \ "${PYVER}-devel" "${PYVER}-virtualenv" \ - gtk3 typelib-1_0-AyatanaAppIndicator3-0_1 libayatana-appindicator3-1 \ + gtk3 ibus typelib-1_0-IBus-1_0 \ + typelib-1_0-AyatanaAppIndicator3-0_1 libayatana-appindicator3-1 \ typelib-1_0-Notify-0_7 libnotify4 \ gobject-introspection-devel portaudio-devel pkg-config cmake \ + gcc gcc-c++ make patchelf \ wget curl unzip xdotool wtype wl-clipboard xclip xsel # Optional: whisper.cpp Vulkan builds diff --git a/install.d/package_map.sh b/install.d/package_map.sh new file mode 100644 index 0000000..ebd1edc --- /dev/null +++ b/install.d/package_map.sh @@ -0,0 +1,129 @@ +#!/bin/bash +# Generated by scripts/generate_distro_package_map.py -- do not edit. +# Regenerate with `just distro-packages` after editing +# scripts/distro-package-map.yaml. + +DEBIAN_13_PLUS_PROBE_PACKAGE=libgirepository-2.0-dev + +load_distro_package_map() { + local package_map_key="$1" + + SYSTEM_PACKAGES=() + APPINDICATOR_PACKAGES=() + GI_DEVELOPMENT_PACKAGES=() + OPTIONAL_SYSTEM_PACKAGES=() + PYTHON_PIP_SUFFIX="" + PYTHON_GOBJECT_SUFFIX="" + PYTHON_GOBJECT_CAIRO_SUFFIX="" + PYTHON_DEVEL_SUFFIX="" + PYTHON_VIRTUALENV_SUFFIX="" + PYTHON_VENV_SUFFIX="" + SHADER_COMPILER_PACKAGES=() + VULKAN_PACKAGES=() + XDOTOOL_PACKAGES=() + WTYPE_PACKAGES=() + YDOTOOL_PACKAGES=() + + case "$package_map_key" in + ubuntu) + SYSTEM_PACKAGES=(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 xclip xsel wl-clipboard libssl-dev autoconf automake libtool patchelf) + APPINDICATOR_PACKAGES=(gir1.2-appindicator3-0.1 gir1.2-ayatanaappindicator3-0.1) + GI_DEVELOPMENT_PACKAGES=(libgirepository-2.0-dev libgirepository1.0-dev) + OPTIONAL_SYSTEM_PACKAGES=(util-linux-extra) + SHADER_COMPILER_PACKAGES=(glslc glslang-tools) + XDOTOOL_PACKAGES=(xdotool) + WTYPE_PACKAGES=(wtype) + YDOTOOL_PACKAGES=(ydotool) + ;; + + debian_12) + SYSTEM_PACKAGES=(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 xclip xsel wl-clipboard libssl-dev autoconf automake libtool patchelf libgirepository1.0-dev gir1.2-ayatanaappindicator3-0.1) + OPTIONAL_SYSTEM_PACKAGES=(util-linux-extra) + SHADER_COMPILER_PACKAGES=(glslc glslang-tools) + XDOTOOL_PACKAGES=(xdotool) + WTYPE_PACKAGES=(wtype) + YDOTOOL_PACKAGES=(ydotool) + ;; + + debian_13_plus) + SYSTEM_PACKAGES=(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 xclip xsel wl-clipboard libssl-dev autoconf automake libtool patchelf libgirepository-2.0-dev gir1.2-ayatanaappindicator3-0.1) + OPTIONAL_SYSTEM_PACKAGES=(util-linux-extra) + SHADER_COMPILER_PACKAGES=(glslc glslang-tools) + XDOTOOL_PACKAGES=(xdotool) + WTYPE_PACKAGES=(wtype) + YDOTOOL_PACKAGES=(ydotool) + ;; + + fedora) + SYSTEM_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) + APPINDICATOR_PACKAGES=(libayatana-appindicator-gtk3 libappindicator-gtk3) + XDOTOOL_PACKAGES=(xdotool) + WTYPE_PACKAGES=(wtype) + YDOTOOL_PACKAGES=(ydotool) + ;; + + arch) + SYSTEM_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) + APPINDICATOR_PACKAGES=(libayatana-appindicator libappindicator-gtk3) + XDOTOOL_PACKAGES=(xdotool) + WTYPE_PACKAGES=(wtype) + YDOTOOL_PACKAGES=(ydotool) + ;; + + suse) + SYSTEM_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) + 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) + SHADER_COMPILER_PACKAGES=(shaderc glslang-devel glslang) + VULKAN_PACKAGES=(vulkan-tools vulkan-devel) + PYTHON_DEVEL_SUFFIX=devel + PYTHON_GOBJECT_SUFFIX=gobject + PYTHON_GOBJECT_CAIRO_SUFFIX=gobject-cairo + PYTHON_PIP_SUFFIX=pip + PYTHON_VENV_SUFFIX=venv + PYTHON_VIRTUALENV_SUFFIX=virtualenv + XDOTOOL_PACKAGES=(xdotool) + WTYPE_PACKAGES=(wtype) + YDOTOOL_PACKAGES=(ydotool) + ;; + + gentoo) + SYSTEM_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) + XDOTOOL_PACKAGES=(x11-misc/xdotool) + WTYPE_PACKAGES=(x11-misc/wtype) + YDOTOOL_PACKAGES=(app-misc/ydotool) + ;; + + alpine) + SYSTEM_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) + XDOTOOL_PACKAGES=(xdotool) + WTYPE_PACKAGES=(wtype) + YDOTOOL_PACKAGES=(ydotool) + ;; + + void) + SYSTEM_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) + XDOTOOL_PACKAGES=(xdotool) + WTYPE_PACKAGES=(wtype) + YDOTOOL_PACKAGES=(ydotool) + ;; + + solus) + SYSTEM_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) + XDOTOOL_PACKAGES=(xdotool) + WTYPE_PACKAGES=(wtype) + YDOTOOL_PACKAGES=(ydotool) + ;; + + mageia) + SYSTEM_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) + XDOTOOL_PACKAGES=(xdotool) + WTYPE_PACKAGES=(wtype) + YDOTOOL_PACKAGES=(ydotool) + ;; + + *) + echo "Unknown distro package map key: $package_map_key" >&2 + return 1 + ;; + esac +} diff --git a/install.d/system_dependencies.sh b/install.d/system_dependencies.sh index a08ee24..86ac379 100644 --- a/install.d/system_dependencies.sh +++ b/install.d/system_dependencies.sh @@ -121,15 +121,6 @@ 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 @@ -161,63 +152,89 @@ suse_shader_compiler_available() { command_exists glslc || command_exists glslangValidator } +resolve_debian_package_map_key() { + local DEBIAN_MAJOR="${DISTRO_VERSION%%.*}" + + # Only Debian's own VERSION_ID identifies a Debian release. Derivatives + # often use an unrelated product version, so select their compatible map + # by probing the package inventory instead. + if [[ "$DISTRO_ID" == "debian" && "$DEBIAN_MAJOR" =~ ^[0-9]+$ ]]; then + if [ "$DEBIAN_MAJOR" -lt 12 ]; then + print_error "Debian 12 or newer is required (detected Debian $DEBIAN_MAJOR)." >&2 + return "$EXIT_MISSING_DEPS" + elif [ "$DEBIAN_MAJOR" -ge 13 ]; then + echo "debian_13_plus" + else + echo "debian_12" + fi + else + # A missing cache is not evidence of an older Debian base. Refresh + # before probing; keep command substitution's stdout for the key only. + sudo apt update >&2 || { + print_error "Failed to refresh package indexes for Debian base detection." >&2 + return "$EXIT_NETWORK" + } + if apt-cache show "$DEBIAN_13_PLUS_PROBE_PACKAGE" &>/dev/null 2>&1; then + echo "debian_13_plus" + else + echo "debian_12" + fi + fi +} + # 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" + local PACKAGE_MAP_KEY="$DISTRO_FAMILY" + if [[ "$DISTRO_FAMILY" == "debian" ]]; then + PACKAGE_MAP_KEY=$(resolve_debian_package_map_key) || exit "$?" fi + case "$PACKAGE_MAP_KEY" in + ubuntu|debian_12|debian_13_plus|fedora|arch|suse|gentoo|alpine|void|solus|mageia) + load_distro_package_map "$PACKAGE_MAP_KEY" || exit "$EXIT_MISSING_DEPS" + ;; + esac - # 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" + if [[ "$DISTRO_FAMILY" == "ubuntu" ]]; then + local GI_DEV_PACKAGE + local GI_DEV_FOUND="no" + for GI_DEV_PACKAGE in "${GI_DEVELOPMENT_PACKAGES[@]}"; do + if apt-cache show "$GI_DEV_PACKAGE" &>/dev/null 2>&1; then + SYSTEM_PACKAGES+=("$GI_DEV_PACKAGE") + GI_DEV_FOUND="yes" + fi + done + if [[ "$GI_DEV_FOUND" == "no" ]]; then + SYSTEM_PACKAGES+=("${GI_DEVELOPMENT_PACKAGES[1]}") fi + SYSTEM_PACKAGES+=("${APPINDICATOR_PACKAGES[0]}") 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" + if [[ "$DISTRO_FAMILY" == "ubuntu" || "$DISTRO_FAMILY" == "debian" ]]; then + local SHADER_PACKAGE="${SHADER_COMPILER_PACKAGES[1]}" + local SHADER_CANDIDATE + for SHADER_CANDIDATE in "${SHADER_COMPILER_PACKAGES[@]}"; do + if apt-cache show "$SHADER_CANDIDATE" &>/dev/null 2>&1; then + SHADER_PACKAGE="$SHADER_CANDIDATE" + break + fi + done + SYSTEM_PACKAGES+=("$SHADER_PACKAGE") + + local OPTIONAL_PACKAGE + for OPTIONAL_PACKAGE in "${OPTIONAL_SYSTEM_PACKAGES[@]}"; do + if apt-cache show "$OPTIONAL_PACKAGE" &>/dev/null 2>&1; then + SYSTEM_PACKAGES+=("$OPTIONAL_PACKAGE") + fi + done + fi local MISSING_PACKAGES="" local INSTALL_CMD="" @@ -225,26 +242,8 @@ install_system_dependencies() { 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 + for pkg in "${SYSTEM_PACKAGES[@]}"; do if ! apt_package_installed "$pkg"; then MISSING_PACKAGES="$MISSING_PACKAGES $pkg" fi @@ -255,16 +254,16 @@ install_system_dependencies() { 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 [[ "$DISTRO_FAMILY" == "ubuntu" ]] && echo "$MISSING_PACKAGES" | grep -q "${APPINDICATOR_PACKAGES[0]}"; then + FILTERED_PACKAGES=$(echo "$MISSING_PACKAGES" | sed "s/${APPINDICATOR_PACKAGES[0]}//" | 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)" + if ! DEBIAN_FRONTEND=noninteractive sudo apt install -y "${APPINDICATOR_PACKAGES[0]}" 2>/dev/null; then + print_info "${APPINDICATOR_PACKAGES[0]} not available, trying ${APPINDICATOR_PACKAGES[1]}..." + if ! DEBIAN_FRONTEND=noninteractive sudo apt install -y "${APPINDICATOR_PACKAGES[1]}"; then + print_error "Failed to install appindicator package (tried both ${APPINDICATOR_PACKAGES[*]})" exit "$EXIT_MISSING_DEPS" fi - print_info "Successfully installed gir1.2-ayatanaappindicator3-0.1 (modern replacement)" + print_info "Successfully installed ${APPINDICATOR_PACKAGES[1]} (modern replacement)" fi if [ -n "$FILTERED_PACKAGES" ]; then @@ -292,7 +291,7 @@ install_system_dependencies() { fi # Check for missing packages - for pkg in $DNF_PACKAGES; do + for pkg in "${SYSTEM_PACKAGES[@]}"; do if ! dnf_package_installed "$pkg"; then MISSING_PACKAGES="$MISSING_PACKAGES $pkg" fi @@ -306,7 +305,7 @@ install_system_dependencies() { 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" + install_preferred_appindicator "$INSTALL_CMD" "${APPINDICATOR_PACKAGES[0]}" "${APPINDICATOR_PACKAGES[1]}" dnf_package_installed || exit "$EXIT_MISSING_DEPS" ;; arch) @@ -317,7 +316,7 @@ install_system_dependencies() { fi # Check for missing packages - for pkg in $PACMAN_PACKAGES; do + for pkg in "${SYSTEM_PACKAGES[@]}"; do if ! pacman_package_installed "$pkg"; then MISSING_PACKAGES="$MISSING_PACKAGES $pkg" fi @@ -331,7 +330,7 @@ install_system_dependencies() { 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" + install_preferred_appindicator "sudo pacman -S --noconfirm" "${APPINDICATOR_PACKAGES[0]}" "${APPINDICATOR_PACKAGES[1]}" pacman_package_installed || exit "$EXIT_MISSING_DEPS" ;; suse) @@ -344,11 +343,11 @@ install_system_dependencies() { sudo zypper refresh || true if [[ "${SELECTED_ENGINE:-whisper_cpp}" == "whisper_cpp" && "${WHISPERCPP_BACKEND:-}" != "cpu" ]]; then - ZYPPER_PACKAGES="$ZYPPER_PACKAGES vulkan-tools vulkan-devel" + SYSTEM_PACKAGES+=("${VULKAN_PACKAGES[@]}") fi local MISSING_ZYPPER_PACKAGES=() - for pkg in $ZYPPER_PACKAGES; do + for pkg in "${SYSTEM_PACKAGES[@]}"; do if ! suse_package_installed "$pkg"; then MISSING_ZYPPER_PACKAGES+=("$pkg") fi @@ -371,12 +370,12 @@ install_system_dependencies() { 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")" + read -r -a PY_PIP_CANDIDATES <<< "$(suse_python_package_candidates "$PYTHON_PIP_SUFFIX")" + read -r -a PY_GOBJECT_CANDIDATES <<< "$(suse_python_package_candidates "$PYTHON_GOBJECT_SUFFIX")" + read -r -a PY_GOBJECT_CAIRO_CANDIDATES <<< "$(suse_python_package_candidates "$PYTHON_GOBJECT_CAIRO_SUFFIX")" + read -r -a PY_DEVEL_CANDIDATES <<< "$(suse_python_package_candidates "$PYTHON_DEVEL_SUFFIX")" + read -r -a PY_VIRTUALENV_CANDIDATES <<< "$(suse_python_package_candidates "$PYTHON_VIRTUALENV_SUFFIX")" + read -r -a PY_VENV_CANDIDATES <<< "$(suse_python_package_candidates "$PYTHON_VENV_SUFFIX")" print_info "Resolving openSUSE Python packages for $(suse_python_package_prefix)..." @@ -407,13 +406,13 @@ 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" + print_error "Try manually: sudo zypper install ${APPINDICATOR_PACKAGES[0]} ${APPINDICATOR_PACKAGES[3]}" 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 + if ! suse_install_first_available "Vulkan shader compiler" "${SHADER_COMPILER_PACKAGES[@]}"; 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 @@ -437,7 +436,7 @@ install_system_dependencies() { # Check for missing packages MISSING_PACKAGES="" - for pkg in $EMERGE_PACKAGES; do + for pkg in "${SYSTEM_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" @@ -467,7 +466,7 @@ install_system_dependencies() { # Check for missing packages MISSING_PACKAGES="" - for pkg in $APK_PACKAGES; do + for pkg in "${SYSTEM_PACKAGES[@]}"; do if ! apk info -e "$pkg" >/dev/null 2>&1; then MISSING_PACKAGES="$MISSING_PACKAGES $pkg" fi @@ -493,7 +492,7 @@ install_system_dependencies() { # Check for missing packages MISSING_PACKAGES="" - for pkg in $XBPS_PACKAGES; do + for pkg in "${SYSTEM_PACKAGES[@]}"; do if ! xbps-query "$pkg" >/dev/null 2>&1; then MISSING_PACKAGES="$MISSING_PACKAGES $pkg" fi @@ -518,7 +517,7 @@ install_system_dependencies() { # Check for missing packages MISSING_PACKAGES="" - for pkg in $EOPKG_PACKAGES; do + for pkg in "${SYSTEM_PACKAGES[@]}"; do if ! eopkg list-installed | grep -qw "$pkg"; then MISSING_PACKAGES="$MISSING_PACKAGES $pkg" fi @@ -546,7 +545,7 @@ install_system_dependencies() { fi # Use similar packages to Fedora/RHEL - for pkg in $DNF_PACKAGES; do + for pkg in "${SYSTEM_PACKAGES[@]}"; do # Mageia uses rpm like Fedora if ! rpm -q "$pkg" >/dev/null 2>&1; then MISSING_PACKAGES="$MISSING_PACKAGES $pkg" @@ -604,6 +603,18 @@ install_text_input_tools() { return 0 fi + # Unsupported distros can continue past install_system_dependencies in + # non-interactive mode, but they have no generated package inventory. + if [[ -z "${XDOTOOL_PACKAGES+x}" ]]; then + print_warning "No text input package map is available for $DISTRO_NAME." + print_warning "Install xdotool or wtype manually for your display server." + return 0 + fi + + local XDOTOOL_PKG="${XDOTOOL_PACKAGES[0]}" + local WTYPE_PKG="${WTYPE_PACKAGES[0]}" + local YDOTOOL_PKG="${YDOTOOL_PACKAGES[0]}" + # Detect session type more robustly local SESSION_TYPE="unknown" @@ -633,64 +644,64 @@ install_text_input_tools() { 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."; } + if ! apt_package_installed "$WTYPE_PKG"; then + DEBIAN_FRONTEND=noninteractive sudo apt install -y "$WTYPE_PKG" || { 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."; } + if command_exists dnf && ! dnf_package_installed "$WTYPE_PKG"; then + sudo dnf install -y "$WTYPE_PKG" || { print_warning "Failed to install wtype. Text injection may not work properly."; } + elif command_exists yum && ! rpm -q "$WTYPE_PKG" &>/dev/null; then + sudo yum install -y "$WTYPE_PKG" || { 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."; } + if ! pacman_package_installed "$WTYPE_PKG"; then + sudo pacman -S --noconfirm "$WTYPE_PKG" || { 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."; } + sudo zypper install -y "$WTYPE_PKG" || { 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."; } + if ! qlist -I "$WTYPE_PKG" >/dev/null 2>&1; then + sudo emerge "$WTYPE_PKG" || { 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."; } + if ! apk info -e "$WTYPE_PKG" >/dev/null 2>&1; then + sudo apk add "$WTYPE_PKG" || { 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."; } + if ! xbps-query "$WTYPE_PKG" >/dev/null 2>&1; then + sudo xbps-install -Sy "$WTYPE_PKG" || { 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."; } + if ! eopkg list-installed | grep -qw "$WTYPE_PKG"; then + sudo eopkg install "$WTYPE_PKG" || { 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."; } + if command_exists dnf && ! rpm -q "$WTYPE_PKG" >/dev/null 2>&1; then + sudo dnf install -y "$WTYPE_PKG" || { print_warning "Failed to install wtype. Text injection may not work properly."; } + elif command_exists urpmi && ! rpm -q "$WTYPE_PKG" >/dev/null 2>&1; then + sudo urpmi -y "$WTYPE_PKG" || { print_warning "Failed to install wtype. Text injection may not work properly."; } else print_info "wtype is already installed." fi @@ -706,8 +717,8 @@ install_text_input_tools() { 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 ! apt_package_installed "$YDOTOOL_PKG"; then + if ! DEBIAN_FRONTEND=noninteractive sudo apt install -y "$YDOTOOL_PKG" 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:" @@ -724,12 +735,12 @@ install_text_input_tools() { ;; fedora) if command_exists dnf; then - sudo dnf install -y ydotool 2>/dev/null || print_info "ydotool not available in repos (optional)" + sudo dnf install -y "$YDOTOOL_PKG" 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)" + if ! pacman_package_installed "$YDOTOOL_PKG"; then + sudo pacman -S --noconfirm "$YDOTOOL_PKG" 2>/dev/null || print_info "ydotool not available in repos (optional)" fi ;; esac @@ -755,64 +766,64 @@ install_text_input_tools() { 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."; } + if ! apt_package_installed "$XDOTOOL_PKG"; then + DEBIAN_FRONTEND=noninteractive sudo apt install -y "$XDOTOOL_PKG" || { 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."; } + if command_exists dnf && ! dnf_package_installed "$XDOTOOL_PKG"; then + sudo dnf install -y "$XDOTOOL_PKG" || { print_warning "Failed to install xdotool. Text injection may not work properly."; } + elif command_exists yum && ! rpm -q "$XDOTOOL_PKG" &>/dev/null; then + sudo yum install -y "$XDOTOOL_PKG" || { 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."; } + if ! pacman_package_installed "$XDOTOOL_PKG"; then + sudo pacman -S --noconfirm "$XDOTOOL_PKG" || { 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."; } + sudo zypper install -y "$XDOTOOL_PKG" || { 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."; } + if ! qlist -I "$XDOTOOL_PKG" >/dev/null 2>&1; then + sudo emerge "$XDOTOOL_PKG" || { 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."; } + if ! apk info -e "$XDOTOOL_PKG" >/dev/null 2>&1; then + sudo apk add "$XDOTOOL_PKG" || { 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."; } + if ! xbps-query "$XDOTOOL_PKG" >/dev/null 2>&1; then + sudo xbps-install -Sy "$XDOTOOL_PKG" || { 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."; } + if ! eopkg list-installed | grep -qw "$XDOTOOL_PKG"; then + sudo eopkg install "$XDOTOOL_PKG" || { 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."; } + if command_exists dnf && ! rpm -q "$XDOTOOL_PKG" >/dev/null 2>&1; then + sudo dnf install -y "$XDOTOOL_PKG" || { print_warning "Failed to install xdotool. Text injection may not work properly."; } + elif command_exists urpmi && ! rpm -q "$XDOTOOL_PKG" >/dev/null 2>&1; then + sudo urpmi -y "$XDOTOOL_PKG" || { print_warning "Failed to install xdotool. Text injection may not work properly."; } else print_info "xdotool is already installed." fi @@ -831,36 +842,36 @@ install_text_input_tools() { # 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."; } + DEBIAN_FRONTEND=noninteractive sudo apt install -y "$XDOTOOL_PKG" "$WTYPE_PKG" || { 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."; } + sudo dnf install -y "$XDOTOOL_PKG" "$WTYPE_PKG" || { 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."; } + sudo yum install -y "$XDOTOOL_PKG" "$WTYPE_PKG" || { 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."; } + sudo urpmi -y "$XDOTOOL_PKG" "$WTYPE_PKG" || { 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."; } + sudo pacman -S --noconfirm "$XDOTOOL_PKG" "$WTYPE_PKG" || { 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."; } + sudo zypper install -y "$XDOTOOL_PKG" "$WTYPE_PKG" || { 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."; } + sudo emerge "$XDOTOOL_PKG" "$WTYPE_PKG" || { 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."; } + sudo apk add "$XDOTOOL_PKG" "$WTYPE_PKG" || { 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."; } + sudo xbps-install -Sy "$XDOTOOL_PKG" "$WTYPE_PKG" || { 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."; } + sudo eopkg install "$XDOTOOL_PKG" "$WTYPE_PKG" || { print_warning "Failed to install text input tools. Text injection may not work properly."; } ;; *) print_warning "Unsupported distribution for text input tools." diff --git a/install.sh b/install.sh index 2ab29a4..a048c07 100755 --- a/install.sh +++ b/install.sh @@ -707,7 +707,7 @@ source_installer_module() { # Shared by the model installer module and the runtime downloader. MODEL_CHECKSUMS_FILE="$INSTALL_DIR/src/vocalinux/utils/model_checksums.txt" -for INSTALLER_MODULE in interactive.sh system_dependencies.sh models.sh desktop.sh; do +for INSTALLER_MODULE in package_map.sh interactive.sh system_dependencies.sh models.sh desktop.sh; do source_installer_module "$INSTALLER_MODULE" || exit 1 done unset INSTALLER_MODULE diff --git a/justfile b/justfile index f612599..07ffb2c 100644 --- a/justfile +++ b/justfile @@ -193,6 +193,15 @@ lock-check: export-check: python3 scripts/check_exports.py +# Regenerate the shell package inventory consumed by install.sh. The committed +# output keeps the runtime installer independent of a YAML parser. +distro-packages: _tooling + uv run --no-sync python scripts/generate_distro_package_map.py + +# Fail if install.d/package_map.sh is behind its YAML source. +distro-packages-check: _tooling + uv run --no-sync python scripts/generate_distro_package_map.py --check + # The export pins names, versions and digests; this looks up the URL that serves # those bytes, so it needs PyPI. tests/test_flatpak_packaging.py checks the same # invariant offline, which is what CI gates on. diff --git a/pyproject.toml b/pyproject.toml index 38dc5a4..e4e13be 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -72,6 +72,7 @@ dev = [ "pytest-timeout>=2.1.0", "pre-commit>=3.0.0", "mypy>=1.0.0", + "pyyaml>=6.0,<7", ] docs = [ "sphinx>=6.0.0", diff --git a/requirements/dev.txt b/requirements/dev.txt index 8fe36a2..3db1d9c 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -993,7 +993,9 @@ pyyaml==6.0.3 \ --hash=sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c \ --hash=sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6 \ --hash=sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0 - # via pre-commit + # via + # pre-commit + # vocalinux requests==2.34.2 \ --hash=sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0 \ --hash=sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed diff --git a/scripts/distro-package-map.yaml b/scripts/distro-package-map.yaml index 1f6c55d..998c599 100644 --- a/scripts/distro-package-map.yaml +++ b/scripts/distro-package-map.yaml @@ -1,256 +1,192 @@ -# Package name mapping for different Linux distributions -# This file documents the package names used by the installer for each distribution -# It serves as a reference for manual installation and for adding support for new distributions +# Authoritative distro package inventory for install.sh. +# +# Edit this file, then run `just distro-packages`. The installer consumes the +# generated install.d/package_map.sh and never needs a YAML parser at runtime. +# The remote bootstrap's git package is the one deliberate exception: it must +# be installed before the tagged repository and this generated map are present. +schema_version: 1 -# System packages required for Vocalinux -system_packages: - # GTK3 and PyGObject - gtk3: - ubuntu: ["python3-gi", "python3-gi-cairo", "gir1.2-gtk-3.0", "gir1.2-gdkpixbuf-2.0"] - debian: ["python3-gi", "python3-gi-cairo", "gir1.2-gtk-3.0", "gir1.2-gdkpixbuf-2.0", "libcairo2-dev"] - fedora: ["python3-gobject", "gtk3", "gobject-introspection-devel"] - arch: ["python-gobject", "gtk3", "gobject-introspection", "python-cairo"] - # Tumbleweed often uses versioned Python package names such as python313-gobject. - # NOTE: "python3XY" is a placeholder convention. The XY suffix is resolved at - # install time by install.sh via suse_python_package_prefix() and - # suse_python_package_candidates(), which try the active Python minor version - # first (e.g. python313-gobject) and fall back to the unversioned name. - opensuse: ["python3XY-gobject", "python3XY-gobject-cairo", "gtk3", "gobject-introspection-devel"] - gentoo: ["dev-python/pygobject:3", "x11-libs/gtk+:3"] - alpine: ["py3-gobject3", "py3-cairo", "gtk+3.0"] - void: ["python3-gobject", "gtk+3"] - solus: ["python3-gobject", "gtk3"] +distributions: + ubuntu: + # libssl/autotools/libtool/patchelf support pywhispercpp source builds. + system: + - 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 + - xclip + - xsel + - wl-clipboard + - libssl-dev + - autoconf + - automake + - libtool + - patchelf + appindicator: [gir1.2-appindicator3-0.1, gir1.2-ayatanaappindicator3-0.1] + gi_development: [libgirepository-2.0-dev, libgirepository1.0-dev] + shader_compiler: [glslc, glslang-tools] + optional_system: [util-linux-extra] + text_input: + xdotool: [xdotool] + wtype: [wtype] + ydotool: [ydotool] - # AppIndicator / Ayatana AppIndicator - # Prefer Ayatana: legacy Canonical AppIndicator can load without error but - # fail to register a StatusNotifierItem on modern KDE Plasma. - appindicator: - ubuntu: ["gir1.2-appindicator3-0.1"] - debian_11: ["gir1.2-ayatanaappindicator3-0.1"] - debian_12_plus: ["gir1.2-ayatanaappindicator3-0.1"] - fedora: ["libayatana-appindicator-gtk3"] - arch: ["libayatana-appindicator"] - opensuse: ["typelib-1_0-AyatanaAppIndicator3-0_1", "libayatana-appindicator3-1", "typelib-1_0-AppIndicator3-0_1"] - gentoo: ["dev-libs/libayatana-appindicator"] - alpine: ["libayatana-appindicator"] - void: ["libappindicator"] - solus: ["libappindicator"] + debian_12: + system: + - 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 + - xclip + - xsel + - wl-clipboard + - libssl-dev + - autoconf + - automake + - libtool + - patchelf + - libgirepository1.0-dev + - gir1.2-ayatanaappindicator3-0.1 + shader_compiler: [glslc, glslang-tools] + optional_system: [util-linux-extra] + text_input: + xdotool: [xdotool] + wtype: [wtype] + ydotool: [ydotool] - # PortAudio - portaudio: - ubuntu: ["portaudio19-dev"] - debian: ["portaudio19-dev"] - fedora: ["portaudio-devel"] - arch: ["portaudio"] - opensuse: ["portaudio-devel"] - gentoo: ["media-libs/portaudio"] - alpine: ["portaudio-dev"] - void: ["portaudio-devel"] - solus: ["portaudio-devel"] + debian_13_plus: + # Used only to distinguish Debian 13-based derivatives whose own + # VERSION_ID does not match the Debian base release. + selection_probe: libgirepository-2.0-dev + system: + - 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 + - xclip + - xsel + - wl-clipboard + - libssl-dev + - autoconf + - automake + - libtool + - patchelf + - libgirepository-2.0-dev + - gir1.2-ayatanaappindicator3-0.1 + shader_compiler: [glslc, glslang-tools] + optional_system: [util-linux-extra] + text_input: + xdotool: [xdotool] + wtype: [wtype] + ydotool: [ydotool] - # Python development - python_dev: - ubuntu: ["python3-dev", "python3-venv"] - debian: ["python3-dev", "python3-venv"] - fedora: ["python3-devel", "python3-virtualenv"] - arch: ["python"] # Arch includes venv by default - # Tumbleweed package names follow the active Python minor version, e.g. python313-devel. - # NOTE: "python3XY" is a placeholder — see gtk3.opensuse comment above for details. - opensuse: ["python3XY-devel", "python3XY-virtualenv", "python3XY-venv", "python3XY-pip"] - gentoo: ["dev-lang/python:3.11"] - alpine: ["py3-virtualenv"] - void: ["python3-devel"] - solus: ["python3-virtualenv"] + fedora: + # cairo-devel is needed because the pinned pycairo builds from source. + system: [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] + appindicator: [libayatana-appindicator-gtk3, libappindicator-gtk3] + text_input: + xdotool: [xdotool] + wtype: [wtype] + ydotool: [ydotool] - # Package build tools - build_tools: - ubuntu: ["pkg-config", "wget", "curl", "unzip"] - debian: ["pkg-config", "wget", "curl", "unzip"] - fedora: ["pkg-config", "wget", "curl", "unzip"] - arch: ["pkgconf", "wget", "curl", "unzip"] - opensuse: ["pkg-config", "wget", "curl", "unzip"] - gentoo: ["dev-util/pkgconfig", "net-misc/wget", "net-misc/curl", "app-arch/unzip"] - alpine: ["pkgconf", "wget", "curl", "unzip"] - void: ["pkg-config", "wget", "curl", "unzip"] - solus: ["pkg-config", "wget", "curl", "unzip"] + arch: + system: [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] + appindicator: [libayatana-appindicator, libappindicator-gtk3] + text_input: + xdotool: [xdotool] + wtype: [wtype] + ydotool: [ydotool] - # Text injection tools - xdotool: - ubuntu: ["xdotool"] - debian: ["xdotool"] - fedora: ["xdotool"] - arch: ["xdotool"] - opensuse: ["xdotool"] - gentoo: ["x11-misc/xdotool"] - alpine: ["xdotool"] - void: ["xdotool"] - solus: ["xdotool"] - - # Clipboard tools used for layout-independent Wayland paste fallback - clipboard_tools: - ubuntu: ["wl-clipboard", "xclip", "xsel"] - debian: ["wl-clipboard", "xclip", "xsel"] - fedora: ["wl-clipboard", "xclip", "xsel"] - arch: ["wl-clipboard", "xclip", "xsel"] - opensuse: ["wl-clipboard", "xclip", "xsel"] - gentoo: ["gui-apps/wl-clipboard", "x11-misc/xclip", "x11-misc/xsel"] - alpine: ["wl-clipboard", "xclip", "xsel"] - void: ["wl-clipboard", "xclip", "xsel"] - solus: ["wl-clipboard", "xclip", "xsel"] - - wtype: - ubuntu: ["wtype"] - debian: ["wtype"] - fedora: ["wtype"] - arch: ["wtype"] - opensuse: ["wtype"] - gentoo: ["x11-misc/wtype"] - alpine: ["wtype"] - void: ["wtype"] - solus: ["wtype"] - - ydotool: - ubuntu: ["ydotool"] - debian: ["ydotool"] - fedora: ["ydotool"] - arch: ["ydotool"] - opensuse: ["ydotool"] - gentoo: ["app-misc/ydotool"] - alpine: ["ydotool"] - void: ["ydotool"] - solus: ["ydotool"] - -# Command templates for package managers -install_commands: - apt: "sudo apt install -y {packages}" - apt_update: "sudo apt update" - - dnf: "sudo dnf install -y {packages}" - dnf_update: "sudo dnf update -y" # Optional update - - yum: "sudo yum install -y {packages}" - - pacman: "sudo pacman -S --noconfirm {packages}" - pacman_update: "sudo pacman -Syu" # Optional update - - zypper: "sudo zypper install -y {packages}" - zypper_update: "sudo zypper refresh" # Optional update - - emerge: "sudo emerge {packages}" - - apk: "sudo apk add {packages}" - apk_update: "sudo apk update" # Optional update - - xbps: "sudo xbps-install -Sy {packages}" - xbps_update: "sudo xbps-install -Sy" # Updates sync during install - - eopkg: "sudo eopkg install -y {packages}" - eopkg_update: "sudo eopkg upgrade" # Optional update - -# Distribution family mappings -# This helps determine which package manager and package names to use -distribution_families: - # Ubuntu family - - id: ubuntu - family: ubuntu - package_manager: apt - variations: [pop, linuxmint, elementary, zorin] - - # Debian family - - id: debian - family: debian - package_manager: apt - variations: [] - - # Fedora family - - id: fedora - family: fedora - package_manager: dnf - variations: [rhel, centos, rocky, almalinux] - - # Arch family - - id: arch - family: arch - package_manager: pacman - variations: [manjaro, endeavouros] - - # openSUSE family - - id: opensuse - family: suse - package_manager: zypper - variations: [] - - # Gentoo - - id: gentoo - family: gentoo - package_manager: emerge - variations: [] - - # Alpine - - id: alpine - family: alpine - package_manager: apk - variations: [] - - # Void Linux - - id: void - family: void - package_manager: xbps - variations: [] - - # Solus - - id: solus - family: solus - package_manager: eopkg - variations: [] - - # Mageia - - id: mageia - family: mageia - package_manager: dnf # Prefers dnf, falls back to urpmi - variations: [] - -# Notes for specific distributions -distribution_notes: - debian_11: - appindicator: "Use gir1.2-ayatanaappindicator3-0.1 instead of gir1.2-appindicator3-0.1" + suse: + # IBus is a runtime dependency; ibus-devel drags in an unsatisfiable gtk-doc + # chain on Tumbleweed. The compilers build evdev and PyAudio from sdists. + system: [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] + appindicator: [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] + python_packages: + pip: pip + gobject: gobject + gobject_cairo: gobject-cairo + devel: devel + virtualenv: virtualenv + venv: venv + vulkan: [vulkan-tools, vulkan-devel] + shader_compiler: [shaderc, glslang-devel, glslang] + text_input: + xdotool: [xdotool] + wtype: [wtype] + ydotool: [ydotool] gentoo: - general: "Gentoo compiles packages from source. Installation will take longer." - python: "Ensure Python 3.11+ is set as the active version" + system: [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] + text_input: + xdotool: [x11-misc/xdotool] + wtype: [x11-misc/wtype] + ydotool: [app-misc/ydotool] alpine: - general: "Alpine uses musl libc. Some Python packages may not have pre-built wheels." - - nixos: - general: "NixOS is not supported due to completely different filesystem layout." + system: [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] + text_input: + xdotool: [xdotool] + wtype: [wtype] + ydotool: [ydotool] void: - general: "Void Linux may use either glibc or musl libc. glibc version recommended." + system: [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] + text_input: + xdotool: [xdotool] + wtype: [wtype] + ydotool: [ydotool] -# Architecture-specific library paths -library_paths: - typelib: - # Debian/Ubuntu multiarch paths - - /usr/lib/x86_64-linux-gnu/girepository-1.0 - - /usr/lib/aarch64-linux-gnu/girepository-1.0 - - /usr/lib/arm-linux-gnueabihf/girepository-1.0 - - /usr/lib/riscv64-linux-gnu/girepository-1.0 - - /usr/lib/powerpc64le-linux-gnu/girepository-1.0 - - /usr/lib/s390x-linux-gnu/girepository-1.0 + solus: + system: [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] + text_input: + xdotool: [xdotool] + wtype: [wtype] + ydotool: [ydotool] - # Fedora/RHEL - - /usr/lib64/girepository-1.0 - - # Arch, generic - - /usr/lib/girepository-1.0 - - # Local installations - - /usr/local/lib/girepository-1.0 - - /usr/local/lib64/girepository-1.0 - - models: - - /usr/local/share/vocalinux/models - - /usr/share/vocalinux/models - - /usr/lib64/vocalinux/models - - /usr/lib/vocalinux/models + mageia: + # The existing installer deliberately follows Fedora's package inventory. + system: [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] + text_input: + xdotool: [xdotool] + wtype: [wtype] + ydotool: [ydotool] diff --git a/scripts/generate_distro_package_map.py b/scripts/generate_distro_package_map.py new file mode 100644 index 0000000..2647975 --- /dev/null +++ b/scripts/generate_distro_package_map.py @@ -0,0 +1,246 @@ +#!/usr/bin/env python3 +"""Generate the installer's shell package map from its YAML source.""" + +from __future__ import annotations + +import argparse +import shlex +import sys +from pathlib import Path +from typing import Any + +import yaml + +ROOT = Path(__file__).resolve().parents[1] +SOURCE = ROOT / "scripts" / "distro-package-map.yaml" +TARGET = ROOT / "install.d" / "package_map.sh" +REQUIRED_DISTRIBUTIONS = { + "alpine", + "arch", + "debian_12", + "debian_13_plus", + "fedora", + "gentoo", + "mageia", + "solus", + "suse", + "ubuntu", + "void", +} +LIST_FIELDS = { + "appindicator", + "gi_development", + "optional_system", + "shader_compiler", + "system", + "vulkan", +} +SCALAR_FIELDS = {"selection_probe"} +TEXT_TOOLS = {"wtype", "xdotool", "ydotool"} +SUSE_PYTHON_ROLES = {"pip", "gobject", "gobject_cairo", "devel", "virtualenv", "venv"} + + +class UniqueKeyLoader(yaml.SafeLoader): + """Reject duplicate mapping keys instead of silently dropping inventory.""" + + def construct_mapping(self, node: yaml.MappingNode, deep: bool = False) -> dict: + self.flatten_mapping(node) + result = {} + for key_node, value_node in node.value: + key = self.construct_object(key_node, deep=deep) + if not isinstance(key, str): + raise ValueError("package map keys must be strings") + if key in result: + raise ValueError(f"duplicate YAML key: {key}") + result[key] = self.construct_object(value_node, deep=deep) + return result + + +def _package_list(value: Any, location: str, *, required: bool = False) -> list[str]: + """Validate and return one package list.""" + if value is None and not required: + return [] + if not isinstance(value, list) or (required and not value): + raise ValueError(f"{location} must be a non-empty list") + if any(not isinstance(item, str) or not item for item in value): + raise ValueError(f"{location} must contain non-empty strings") + if len(value) != len(set(value)): + raise ValueError(f"{location} contains duplicate packages") + return value + + +def load_map() -> dict[str, dict[str, Any]]: + """Load and validate the authoritative YAML package map.""" + document = yaml.load(SOURCE.read_text(encoding="utf-8"), Loader=UniqueKeyLoader) + if not isinstance(document, dict) or document.get("schema_version") != 1: + raise ValueError("distro package map must use schema_version: 1") + distributions = document.get("distributions") + if not isinstance(distributions, dict): + raise ValueError("distro package map must define a distributions mapping") + names = set(distributions) + if names != REQUIRED_DISTRIBUTIONS: + missing = ", ".join(sorted(REQUIRED_DISTRIBUTIONS - names)) or "none" + extra = ", ".join(sorted(names - REQUIRED_DISTRIBUTIONS)) or "none" + raise ValueError(f"distribution keys differ (missing: {missing}; extra: {extra})") + + for distro, config in distributions.items(): + if not isinstance(config, dict): + raise ValueError(f"distributions.{distro} must be a mapping") + unknown = set(config) - LIST_FIELDS - SCALAR_FIELDS - {"python_packages", "text_input"} + if unknown: + raise ValueError(f"distributions.{distro} has unknown fields: {sorted(unknown)}") + _package_list(config.get("system"), f"distributions.{distro}.system", required=True) + for field in LIST_FIELDS - {"system"}: + _package_list(config.get(field), f"distributions.{distro}.{field}") + selection_probe = config.get("selection_probe") + if selection_probe is not None and ( + distro != "debian_13_plus" + or not isinstance(selection_probe, str) + or not selection_probe + ): + raise ValueError("selection_probe must be a non-empty string on debian_13_plus only") + python_packages = config.get("python_packages") + if python_packages is not None: + if distro != "suse" or not isinstance(python_packages, dict): + raise ValueError("python_packages must be a mapping on suse only") + if set(python_packages) != SUSE_PYTHON_ROLES: + raise ValueError( + f"distributions.suse.python_packages must define {sorted(SUSE_PYTHON_ROLES)}" + ) + if any(not isinstance(value, str) or not value for value in python_packages.values()): + raise ValueError("distributions.suse.python_packages values must be strings") + text_input = config.get("text_input") + if not isinstance(text_input, dict) or set(text_input) != TEXT_TOOLS: + raise ValueError(f"distributions.{distro}.text_input must define {sorted(TEXT_TOOLS)}") + for tool in TEXT_TOOLS: + _package_list( + text_input.get(tool), + f"distributions.{distro}.text_input.{tool}", + required=True, + ) + if len(text_input[tool]) != 1: + raise ValueError( + f"distributions.{distro}.text_input.{tool} needs exactly one package" + ) + + for distro in ("ubuntu", "fedora", "arch"): + if len(distributions[distro].get("appindicator") or []) != 2: + raise ValueError(f"distributions.{distro}.appindicator must define two fallbacks") + if len(distributions["suse"].get("appindicator") or []) < 4: + raise ValueError("distributions.suse.appindicator must define at least four alternatives") + if not distributions["debian_13_plus"].get("selection_probe"): + raise ValueError("distributions.debian_13_plus.selection_probe is required") + if len(distributions["ubuntu"].get("gi_development") or []) != 2: + raise ValueError("distributions.ubuntu.gi_development must define modern and legacy") + for distro in ("ubuntu", "debian_12", "debian_13_plus"): + if len(distributions[distro].get("shader_compiler") or []) < 2: + raise ValueError(f"distributions.{distro}.shader_compiler needs a fallback") + if "python_packages" not in distributions["suse"]: + raise ValueError("distributions.suse.python_packages is required") + for field in ("vulkan", "shader_compiler"): + _package_list(distributions["suse"].get(field), f"suse.{field}", required=True) + return distributions + + +def _array_assignment(name: str, values: list[str], indent: str = " ") -> str: + quoted = " ".join(shlex.quote(value) for value in values) + return f"{indent}{name}=({quoted})" + + +def render(distributions: dict[str, dict[str, Any]]) -> str: + """Render a deterministic Bash module from the validated map.""" + debian_13_probe = distributions["debian_13_plus"]["selection_probe"] + lines = [ + "#!/bin/bash", + "# Generated by scripts/generate_distro_package_map.py -- do not edit.", + "# Regenerate with `just distro-packages` after editing", + "# scripts/distro-package-map.yaml.", + "", + f"DEBIAN_13_PLUS_PROBE_PACKAGE={shlex.quote(debian_13_probe)}", + "", + "load_distro_package_map() {", + ' local package_map_key="$1"', + "", + " SYSTEM_PACKAGES=()", + " APPINDICATOR_PACKAGES=()", + " GI_DEVELOPMENT_PACKAGES=()", + " OPTIONAL_SYSTEM_PACKAGES=()", + ' PYTHON_PIP_SUFFIX=""', + ' PYTHON_GOBJECT_SUFFIX=""', + ' PYTHON_GOBJECT_CAIRO_SUFFIX=""', + ' PYTHON_DEVEL_SUFFIX=""', + ' PYTHON_VIRTUALENV_SUFFIX=""', + ' PYTHON_VENV_SUFFIX=""', + " SHADER_COMPILER_PACKAGES=()", + " VULKAN_PACKAGES=()", + " XDOTOOL_PACKAGES=()", + " WTYPE_PACKAGES=()", + " YDOTOOL_PACKAGES=()", + "", + ' case "$package_map_key" in', + ] + names = { + "system": "SYSTEM_PACKAGES", + "appindicator": "APPINDICATOR_PACKAGES", + "gi_development": "GI_DEVELOPMENT_PACKAGES", + "optional_system": "OPTIONAL_SYSTEM_PACKAGES", + "shader_compiler": "SHADER_COMPILER_PACKAGES", + "vulkan": "VULKAN_PACKAGES", + } + for distro, config in distributions.items(): + lines.append(f" {distro})") + for field, variable in names.items(): + values = _package_list(config.get(field), f"{distro}.{field}") + if values: + lines.append(_array_assignment(variable, values)) + python_packages = config.get("python_packages") + if python_packages: + for role in sorted(SUSE_PYTHON_ROLES): + variable = f"PYTHON_{role.upper()}_SUFFIX" + lines.append(f" {variable}={shlex.quote(python_packages[role])}") + text_input = config["text_input"] + lines.append(_array_assignment("XDOTOOL_PACKAGES", text_input["xdotool"])) + lines.append(_array_assignment("WTYPE_PACKAGES", text_input["wtype"])) + lines.append(_array_assignment("YDOTOOL_PACKAGES", text_input["ydotool"])) + lines.extend([" ;;", ""]) + lines.extend( + [ + " *)", + ' echo "Unknown distro package map key: $package_map_key" >&2', + " return 1", + " ;;", + " esac", + "}", + "", + ] + ) + return "\n".join(lines) + + +def main() -> int: + """Generate the map, or check that the committed output is current.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--check", action="store_true", help="fail if the output is stale") + args = parser.parse_args() + try: + generated = render(load_map()) + except (OSError, ValueError, yaml.YAMLError) as error: + print(f"error: {error}", file=sys.stderr) + return 1 + + if args.check: + current = TARGET.read_text(encoding="utf-8") if TARGET.exists() else "" + if current != generated: + print( + f"{TARGET.relative_to(ROOT)} is stale; run `just distro-packages`", + file=sys.stderr, + ) + return 1 + return 0 + + TARGET.write_text(generated, encoding="utf-8") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_cross_distro_compatibility.py b/tests/test_cross_distro_compatibility.py index a5485b8..b1efb3a 100644 --- a/tests/test_cross_distro_compatibility.py +++ b/tests/test_cross_distro_compatibility.py @@ -138,12 +138,12 @@ class TestCrossDistroCompatibility: assert "typelib-1_0-Notify-0_7" in install_sh_content assert "libnotify4" in install_sh_content assert "suse_install_appindicator_runtime" in install_sh_content - assert "shaderc glslang-devel glslang" in install_sh_content - zypper_line = next( - line for line in install_sh_content.splitlines() if "local ZYPPER_PACKAGES=" in line + line + for line in install_sh_content.splitlines() + if "SYSTEM_PACKAGES=(gtk3 ibus " in line ) - zypper_packages = zypper_line.split('"')[1].split() + zypper_packages = zypper_line.removeprefix("SYSTEM_PACKAGES=(").rstrip(")").split() # ibus, not the typelib alone: ibus_engine.py spawns `ibus-daemon -x -d -r` # and shells out to `ibus engine`, neither of which libibus provides. assert "ibus" in zypper_packages @@ -157,10 +157,14 @@ class TestCrossDistroCompatibility: def test_rpm_arch_install_glslc_not_glslang(self, install_sh_content): """ggml Vulkan needs glslc (shaderc). glslangValidator is not a substitute (#604).""" dnf_line = next( - line for line in install_sh_content.splitlines() if "local DNF_PACKAGES=" in line + line + for line in install_sh_content.splitlines() + if "SYSTEM_PACKAGES=(python3-pip python3-gobject gtk3 " in line ) pacman_line = next( - line for line in install_sh_content.splitlines() if "local PACMAN_PACKAGES=" in line + line + for line in install_sh_content.splitlines() + if "SYSTEM_PACKAGES=(python-pip python-gobject gtk3 " in line ) assert "glslc" in dnf_line assert "patchelf" in dnf_line @@ -177,7 +181,9 @@ class TestCrossDistroCompatibility: in install_sh_content ) emerge_line = next( - line for line in install_sh_content.splitlines() if "local EMERGE_PACKAGES=" in line + line + for line in install_sh_content.splitlines() + if "SYSTEM_PACKAGES=(dev-python/pygobject:3 " in line ) assert "media-libs/shaderc" in emerge_line assert "dev-util/patchelf" in emerge_line diff --git a/tests/test_distro_package_map.py b/tests/test_distro_package_map.py new file mode 100644 index 0000000..3ed40da --- /dev/null +++ b/tests/test_distro_package_map.py @@ -0,0 +1,204 @@ +"""Guards for the YAML-owned installer package inventory.""" + +import importlib.util +import os +import shlex +import subprocess +import sys +from pathlib import Path +from typing import Any + +import pytest +import yaml + +ROOT = Path(__file__).resolve().parents[1] +SOURCE = ROOT / "scripts" / "distro-package-map.yaml" +TARGET = ROOT / "install.d" / "package_map.sh" +GENERATOR = ROOT / "scripts" / "generate_distro_package_map.py" + + +def _load_generator() -> Any: + spec = importlib.util.spec_from_file_location("generate_distro_package_map", GENERATOR) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def test_generated_package_map_matches_yaml() -> None: + """A YAML edit cannot leave the installer using yesterday's packages.""" + generator = _load_generator() + assert TARGET.read_text(encoding="utf-8") == generator.render(generator.load_map()) + + +def test_generated_package_map_is_valid_bash() -> None: + """The committed artifact must remain safe for install.sh to source.""" + result = subprocess.run(["bash", "-n", str(TARGET)], capture_output=True, text=True) + assert result.returncode == 0, result.stderr + + +def test_supported_debian_maps_start_at_debian_12() -> None: + """Do not retain an EOL Debian 11 package branch in the source of truth.""" + distributions = yaml.safe_load(SOURCE.read_text(encoding="utf-8"))["distributions"] + debian_maps = {name for name in distributions if name.startswith("debian_")} + assert debian_maps == {"debian_12", "debian_13_plus"} + + +def _resolve_debian_map( + distro_id: str, version: str, *, modern_gi: bool, refresh_fails: bool = False +) -> subprocess.CompletedProcess: + script = f""" +set -eu +source {shlex.quote(str(TARGET))} +source {shlex.quote(str(ROOT / 'install.d' / 'system_dependencies.sh'))} +print_error() {{ echo "$*"; }} +EXIT_NETWORK=3 +EXIT_MISSING_DEPS=2 +CACHE_READY=no +sudo() {{ + [ "$*" = "apt update" ] || exit 99 + echo "refreshing indexes" + [ "$REFRESH_FAILS" = no ] || return 1 + CACHE_READY=yes +}} +apt-cache() {{ + [ "$*" = "show $DEBIAN_13_PLUS_PROBE_PACKAGE" ] || exit 99 + [ "$CACHE_READY" = yes ] && [ "$HAS_MODERN_GI" = yes ] +}} +DISTRO_ID="$1" +DISTRO_VERSION="$2" +resolve_debian_package_map_key +""" + return subprocess.run( + ["bash", "-c", script, "bash", distro_id, version], + capture_output=True, + text=True, + env={ + **os.environ, + "HAS_MODERN_GI": "yes" if modern_gi else "no", + "REFRESH_FAILS": "yes" if refresh_fails else "no", + }, + ) + + +def test_native_debian_version_selects_supported_map() -> None: + """Debian itself uses VERSION_ID and rejects its EOL releases.""" + assert _resolve_debian_map("debian", "11", modern_gi=False).returncode != 0 + assert _resolve_debian_map("debian", "12", modern_gi=True).stdout.strip() == "debian_12" + assert _resolve_debian_map("debian", "13", modern_gi=False).stdout.strip() == "debian_13_plus" + + +def test_debian_derivative_ignores_unrelated_product_version() -> None: + """A derivative's VERSION_ID must not be interpreted as a Debian release.""" + legacy = _resolve_debian_map("mx", "11", modern_gi=False) + modern = _resolve_debian_map("kali", "2026.3", modern_gi=True) + assert legacy.returncode == 0 + assert legacy.stdout.strip() == "debian_12" + assert modern.stdout.strip() == "debian_13_plus" + assert "refreshing indexes" in modern.stderr + + +def test_debian_probe_does_not_fall_back_on_network_failure() -> None: + result = _resolve_debian_map("derivative", "7", modern_gi=True, refresh_fails=True) + assert result.returncode == 3 + assert "debian_12" not in result.stdout + + +@pytest.mark.parametrize("field", ["vulkan", "shader_compiler"]) +@pytest.mark.parametrize("value", [None, []]) +def test_required_suse_build_inventory(tmp_path: Path, field: str, value: Any) -> None: + generator = _load_generator() + document = yaml.safe_load(SOURCE.read_text(encoding="utf-8")) + if value is None: + del document["distributions"]["suse"][field] + else: + document["distributions"]["suse"][field] = value + generator.SOURCE = tmp_path / "invalid.yaml" + generator.SOURCE.write_text(yaml.safe_dump(document), encoding="utf-8") + with pytest.raises(ValueError, match=f"suse.{field}"): + generator.load_map() + + +def test_text_tool_cannot_silently_drop_second_package(tmp_path: Path) -> None: + generator = _load_generator() + document = yaml.safe_load(SOURCE.read_text(encoding="utf-8")) + document["distributions"]["ubuntu"]["text_input"]["wtype"].append("extra-package") + generator.SOURCE = tmp_path / "invalid.yaml" + generator.SOURCE.write_text(yaml.safe_dump(document), encoding="utf-8") + with pytest.raises(ValueError, match="exactly one package"): + generator.load_map() + + +def test_suse_suffix_spelling_is_owned_by_yaml(tmp_path: Path) -> None: + generator = _load_generator() + document = yaml.safe_load(SOURCE.read_text(encoding="utf-8")) + document["distributions"]["suse"]["python_packages"]["pip"] = "renamed-pip" + generator.SOURCE = tmp_path / "map.yaml" + generator.SOURCE.write_text(yaml.safe_dump(document), encoding="utf-8") + rendered = generator.render(generator.load_map()) + assert "PYTHON_PIP_SUFFIX=renamed-pip" in rendered + + +def test_suse_python_roles_are_named_not_positional(tmp_path: Path) -> None: + generator = _load_generator() + document = yaml.safe_load(SOURCE.read_text(encoding="utf-8")) + del document["distributions"]["suse"]["python_packages"]["pip"] + document["distributions"]["suse"]["python_packages"]["unexpected"] = "pip" + generator.SOURCE = tmp_path / "invalid.yaml" + generator.SOURCE.write_text(yaml.safe_dump(document), encoding="utf-8") + with pytest.raises(ValueError, match="python_packages must define"): + generator.load_map() + + +@pytest.mark.parametrize( + "old,new", + [ + ("schema_version: 1", "schema_version: 99\nschema_version: 1"), + ("wtype: [wtype]", "wtype: [discarded]\n wtype: [wtype]"), + ], +) +def test_duplicate_keys_are_rejected(tmp_path: Path, old: str, new: str) -> None: + generator = _load_generator() + generator.SOURCE = tmp_path / "invalid.yaml" + generator.SOURCE.write_text(SOURCE.read_text().replace(old, new, 1), encoding="utf-8") + with pytest.raises(ValueError, match="duplicate YAML key"): + generator.load_map() + + +def test_suse_appindicator_alternatives_are_required(tmp_path: Path) -> None: + """Generation fails before an empty openSUSE fallback loop reaches users.""" + generator = _load_generator() + document = yaml.safe_load(SOURCE.read_text(encoding="utf-8")) + document["distributions"]["suse"]["appindicator"] = [] + invalid_source = tmp_path / "invalid.yaml" + invalid_source.write_text(yaml.safe_dump(document), encoding="utf-8") + generator.SOURCE = invalid_source + with pytest.raises(ValueError, match="suse.appindicator"): + generator.load_map() + + +def test_installer_uses_generated_inventory_instead_of_package_lists() -> None: + """Package data belongs in YAML; handwritten shell owns only selection policy.""" + installer = (ROOT / "install.d" / "system_dependencies.sh").read_text(encoding="utf-8") + assert 'load_distro_package_map "$PACKAGE_MAP_KEY"' in installer + for old_variable in ( + "APT_PACKAGES_UBUNTU", + "APT_PACKAGES_DEBIAN_BASE", + "DNF_PACKAGES", + "PACMAN_PACKAGES", + "ZYPPER_PACKAGES", + "EMERGE_PACKAGES", + "APK_PACKAGES", + "XBPS_PACKAGES", + "EOPKG_PACKAGES", + ): + assert f"local {old_variable}=" not in installer + + +def test_unsupported_distros_do_not_read_uninitialized_package_arrays() -> None: + """The existing non-interactive unsupported-distro path may continue safely.""" + installer = (ROOT / "install.d" / "system_dependencies.sh").read_text(encoding="utf-8") + guard = installer.index('[[ -z "${XDOTOOL_PACKAGES+x}" ]]') + first_read = installer.index("${XDOTOOL_PACKAGES[0]}") + assert guard < first_read diff --git a/tests/test_installer_cuda_diagnostics.py b/tests/test_installer_cuda_diagnostics.py index 5caaa0a..c05ce08 100644 --- a/tests/test_installer_cuda_diagnostics.py +++ b/tests/test_installer_cuda_diagnostics.py @@ -3,8 +3,13 @@ import unittest from pathlib import Path +import yaml + INSTALLER = Path(__file__).resolve().parents[1] / "install.sh" INSTALLER_MODULES = Path(__file__).resolve().parents[1] / "install.d" +PACKAGE_MAP = yaml.safe_load( + (INSTALLER.parent / "scripts" / "distro-package-map.yaml").read_text(encoding="utf-8") +)["distributions"] def _installer_source() -> str: @@ -83,40 +88,25 @@ class InstallerCudaDiagnosticsTests(unittest.TestCase): def test_installer_includes_xsel_for_wayland_clipboard_fallback(self) -> None: """Fresh installs should include xsel for ydotool's layout-safe paste path.""" - source = _installer_source() + for distro, config in PACKAGE_MAP.items(): + for tool in ("xclip", "xsel", "wl-clipboard"): + self.assertTrue( + any( + package == tool or package.endswith(f"/{tool}") + for package in config["system"] + ), + f"{distro} does not install {tool}", + ) - required_lines = [ - "local APT_PACKAGES_UBUNTU=", - "local APT_PACKAGES_DEBIAN_BASE=", - "local DNF_PACKAGES=", - "local PACMAN_PACKAGES=", - "local ZYPPER_PACKAGES=", - "local EMERGE_PACKAGES=", - "local APK_PACKAGES=", - "local XBPS_PACKAGES=", - "local EOPKG_PACKAGES=", - ] - - for name in required_lines: - line = next(line for line in source.splitlines() if name in line) - self.assertIn("xclip", line) - self.assertIn("xsel", line) - self.assertIn("wl-clipboard", line) - - def test_ubuntu_22_package_list_omits_util_linux_extra(self) -> None: + def test_optional_apt_package_is_not_in_base_lists(self) -> None: """util-linux-extra is probed via apt-cache, not hardcoded in base package lists.""" source = _installer_source() - ubuntu_line = next( - line for line in source.splitlines() if "local APT_PACKAGES_UBUNTU=" in line - ) - debian_13_line = next( - line for line in source.splitlines() if "local APT_PACKAGES_DEBIAN_13_PLUS=" in line - ) - self.assertNotIn("util-linux-extra", ubuntu_line) - self.assertNotIn("util-linux-extra", debian_13_line) - self.assertIn("apt-cache show util-linux-extra", source) - self.assertIn('APT_PACKAGES="$APT_PACKAGES util-linux-extra"', source) + for distro in ("ubuntu", "debian_12", "debian_13_plus"): + self.assertNotIn("util-linux-extra", PACKAGE_MAP[distro]["system"]) + self.assertIn("util-linux-extra", PACKAGE_MAP[distro]["optional_system"]) + self.assertIn('apt-cache show "$OPTIONAL_PACKAGE"', source) + self.assertIn('SYSTEM_PACKAGES+=("$OPTIONAL_PACKAGE")', source) if __name__ == "__main__": diff --git a/tests/test_installer_modules.py b/tests/test_installer_modules.py index 4a65767..8020d67 100644 --- a/tests/test_installer_modules.py +++ b/tests/test_installer_modules.py @@ -12,6 +12,7 @@ EXPECTED_MODULES = { "desktop.sh", "interactive.sh", "models.sh", + "package_map.sh", "system_dependencies.sh", } @@ -22,6 +23,7 @@ def test_installer_loads_every_module_from_the_resolved_tree() -> None: assert "source_installer_module()" in source for module in EXPECTED_MODULES: assert module in source + assert source.index("package_map.sh") < source.index("system_dependencies.sh") remote_handoff = source.index("handoff_to_tagged_installer") module_loader = source.index("source_installer_module()") diff --git a/tests/test_installer_pinned_dependencies.py b/tests/test_installer_pinned_dependencies.py index de3a352..c90bfeb 100644 --- a/tests/test_installer_pinned_dependencies.py +++ b/tests/test_installer_pinned_dependencies.py @@ -15,6 +15,7 @@ import zipfile from pathlib import Path import pytest +import yaml from packaging.requirements import Requirement ROOT = Path(__file__).resolve().parents[1] @@ -340,10 +341,9 @@ 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_source() - - def package_line(prefix: str) -> str: - return next(line for line in installer.splitlines() if f"local {prefix}=" in line) + package_map = yaml.safe_load( + (ROOT / "scripts" / "distro-package-map.yaml").read_text(encoding="utf-8") + )["distributions"] build_deps = { "pycairo": ("cairo-devel", "libcairo2-dev"), @@ -353,6 +353,7 @@ def test_source_built_runtime_packages_have_build_deps_on_split_devel_distros() for name, (dnf_pkg, apt_pkg) in build_deps.items(): if not re.search(rf"^{name}==", runtime, re.MULTILINE): continue # wheels-only or dropped: nothing to build - assert dnf_pkg in package_line("DNF_PACKAGES"), name - assert apt_pkg in package_line("APT_PACKAGES_UBUNTU"), name - assert apt_pkg in package_line("APT_PACKAGES_DEBIAN_BASE"), name + assert dnf_pkg in package_map["fedora"]["system"], name + assert apt_pkg in package_map["ubuntu"]["system"], name + assert apt_pkg in package_map["debian_12"]["system"], name + assert apt_pkg in package_map["debian_13_plus"]["system"], name diff --git a/uv.lock b/uv.lock index b9ad6f0..81570eb 100644 --- a/uv.lock +++ b/uv.lock @@ -2958,6 +2958,7 @@ dev = [ { name = "pytest-cov" }, { name = "pytest-mock" }, { name = "pytest-timeout" }, + { name = "pyyaml" }, ] docs = [ { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, @@ -3016,6 +3017,7 @@ requires-dist = [ { name = "pytest-mock", marker = "extra == 'dev'", specifier = ">=3.10.0" }, { name = "pytest-timeout", marker = "extra == 'dev'", specifier = ">=2.1.0" }, { name = "pywhispercpp", specifier = ">=1.5.0,<2" }, + { name = "pyyaml", marker = "extra == 'dev'", specifier = ">=6.0,<7" }, { name = "requests", specifier = ">=2.28.0" }, { name = "sherpa-onnx", marker = "extra == 'parakeet'", specifier = ">=1.12.0,<2" }, { name = "sphinx", marker = "extra == 'docs'", specifier = ">=6.0.0" },