diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 6824012..5dc262d 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -29,35 +29,6 @@ jobs: python-version: '3.11' cache: 'pip' - - name: Install system dependencies - run: | - sudo apt-get update - sudo apt-get install -y \ - portaudio19-dev \ - python3-dev \ - libgirepository1.0-dev \ - libgirepository-2.0-dev \ - pkg-config \ - libcairo2-dev \ - gobject-introspection \ - gir1.2-gtk-3.0 \ - gir1.2-appindicator3-0.1 \ - gir1.2-ayatanaappindicator3-0.1 \ - gir1.2-notify-0.7 \ - gir1.2-dbusmenu-glib-0.4 \ - gir1.2-ibus-1.0 \ - gir1.2-rsvg-2.0 \ - librsvg2-common \ - libdbusmenu-gtk3-4 \ - libnotify4 \ - cmake \ - ninja-build \ - build-essential \ - libvulkan-dev \ - glslc \ - spirv-headers \ - spirv-tools - - name: Install build dependencies run: | python -m pip install --upgrade pip setuptools wheel build twine @@ -103,11 +74,19 @@ jobs: done ls -la dist/ + # glslc is built from the pinned shaderc commit inside the container - + # Ubuntu 22.04 packages none - and depends on nothing but that commit. + - name: Cache AppImage build tools + uses: actions/cache@v4 + with: + path: ~/.cache/vocalinux-appimage + key: appimage-tools-${{ runner.arch }}-${{ hashFiles('packaging/appimage/tool_checksums.txt') }} + - name: Build AppImage (x86_64) env: VOCALINUX_APPIMAGE_REQUIRE_VULKAN: "1" run: | - bash packaging/appimage/build.sh dist/*.whl "${{ steps.version.outputs.version }}" dist + bash packaging/appimage/docker-build.sh dist/*.whl "${{ steps.version.outputs.version }}" dist ls -la dist/*.AppImage - name: Cleanup old nightly releases @@ -287,35 +266,6 @@ jobs: python-version: '3.11' cache: 'pip' - - name: Install system dependencies - run: | - sudo apt-get update - sudo apt-get install -y \ - portaudio19-dev \ - python3-dev \ - libgirepository1.0-dev \ - libgirepository-2.0-dev \ - pkg-config \ - libcairo2-dev \ - gobject-introspection \ - gir1.2-gtk-3.0 \ - gir1.2-appindicator3-0.1 \ - gir1.2-ayatanaappindicator3-0.1 \ - gir1.2-notify-0.7 \ - gir1.2-dbusmenu-glib-0.4 \ - gir1.2-ibus-1.0 \ - gir1.2-rsvg-2.0 \ - librsvg2-common \ - libdbusmenu-gtk3-4 \ - libnotify4 \ - cmake \ - ninja-build \ - build-essential \ - libvulkan-dev \ - glslc \ - spirv-headers \ - spirv-tools - - name: Install build dependencies run: | python -m pip install --upgrade pip setuptools wheel build @@ -325,11 +275,19 @@ jobs: python -m build ls -la dist/ + # glslc is built from the pinned shaderc commit inside the container - + # Ubuntu 22.04 packages none - and depends on nothing but that commit. + - name: Cache AppImage build tools + uses: actions/cache@v4 + with: + path: ~/.cache/vocalinux-appimage + key: appimage-tools-${{ runner.arch }}-${{ hashFiles('packaging/appimage/tool_checksums.txt') }} + - name: Build AppImage (aarch64) env: VOCALINUX_APPIMAGE_REQUIRE_VULKAN: "1" run: | - bash packaging/appimage/build.sh dist/*.whl "${{ needs.nightly.outputs.version }}" dist + bash packaging/appimage/docker-build.sh dist/*.whl "${{ needs.nightly.outputs.version }}" dist ls -la dist/*.AppImage - name: Attach to nightly release diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c5f018b..5ce5722 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -30,35 +30,6 @@ jobs: python-version: ${{ env.PYTHON_VERSION }} cache: 'pip' - - name: Install system dependencies - run: | - sudo apt-get update - sudo apt-get install -y \ - portaudio19-dev \ - python3-dev \ - libgirepository1.0-dev \ - libgirepository-2.0-dev \ - pkg-config \ - libcairo2-dev \ - gobject-introspection \ - gir1.2-gtk-3.0 \ - gir1.2-appindicator3-0.1 \ - gir1.2-ayatanaappindicator3-0.1 \ - gir1.2-notify-0.7 \ - gir1.2-dbusmenu-glib-0.4 \ - gir1.2-ibus-1.0 \ - gir1.2-rsvg-2.0 \ - librsvg2-common \ - libdbusmenu-gtk3-4 \ - libnotify4 \ - cmake \ - ninja-build \ - build-essential \ - libvulkan-dev \ - glslc \ - spirv-headers \ - spirv-tools - - name: Install build dependencies run: | python -m pip install --upgrade pip @@ -89,11 +60,19 @@ jobs: echo "Built packages:" ls -la dist/ + # glslc is built from the pinned shaderc commit inside the container - + # Ubuntu 22.04 packages none - and depends on nothing but that commit. + - name: Cache AppImage build tools + uses: actions/cache@v4 + with: + path: ~/.cache/vocalinux-appimage + key: appimage-tools-${{ runner.arch }}-${{ hashFiles('packaging/appimage/tool_checksums.txt') }} + - name: Build AppImage (x86_64) env: VOCALINUX_APPIMAGE_REQUIRE_VULKAN: "1" run: | - bash packaging/appimage/build.sh dist/*.whl "${{ steps.get_version.outputs.VERSION }}" dist + bash packaging/appimage/docker-build.sh dist/*.whl "${{ steps.get_version.outputs.VERSION }}" dist ls -la dist/*.AppImage - name: Create Release @@ -167,35 +146,6 @@ jobs: python-version: ${{ env.PYTHON_VERSION }} cache: 'pip' - - name: Install system dependencies - run: | - sudo apt-get update - sudo apt-get install -y \ - portaudio19-dev \ - python3-dev \ - libgirepository1.0-dev \ - libgirepository-2.0-dev \ - pkg-config \ - libcairo2-dev \ - gobject-introspection \ - gir1.2-gtk-3.0 \ - gir1.2-appindicator3-0.1 \ - gir1.2-ayatanaappindicator3-0.1 \ - gir1.2-notify-0.7 \ - gir1.2-dbusmenu-glib-0.4 \ - gir1.2-ibus-1.0 \ - gir1.2-rsvg-2.0 \ - librsvg2-common \ - libdbusmenu-gtk3-4 \ - libnotify4 \ - cmake \ - ninja-build \ - build-essential \ - libvulkan-dev \ - glslc \ - spirv-headers \ - spirv-tools - - name: Install build dependencies run: | python -m pip install --upgrade pip @@ -212,11 +162,19 @@ jobs: python -m build ls -la dist/ + # glslc is built from the pinned shaderc commit inside the container - + # Ubuntu 22.04 packages none - and depends on nothing but that commit. + - name: Cache AppImage build tools + uses: actions/cache@v4 + with: + path: ~/.cache/vocalinux-appimage + key: appimage-tools-${{ runner.arch }}-${{ hashFiles('packaging/appimage/tool_checksums.txt') }} + - name: Build AppImage (aarch64) env: VOCALINUX_APPIMAGE_REQUIRE_VULKAN: "1" run: | - bash packaging/appimage/build.sh dist/*.whl "${{ steps.get_version.outputs.VERSION }}" dist + bash packaging/appimage/docker-build.sh dist/*.whl "${{ steps.get_version.outputs.VERSION }}" dist ls -la dist/*.AppImage - name: Attach to release diff --git a/.github/workflows/unified-pipeline.yml b/.github/workflows/unified-pipeline.yml index 199c1e4..f96af26 100644 --- a/.github/workflows/unified-pipeline.yml +++ b/.github/workflows/unified-pipeline.yml @@ -37,7 +37,18 @@ jobs: - 'src/**' - 'tests/**' - 'pyproject.toml' - - 'requirements*.txt' + # 'requirements*.txt' does not match requirements/runtime.txt: a + # glob star stops at the slash. The exports and the AppImage build + # gate the same jobs, so a change to either has to reach them. + - 'requirements/**' + - 'packaging/appimage/**' + - 'uv.lock' + # These three end up inside the AppImage: build.sh reads the + # pywhispercpp pin out of install.sh and installs the desktop + # entry and the icon into the AppDir. + - 'install.sh' + - 'vocalinux.desktop' + - 'resources/**' - 'justfile' - '.pre-commit-config.yaml' - '.github/workflows/unified-pipeline.yml' @@ -228,54 +239,27 @@ jobs: python-version: '3.11' cache: 'pip' - - name: Install system dependencies - run: | - sudo apt-get update - sudo apt-get install -y \ - xdotool \ - gir1.2-appindicator3-0.1 \ - gir1.2-ayatanaappindicator3-0.1 \ - gir1.2-notify-0.7 \ - gir1.2-dbusmenu-glib-0.4 \ - gir1.2-ibus-1.0 \ - gir1.2-rsvg-2.0 \ - librsvg2-common \ - libdbusmenu-gtk3-4 \ - libnotify4 \ - libcairo2-dev \ - pkg-config \ - python3-dev \ - libgirepository1.0-dev \ - gobject-introspection \ - libcairo-gobject2 \ - gir1.2-gtk-3.0 \ - portaudio19-dev \ - cmake \ - ninja-build \ - build-essential \ - libvulkan-dev \ - glslc \ - spirv-headers \ - spirv-tools - - - name: Install GObject introspection dependencies - run: | - sudo apt-get update - sudo apt-get install -y gir1.2-glib-2.0 libgirepository-2.0-dev pkg-config - - name: Build wheel run: | python -m pip install --upgrade pip build python -m build ls -la dist/ + # glslc is built from the pinned shaderc commit inside the container - + # Ubuntu 22.04 packages none - and depends on nothing but that commit. + - name: Cache AppImage build tools + uses: actions/cache@v4 + with: + path: ~/.cache/vocalinux-appimage + key: appimage-tools-${{ matrix.arch }}-${{ hashFiles('packaging/appimage/tool_checksums.txt') }} + - name: Build AppImage env: VOCALINUX_APPIMAGE_REQUIRE_VULKAN: "1" run: | VERSION=$(grep -oP '__version__\s*=\s*"\K[^"]+' src/vocalinux/version.py) WHEEL=$(ls dist/*.whl) - bash packaging/appimage/build.sh "$WHEEL" "$VERSION" dist + bash packaging/appimage/docker-build.sh "$WHEEL" "$VERSION" dist ls -la dist/*.AppImage - name: Upload AppImage artifact diff --git a/AGENTS.md b/AGENTS.md index ff08ee6..808a54d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -78,6 +78,7 @@ just deps-all # also whisper/vosk/docs; later recipes use --no-sync so they just lock # regenerate uv.lock + requirements/*.txt just lock-check # fail if uv.lock is stale vs pyproject.toml just model-checksums # refresh pinned model digests after adding a model +just appimage # build the AppImage in its pinned base image (needs docker) just pre-commit # pre-commit run --all-files just run-debug # vocalinux --debug just run-source-debug @@ -96,7 +97,7 @@ Website: `web/AGENTS.md`, `web/PRODUCT.md`, `web/DESIGN.md`. Do not duplicate si ## Dependencies (uv) -`uv.lock` is authoritative. `just lock` regenerates it and the hash-pinned `requirements/*.txt` exports. Those exports exist for later packaging work (`install.sh`, AppImage, CI — phases 2, 3 and 5 of #701) and are unused until those phases land. **Do not edit `requirements/*.txt` by hand.** Change `pyproject.toml` (or `requirements/whisper.in` for the Whisper engine), run `just lock`, and commit the lock plus the exports with the manifest change. +`uv.lock` is authoritative. `just lock` regenerates it and the hash-pinned `requirements/*.txt` exports. The AppImage build installs from `runtime`/`vad` plus `appimage.txt`; `install.sh` still resolves at install time (phase 2 of #701). **Do not edit `requirements/*.txt` by hand.** Change `pyproject.toml` (or `requirements/whisper.in` for the Whisper engine, `requirements/appimage*.in` for the AppImage), run `just lock`, and commit the lock plus the exports with the manifest change. | Constraint | Rule | |---|---| @@ -105,6 +106,8 @@ Website: `web/AGENTS.md`, `web/PRODUCT.md`, `web/DESIGN.md`. Do not duplicate si | Whisper CPU torch | `requirements/whisper.txt` is compiled from `requirements/whisper.in`. Pin `torch`/`torchaudio` together to `+cpu` local versions — PyPI CUDA wheels win resolution regardless of index order, and torchaudio lags torch on the CPU index | | pywhispercpp | Pinned in `install.sh` as `PYWHISPERCPP_VERSION` (keep in sync with `uv.lock`) | | `[vad]` extra | `onnxruntime` for Silero VAD | +| AppImage PyGObject | Pinned separately in `requirements/appimage.in`, and below 3.52: the AppImage bundles its own interpreter and builds PyGObject against the base image's girepository-1.0, while uv.lock's 3.56 needs girepository-2.0 (glib 2.80+) | +| AppImage build inputs | Base image, tooling, interpreter, shaderc and the Vulkan headers are pinned in `packaging/appimage/tool_checksums.txt`. Build with `just appimage` (docker) — building on the host ships the host's glibc, which is what kept the AppImage off Debian 12 | | Speech models | Verified against digests pinned in `src/vocalinux/utils/model_checksums.txt` before install, by both `install.sh` and the runtime downloaders. **Fails closed** — an unpinned model is refused. Regenerate with `just model-checksums` (never by hand); `tests/test_model_checksums.py` fails if it falls behind. whisper.cpp URLs use a pinned Hugging Face commit, never `main` | Optional extras: `vosk`, `whisper`, `vad`, `dev`. @@ -157,6 +160,7 @@ Also: `tests/`, `docs/`, `packaging/` (AppImage, AUR, Flatpak), `scripts/`, `ins - `logger = logging.getLogger(__name__)` per module - Triple-quoted docstrings on modules, classes, and public functions - Specific exceptions; log errors with context +- `subprocess` calls that run a **host** binary pass `env=host_env()` (`utils/host_process.py`): a host binary inheriting the AppImage's library paths dies on the wrong GLib. The one spawn of our own interpreter (`start_engine_process`) passes the environment untouched — it needs the bundle. `tests/test_host_process.py` walks the AST and fails on either mistake - Tests: `tests/test_*.py`, `test_*` functions, `unittest.TestCase` or pytest; `pytest-mock` via `mocker` - Markers: `@pytest.mark.slow`, `integration`, `audio` (hardware). Default timeout 10s (`pytest-timeout`) diff --git a/docs/INSTALL.md b/docs/INSTALL.md index caf4d8a..8f02ec1 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -37,6 +37,10 @@ chmod +x Vocalinux-*-x86_64.AppImage # or aarch64 ./Vocalinux-*-x86_64.AppImage ``` +It is 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 the PyPI path there. + AppImage still needs host text-injection tools (`xdotool` on X11; `wtype` / `ydotool` / clipboard tools on Wayland), same as the PyPI path. Current AppImages rebuild whisper.cpp with Vulkan and use the host GPU driver; you diff --git a/justfile b/justfile index c7d84b4..84b349d 100644 --- a/justfile +++ b/justfile @@ -90,6 +90,13 @@ build: uv build @echo "Built packages in dist/" +# Building on the host instead ships the host's glibc and GTK, which is how the +# published AppImage ended up unable to start on Debian 12 or Ubuntu 22.04. +# +# Build the AppImage as CI does, in the pinned base image (needs docker) +appimage: build + bash packaging/appimage/docker-build.sh dist/*.whl "$(grep -oP '__version__\s*=\s*"\K[^"]+' src/vocalinux/version.py)" dist + # Regenerate uv.lock and the hash-pinned requirements/* exports. # Bump the torch/torchaudio +cpu pins in requirements/whisper.in together # when you want newer CPU builds (torchaudio on the CPU index lags torch). @@ -105,6 +112,13 @@ lock: --index-url https://pypi.org/simple \ --extra-index-url https://download.pytorch.org/whl/cpu \ --python-platform x86_64-unknown-linux-gnu -o requirements/whisper.txt + # Compiled rather than exported: what the AppImage bundles on top of the + # lock, and what builds it, are pinned away from uv.lock on purpose -- + # see requirements/appimage.in. --universal so one file covers both arches. + uv pip compile requirements/appimage.in --universal --no-deps --generate-hashes \ + -o requirements/appimage.txt + uv pip compile requirements/appimage-tools.in --universal --no-deps --generate-hashes \ + -o requirements/appimage-tools.txt # Fail if uv.lock is stale relative to pyproject.toml lock-check: diff --git a/packaging/appimage/build.sh b/packaging/appimage/build.sh index bef5235..9b3f18c 100755 --- a/packaging/appimage/build.sh +++ b/packaging/appimage/build.sh @@ -4,13 +4,20 @@ # # Usage: build.sh [output-dir] # -# Bundles a full copy of the active Python interpreter (relocated via -# PYTHONHOME, not a venv, since venvs hard-code absolute paths) plus +# Bundles a managed CPython (python-build-standalone, fetched by uv, relocated +# via PYTHONHOME rather than a venv since venvs hard-code absolute paths) plus # PyGObject/GTK3/AppIndicator/IBus GObject-Introspection typelibs, since # those are needed by `gi.repository` at runtime and linuxdeploy-plugin-gtk # does not bundle them (it targets native C GTK apps, which don't need # introspection data). # +# Downloads are pinned in packaging/appimage/tool_checksums.txt; packages come +# from the hash-pinned requirements/*.txt exports. +# +# Run it inside the base image those pins name (container-build.sh does), or the +# AppImage inherits this host's glibc and GTK: built on Ubuntu 24.04 it starts on +# neither Debian 12, Ubuntu 22.04 nor RHEL 9. +# # ponytail: text-injection CLI tools (xdotool/wtype/ydotool) are not # bundled, same runtime prerequisite as the PyPI install path documented # in docs/INSTALL.md. Add bundling if users hit missing-binary complaints. @@ -37,14 +44,70 @@ if [ -z "$PYWHISPERCPP_VERSION" ]; then echo "Could not read PYWHISPERCPP_VERSION from install.sh" >&2 exit 1 fi +# The bundle and the Vulkan rebuild install the same pinned sdist. Two pins that +# disagree would ship one whisper build and link against another. +LOCKED_PYWHISPERCPP="$(awk -F'==' '/^pywhispercpp==/ {print $2; exit}' \ + "$REPO_ROOT/requirements/vad.txt" | tr -d ' \\')" +if [ "$LOCKED_PYWHISPERCPP" != "$PYWHISPERCPP_VERSION" ]; then + echo "pywhispercpp is pinned twice and the pins disagree:" >&2 + echo " install.sh: $PYWHISPERCPP_VERSION" >&2 + echo " requirements/vad.txt: $LOCKED_PYWHISPERCPP" >&2 + echo "Change pyproject.toml and run 'just lock', or fix install.sh." >&2 + exit 1 +fi ARCH="$(uname -m)" -PYTHON="${PYTHON:-python3}" case "$ARCH" in x86_64|aarch64) ;; *) echo "Unsupported architecture: $ARCH (need x86_64 or aarch64)" >&2; exit 1 ;; esac +# Every download below is looked up here first, so a moved upstream asset fails +# the build instead of quietly changing what ships. +PINS="$REPO_ROOT/packaging/appimage/tool_checksums.txt" + +pin_field() { + local name="$1" field="$2" value + value="$(awk -v k="$name" -v f="$field" '$1==k {print $f; exit}' "$PINS")" + if [ -z "$value" ]; then + echo "No pin named '$name' in $PINS" >&2 + exit 1 + fi + printf '%s\n' "$value" +} + +# Default --retry skips TLS RST (curl 35). GitHub release assets flake that way. +curl_retry() { + curl --retry 5 --retry-all-errors --retry-delay 2 --fail --silent --show-error --location "$@" +} + +# Download the pin named $1 to $2, and refuse to go on unless it hashes right. +fetch_pinned() { + local name="$1" dest="$2" url expected actual + url="$(pin_field "$name" 4)" + expected="$(pin_field "$name" 3)" + curl_retry -o "$dest" "$url" + actual="$(sha256sum "$dest" | cut -d' ' -f1)" + if [ "$actual" != "$expected" ]; then + echo "Checksum mismatch for $name" >&2 + echo " url: $url" >&2 + echo " expected: $expected" >&2 + echo " actual: $actual" >&2 + exit 1 + fi +} + +# Checkout of a pinned git tree, shared by the two things that need one. +checkout_pinned() { + local name="$1" dest="$2" commit + commit="$(pin_field "$name" 3)" + mkdir -p "$dest" + git -C "$dest" init -q + git -C "$dest" remote add origin "$(pin_field "$name" 4)" + git -C "$dest" fetch -q --depth 1 origin "$commit" + git -C "$dest" checkout -q FETCH_HEAD +} + # Seed typelibs Vocalinux imports directly, plus transitive GIR deps that Gtk # and AppIndicator require (xlib, Dbusmenu, …). Without those, GI falls back to # the builder's baked-in path (/usr/lib/x86_64-linux-gnu/girepository-1.0), which @@ -65,12 +128,33 @@ INDICATOR_TYPELIBS=( # Shared libs loaded via GI at runtime (not linked into python3), so # linuxdeploy will not discover them from -e python3 alone. +# verify_typelib_libraries below fails the build when one is missing. GI_RUNTIME_LIBS=( libappindicator3.so.1 libayatana-appindicator3.so.1 libdbusmenu-glib.so.4 libdbusmenu-gtk3.so.4 libnotify.so.4 + libibus-1.0.so.5 + # Nothing imports PangoXft, but linuxdeploy keeps its typelib, and a typelib + # without its library is the trap that cost us IBus. 30 KB is cheaper than an + # exception to the rule. + libpangoxft-1.0.so.0 +) + +# Named by a bundled typelib but on the AppImage excludelist, so the host's copy +# is the right one. Anything else missing is a bug, not a policy. +HOST_PROVIDED_LIBS=( + libharfbuzz.so.0 + libharfbuzz-gobject.so.0 +) + +# On the excludelist, but something we bundle links them, so the host cannot be +# asked for them: libgcrypt is bundled and needs libgpg-error, and a host +# without it — a Fedora container, any minimal system — cannot load our GTK at +# all ("Failed to load shared library 'libgtk-3.so.0'"). +BUNDLED_DEPENDENCY_LIBS=( + libgpg-error.so.0 ) WORKDIR="$(mktemp -d)" @@ -82,43 +166,74 @@ mkdir -p "$APPDIR/usr/bin" "$APPDIR/usr/lib" "$TOOLDIR" "$OUTDIR" # Nested AppImages need extract-and-run on hosts without usable FUSE. export APPIMAGE_EXTRACT_AND_RUN="${APPIMAGE_EXTRACT_AND_RUN:-1}" -echo "== Fetching AppImage tooling ($ARCH) ==" -# Default --retry skips TLS RST (curl 35). GitHub continuous AppImages flake that way. -curl_retry() { - curl --retry 5 --retry-all-errors --retry-delay 2 --fail --silent --show-error --location "$@" -} -curl_retry -o "$TOOLDIR/linuxdeploy" \ - "https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-${ARCH}.AppImage" -curl_retry -o "$TOOLDIR/linuxdeploy-plugin-gtk.sh" \ - https://raw.githubusercontent.com/linuxdeploy/linuxdeploy-plugin-gtk/master/linuxdeploy-plugin-gtk.sh -curl_retry -o "$TOOLDIR/appimagetool" \ - "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-${ARCH}.AppImage" +# Warn rather than refuse: the script still has to run by hand when debugging. +expected_base="$(pin_field base-image 4)" +actual_base="$(. /etc/os-release 2>/dev/null && echo "$ID:$VERSION_ID")" +if [ "$actual_base" != "$expected_base" ]; then + echo "Warning: building on ${actual_base:-an unknown host}, not the pinned $expected_base." >&2 + echo " This AppImage will inherit this host's glibc and GTK. Use docker-build.sh." >&2 +fi + +echo "== Fetching pinned AppImage tooling ($ARCH) ==" +fetch_pinned "linuxdeploy-$ARCH" "$TOOLDIR/linuxdeploy" +fetch_pinned "linuxdeploy-plugin-gtk" "$TOOLDIR/linuxdeploy-plugin-gtk.sh" +fetch_pinned "appimagetool-$ARCH" "$TOOLDIR/appimagetool" chmod +x "$TOOLDIR/linuxdeploy" "$TOOLDIR/linuxdeploy-plugin-gtk.sh" "$TOOLDIR/appimagetool" -echo "== Bundling Python runtime ($PYTHON) ==" -# Use base_prefix so a venv builder still ships a full stdlib (encodings, etc.). -PY_PREFIX="$("$PYTHON" -c 'import sys; print(sys.base_prefix)')" -PY_VER="$("$PYTHON" -c 'import sys; print("%d.%d" % sys.version_info[:2])')" -PY_BIN="$("$PYTHON" -c 'import sys; print(sys.base_prefix)')/bin/python${PY_VER}" -if [ ! -x "$PY_BIN" ]; then - PY_BIN="$("$PYTHON" -c 'import sys; print(sys.base_prefix)')/bin/python3" +echo "== Fetching pinned uv ==" +# Only if it is the pinned version: uv picks the interpreter build and resolves +# the wheels, so a different uv is a different AppImage. +UV_VERSION="$(pin_field "uv-$ARCH" 4 | sed 's|.*/download/\([^/]*\)/.*|\1|')" +if command -v uv >/dev/null 2>&1 && [ "$(uv --version | awk '{print $2}')" = "$UV_VERSION" ]; then + UV="$(command -v uv)" +else + fetch_pinned "uv-$ARCH" "$TOOLDIR/uv.tar.gz" + tar xzf "$TOOLDIR/uv.tar.gz" -C "$TOOLDIR" + UV="$TOOLDIR/uv-${ARCH}-unknown-linux-gnu/uv" fi -if [ ! -x "$PY_BIN" ]; then - PY_BIN="$("$PYTHON" -c 'import sys; print(sys.executable)')" -fi -cp -L "$PY_BIN" "$APPDIR/usr/bin/python3" -cp -r "$PY_PREFIX/lib/python${PY_VER}" "$APPDIR/usr/lib/python${PY_VER}" -rm -rf "$APPDIR/usr/lib/python${PY_VER}/site-packages" +echo " uv $UV_VERSION ($UV)" + +echo "== Bundling managed CPython ==" +# python-build-standalone, not the host's interpreter: deterministic, and it +# needs glibc 2.17, so the base image alone decides the floor. +CPYTHON_VERSION="$(pin_field cpython 3)" +export UV_PYTHON_INSTALL_DIR="$WORKDIR/pyroot" +"$UV" python install "$CPYTHON_VERSION" +PY_BIN="$("$UV" python find "$CPYTHON_VERSION")" +PY_PREFIX="$(cd "$(dirname "$PY_BIN")/.." && pwd)" +PY_VER="${CPYTHON_VERSION%.*}" +echo " CPython $CPYTHON_VERSION from $PY_PREFIX" +cp -a "$PY_PREFIX/bin" "$PY_PREFIX/lib" "$APPDIR/usr/" +# Drop what nothing imports. tkinter goes with its extension module and the +# Tcl/Tk runtime: a stray _tkinter.so stops linuxdeploy dead on libtcl9.0.so. +rm -rf "$APPDIR/usr/lib/python${PY_VER}/test" \ + "$APPDIR/usr/lib/python${PY_VER}/idlelib" \ + "$APPDIR/usr/lib/python${PY_VER}/tkinter" \ + "$APPDIR/usr/lib/python${PY_VER}/lib2to3" \ + "$APPDIR/usr/lib/python${PY_VER}/lib-dynload/_tkinter"*.so \ + "$APPDIR/usr/lib/python${PY_VER}/config-${PY_VER}"* +rm -rf "$APPDIR/usr/lib"/libtcl*.so "$APPDIR/usr/lib"/tcl9* "$APPDIR/usr/lib"/tk9* \ + "$APPDIR/usr/lib"/itcl* "$APPDIR/usr/lib"/thread3* +rm -f "$APPDIR/usr/lib"/libpython*.a +find "$APPDIR/usr/lib/python${PY_VER}" -name '__pycache__' -type d -prune -exec rm -rf {} + 2>/dev/null || true +rm -rf "${APPDIR:?}/usr/lib/python${PY_VER}/site-packages"/* + +# Everything lands in the bundle prefix, built against the managed interpreter +# so the extension modules match the one that will run them. +uv_install() { + "$UV" pip install --python "$PY_BIN" --prefix "$APPDIR/usr" "$@" +} echo "== Installing Vocalinux + runtime deps into the bundle ==" -# --ignore-installed: pip otherwise treats the builder env's packages as -# satisfying deps and skips copying vosk/pywhispercpp/etc. into AppDir. -# pywhispercpp is named explicitly so this resolves to the pinned release rather -# than the newest inside the wheel's >=1.5.0,<2 range: the Vulkan rebuild below -# installs the pinned one, and two versions' libggml*.so in one AppDir leave -# linuxdeploy chasing a dependency that is not there. -"$PYTHON" -m pip install --no-cache-dir --ignore-installed --prefix "$APPDIR/usr" \ - "$WHEEL" "pywhispercpp==$PYWHISPERCPP_VERSION" PyGObject pycairo onnxruntime +# From the lock, not from whatever PyPI serves today. vad.txt is a superset of +# runtime.txt (a test keeps it that way, so this is one resolution rather than +# two); appimage.txt adds the PyGObject the exports leave out. The wheel is last +# with --no-deps: re-resolving its deps would walk back out of the lock. +uv_install --require-hashes -r "$REPO_ROOT/requirements/vad.txt" +# --no-deps: PyGObject declares pycairo, which vad.txt already installed, and +# --require-hashes would stop on a requirement appimage.txt does not carry. +uv_install --require-hashes --no-deps -r "$REPO_ROOT/requirements/appimage.txt" +uv_install --no-deps "$WHEEL" echo "== Adding desktop entry + icon ==" install -Dm644 "$REPO_ROOT/vocalinux.desktop" "$APPDIR/usr/share/applications/vocalinux.desktop" @@ -180,9 +295,10 @@ copy_typelibs() { copy_gi_runtime_libs() { local dest="$1" + shift mkdir -p "$dest" local lib found - for lib in "${GI_RUNTIME_LIBS[@]}"; do + for lib in "$@"; do found="$(find /usr/lib /usr/lib64 -name "$lib" 2>/dev/null | head -1 || true)" if [ -n "$found" ]; then cp -aL "$found" "$dest/" @@ -204,12 +320,74 @@ has_vulkan_build_deps() { else return 1 fi - command -v cmake >/dev/null 2>&1 || return 1 command -v g++ >/dev/null 2>&1 || command -v clang++ >/dev/null 2>&1 || return 1 - command -v glslc >/dev/null 2>&1 || command -v glslangValidator >/dev/null 2>&1 || return 1 return 0 } +# Pinned wheels, not the image's cmake 3.22.1 — exactly shaderc's floor. Stay on +# cmake 3.x: 4.0 rejects the `VERSION 3.4...3.18` pywhispercpp declares. +ensure_build_tools() { + if [ -n "${BUILD_TOOLS_READY:-}" ]; then + return 0 + fi + echo "== Installing pinned build tools (cmake, ninja) ==" + # A venv, not --prefix: the console scripts import their own package, which a + # prefix install leaves on no interpreter's path. + "$UV" venv --python "$PY_BIN" "$TOOLDIR/buildtools" >/dev/null + "$UV" pip install --python "$TOOLDIR/buildtools" \ + --require-hashes -r "$REPO_ROOT/requirements/appimage-tools.txt" + export PATH="$TOOLDIR/buildtools/bin:$PATH" + BUILD_TOOLS_READY=1 + echo " $(cmake --version | head -1), ninja $(ninja --version)" +} + +# ggml demands glslc (`find_package(Vulkan COMPONENTS glslc REQUIRED)`) and the +# base image packages none, so build it from the pinned shaderc commit — ~4 min +# on a runner, and CI caches it in VOCALINUX_APPIMAGE_CACHE since the commit is +# its only input. +ensure_glslc() { + if command -v glslc >/dev/null 2>&1; then + echo " glslc: $(command -v glslc)" + return 0 + fi + local commit cache_dir src + commit="$(pin_field shaderc 3)" + cache_dir="${VOCALINUX_APPIMAGE_CACHE:-${XDG_CACHE_HOME:-$HOME/.cache}/vocalinux-appimage}/glslc-${commit}-${ARCH}" + if [ ! -x "$cache_dir/glslc" ]; then + echo "== Building glslc from shaderc ${commit:0:12} (this image packages none) ==" + ensure_build_tools + src="$WORKDIR/shaderc" + checkout_pinned shaderc "$src" + # DEPS pins glslang, SPIRV-Tools and SPIRV-Headers by commit; git-sync-deps + # checks out exactly those, so the pinned commit fixes the whole tree. + (cd "$src" && ./utils/git-sync-deps) + cmake -S "$src" -B "$src/build" -G Ninja -DCMAKE_BUILD_TYPE=Release \ + -DSHADERC_SKIP_TESTS=ON -DSHADERC_SKIP_EXAMPLES=ON \ + -DSHADERC_SKIP_COPYRIGHT_CHECK=ON -DSPIRV_SKIP_TESTS=ON \ + -DSPIRV_SKIP_EXECUTABLES=ON >"$WORKDIR/shaderc-cmake.log" 2>&1 || { + tail -n 30 "$WORKDIR/shaderc-cmake.log" >&2; return 1; } + cmake --build "$src/build" --target glslc_exe \ + -j"${CMAKE_BUILD_PARALLEL_LEVEL:-$(nproc)}" >"$WORKDIR/shaderc-build.log" 2>&1 || { + tail -n 30 "$WORKDIR/shaderc-build.log" >&2; return 1; } + mkdir -p "$cache_dir" + install -m755 "$src/build/glslc/glslc" "$cache_dir/glslc" + fi + export PATH="$cache_dir:$PATH" + echo " glslc: $("$cache_dir/glslc" --version | head -1)" +} + +# The base image's headers are too old for ggml (see the pin). Only the compile +# sees these: the bundle carries no loader, the host's is used. +ensure_vulkan_headers() { + VULKAN_INCLUDE_DIR="$WORKDIR/vulkan-headers/include" + if [ ! -d "$VULKAN_INCLUDE_DIR" ]; then + echo "== Fetching pinned Vulkan headers ==" + checkout_pinned vulkan-headers "$WORKDIR/vulkan-headers" + fi + echo " $(sed -n 's/.*VK_HEADER_VERSION \([0-9]*\).*/vulkan headers 1.x.\1/p' \ + "$VULKAN_INCLUDE_DIR/vulkan/vulkan_core.h" | head -1)" +} + remove_appdir_pywhispercpp() { find "$APPDIR/usr" -depth \( \ -name 'pywhispercpp' -o \ @@ -237,7 +415,7 @@ rebuild_pywhispercpp_vulkan() { fi if ! has_vulkan_build_deps; then - echo "Vulkan build deps missing (libvulkan-dev, cmake, g++, glslc/glslangValidator, spirv-headers)." >&2 + echo "Vulkan build deps missing (libvulkan-dev plus a C++ compiler)." >&2 if [ "$require_vulkan" = "1" ]; then echo "VOCALINUX_APPIMAGE_REQUIRE_VULKAN=1: refusing to ship a CPU-only AppImage." >&2 exit 1 @@ -246,20 +424,41 @@ rebuild_pywhispercpp_vulkan() { return 0 fi + ensure_build_tools + ensure_vulkan_headers + if ! ensure_glslc; then + echo "Could not provide glslc; ggml cannot compile its Vulkan shaders." >&2 + if [ "$require_vulkan" = "1" ]; then + exit 1 + fi + echo "Warning: continuing with the CPU-only pywhispercpp wheel." >&2 + return 0 + fi + echo "== Rebuilding pywhispercpp with Vulkan ==" remove_appdir_pywhispercpp local pip_log="$WORKDIR/pywhispercpp-vulkan.log" export CMAKE_BUILD_PARALLEL_LEVEL="${CMAKE_BUILD_PARALLEL_LEVEL:-$(nproc)}" + # Reinstall the same pinned sdist the bundle already carries, hashes and all: + # a source build is still a download, and this one ends up in ctypes.cdll. + awk '/^pywhispercpp==/ {found=1; print; next} + found && /^[[:space:]]/ {print; next} + found {exit}' "$REPO_ROOT/requirements/vad.txt" > "$WORKDIR/pywhispercpp.txt" # Hosts that default cc/c++ to clang (this Cloud Agent image) fail the # CMAKE C++ compiler test: clang does not search gcc's libstdc++ path. if ! CC="${CC:-gcc}" CXX="${CXX:-g++}" \ GGML_VULKAN=1 \ - CMAKE_ARGS='-DCMAKE_INSTALL_RPATH=$ORIGIN -DCMAKE_BUILD_WITH_INSTALL_RPATH=ON' \ - "$PYTHON" -m pip install --verbose --no-cache-dir --force-reinstall \ - --no-binary pywhispercpp --ignore-installed --prefix "$APPDIR/usr" \ - --log "$pip_log" "pywhispercpp==$PYWHISPERCPP_VERSION"; then - echo "pywhispercpp Vulkan rebuild failed. Last 80 log lines:" >&2 - tail -n 80 "$pip_log" 2>/dev/null | sed 's/^/ /' >&2 || true + CMAKE_ARGS="-DCMAKE_INSTALL_RPATH=\$ORIGIN -DCMAKE_BUILD_WITH_INSTALL_RPATH=ON -DVulkan_INCLUDE_DIR=$VULKAN_INCLUDE_DIR" \ + uv_install --verbose --require-hashes --no-deps \ + --reinstall-package pywhispercpp --no-binary pywhispercpp \ + -r "$WORKDIR/pywhispercpp.txt" >"$pip_log" 2>&1; then + # The tail is the Python traceback wrapping the build and says nothing about + # the compile, so lead with the compiler's own errors. + echo "pywhispercpp Vulkan rebuild failed." >&2 + grep -E "error:|FAILED:|CMake Error" "$pip_log" 2>/dev/null \ + | head -n 20 | sed 's/^/ /' >&2 || true + echo " --- last 40 log lines ---" >&2 + tail -n 40 "$pip_log" 2>/dev/null | sed 's/^/ /' >&2 || true if [ "$require_vulkan" = "1" ]; then exit 1 fi @@ -286,7 +485,8 @@ echo "== Copying GObject-Introspection typelibs (not handled by linuxdeploy-plug copy_typelibs "$APPDIR/usr/lib/girepository-1.0" 0 echo "== Copying GI runtime shared libraries (AppIndicator/Notify) ==" -copy_gi_runtime_libs "$APPDIR/usr/lib" +copy_gi_runtime_libs "$APPDIR/usr/lib" \ + "${GI_RUNTIME_LIBS[@]}" "${BUNDLED_DEPENDENCY_LIBS[@]}" echo "== Writing AppRun ==" cat > "$APPDIR/AppRun" << 'APPRUN' @@ -319,13 +519,70 @@ export DEPLOY_GTK_VERSION=3 echo "== Ensuring required typelibs are present (keeping linuxdeploy extras) ==" copy_typelibs "$APPDIR/usr/lib/girepository-1.0" 1 +# GI loads a typelib's library by name, so a typelib bundled without it pulls in +# the host's copy against the bundled GLib. IBus shipped that way and died on +# hosts with GLib 2.76+ (`undefined symbol: g_task_set_static_name`). The build +# host cannot notice — its libraries match what it bundled. +echo "== Checking every bundled typelib has its library ==" +verify_typelib_libraries() { + "$APPDIR/usr/bin/python3" - "$APPDIR" "${HOST_PROVIDED_LIBS[*]}" <<'PY' +import pathlib +import struct +import sys + +appdir = pathlib.Path(sys.argv[1]) +host_provided = set(sys.argv[2].split()) +typelib_dir = appdir / "usr" / "lib" / "girepository-1.0" +lib_dir = appdir / "usr" / "lib" + +# Typelib header layout: 16-byte magic, then fixed fields; shared_library is a +# uint32 offset at byte 52 into a comma-separated, NUL-terminated string. +MAGIC = b"GOBJ\nMETADATA\r\n\x1a" +SHARED_LIBRARY_OFFSET = 52 + + +def shared_libraries(path): + blob = path.read_bytes() + if not blob.startswith(MAGIC): + return [] + offset = struct.unpack_from("/dev/null || true +fi diff --git a/packaging/appimage/docker-build.sh b/packaging/appimage/docker-build.sh new file mode 100755 index 0000000..f8602d7 --- /dev/null +++ b/packaging/appimage/docker-build.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# Run the AppImage build inside the pinned base image. +# +# Usage: docker-build.sh [output-dir] +# +# This is what CI calls and what you should call locally, so that what you get +# is what CI gets: the base image decides the glibc floor and the GTK version +# the AppImage links against, and building on the host instead means shipping +# whatever that host happens to run. +# +# Honours VOCALINUX_APPIMAGE_REQUIRE_VULKAN / _SKIP_VULKAN like build.sh, and +# keeps the glslc it has to build in VOCALINUX_APPIMAGE_CACHE +# (~/.cache/vocalinux-appimage by default) so the next build reuses it. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/../.." && pwd)" +IMAGE="$(awk '$1=="base-image" {print $3}' "$REPO_ROOT/packaging/appimage/tool_checksums.txt")" +if [ -z "$IMAGE" ]; then + echo "No base-image pin in packaging/appimage/tool_checksums.txt" >&2 + exit 1 +fi + +CACHE="${VOCALINUX_APPIMAGE_CACHE:-${XDG_CACHE_HOME:-$HOME/.cache}/vocalinux-appimage}" +mkdir -p "$CACHE" + +# The wheel and output dir are given as host paths; the repo is mounted at the +# same place it lives, so relative arguments keep working inside. +# seccomp=unconfined only to allow unshare: without it build.sh's isolated GI +# smoke test silently skips, which is how #585 shipped. +exec docker run --rm \ + --security-opt seccomp=unconfined \ + -v "$REPO_ROOT:$REPO_ROOT" -w "$REPO_ROOT" \ + -v "$CACHE:/cache" \ + -e VOCALINUX_APPIMAGE_CACHE=/cache \ + -e VOCALINUX_APPIMAGE_REQUIRE_VULKAN \ + -e VOCALINUX_APPIMAGE_SKIP_VULKAN \ + -e CMAKE_BUILD_PARALLEL_LEVEL \ + -e HOST_UID="$(id -u)" \ + -e HOST_GID="$(id -g)" \ + "$IMAGE" bash packaging/appimage/container-build.sh "$@" diff --git a/packaging/appimage/tool_checksums.txt b/packaging/appimage/tool_checksums.txt new file mode 100644 index 0000000..49b01f5 --- /dev/null +++ b/packaging/appimage/tool_checksums.txt @@ -0,0 +1,47 @@ +# Pinned build inputs for packaging/appimage/build.sh. +# +# Everything the AppImage build downloads is pinned and verified here. Before +# this file the build pulled linuxdeploy and appimagetool from their +# `continuous` tags and the GTK plugin from `master`, so two builds of the same +# commit could differ, and nothing would notice a replaced asset. +# +# Columns: name, kind, value, source — parseable by awk, like +# src/vocalinux/utils/model_checksums.txt. +# +# sha256 the file downloaded from must hash to +# git clone , check out commit (a commit is its own digest) +# docker build inside image ; records the tag it came from +# version handed to uv, which pins and verifies the download itself +# +# To bump one: change the version in its URL, download it, and replace +# with `sha256sum` of what you got. The base image digest comes from +# `docker buildx imagetools inspect ubuntu:22.04` (use the index digest, not a +# per-architecture one — the same line has to work on x86_64 and aarch64). +# +# The base image is old on purpose: the AppImage inherits the glibc of whatever +# built it, and Ubuntu 22.04's 2.35 is below Debian 12, Fedora, Arch and +# Tumbleweed. Raising it silently drops distros. See docs/INSTALL.md. + +base-image docker docker.io/library/ubuntu@sha256:2edbbc5dc405e9612ba3584ce95480277e3eb374407b5505fe26f17df77c7dbc ubuntu:22.04 +cpython version 3.12.14 python-build-standalone, installed by uv + +uv-x86_64 sha256 68a509da24b06b4223a1c0175fb5eb5bc79342b76cbeff0cfe51ac3f5b17b6b2 https://github.com/astral-sh/uv/releases/download/0.12.5/uv-x86_64-unknown-linux-gnu.tar.gz +uv-aarch64 sha256 9bf43b4d1a07665bf64d4c4e710930b382321a785e0eb10aac07f46471f86a31 https://github.com/astral-sh/uv/releases/download/0.12.5/uv-aarch64-unknown-linux-gnu.tar.gz + +linuxdeploy-x86_64 sha256 c20cd71e3a4e3b80c3483cef793cda3f4e990aca14014d23c544ca3ce1270b4d https://github.com/linuxdeploy/linuxdeploy/releases/download/1-alpha-20251107-1/linuxdeploy-x86_64.AppImage +linuxdeploy-aarch64 sha256 620095110d693282b8ebeb244a95b5e911cf8f65f76c88b4b47d16ae6346fcff https://github.com/linuxdeploy/linuxdeploy/releases/download/1-alpha-20251107-1/linuxdeploy-aarch64.AppImage +appimagetool-x86_64 sha256 ed4ce84f0d9caff66f50bcca6ff6f35aae54ce8135408b3fa33abfc3cb384eb0 https://github.com/AppImage/appimagetool/releases/download/1.9.1/appimagetool-x86_64.AppImage +appimagetool-aarch64 sha256 f0837e7448a0c1e4e650a93bb3e85802546e60654ef287576f46c71c126a9158 https://github.com/AppImage/appimagetool/releases/download/1.9.1/appimagetool-aarch64.AppImage +linuxdeploy-plugin-gtk sha256 b0f4cbc684a0103a9651f0955b635eaea0096b3a66c0f5a2c2aa337960375171 https://raw.githubusercontent.com/linuxdeploy/linuxdeploy-plugin-gtk/7a3fbc31a9e5075073ff8790f26effbac5f84453/linuxdeploy-plugin-gtk.sh + +# ggml compiles its Vulkan shaders with glslc, which Ubuntu 22.04 does not +# package at all (24.04 does). Built from source when the build host has none; +# see ensure_glslc in build.sh. +shaderc git 2c8cae778eec0283b44acbe7ed1a386865d78799 https://github.com/google/shaderc.git + +# The base image's Vulkan headers are 1.3.204, from February 2022. ggml wants +# VK_KHR_cooperative_matrix, VK_KHR_shader_bfloat16 and VK_NV_cooperative_matrix2, +# so it does not compile against them. Headers decide only what the build sees: +# at run time the AppImage uses the host's loader and driver, and that ABI is +# stable. Tag vulkan-sdk-1.4.357.0. +vulkan-headers git e3b1eec08173d6b825cd3ac88c885a63b621504a https://github.com/KhronosGroup/Vulkan-Headers.git diff --git a/requirements/appimage-tools.in b/requirements/appimage-tools.in new file mode 100644 index 0000000..e0d6c5e --- /dev/null +++ b/requirements/appimage-tools.in @@ -0,0 +1,10 @@ +# Build tools for packaging/appimage/build.sh — installed on the build host, +# never into the AppImage. +# +# Taken from PyPI rather than the base image so the toolchain is pinned instead +# of inherited: the base image's cmake is 3.22.1, exactly shaderc's floor and +# below what newer ggml releases ask for. Stay on the 3.x line — CMake 4 removed +# compatibility with the `cmake_minimum_required(VERSION 3.4...3.18)` that +# pywhispercpp still declares. +cmake==3.31.10 +ninja==1.13.0 diff --git a/requirements/appimage-tools.txt b/requirements/appimage-tools.txt new file mode 100644 index 0000000..8db6ee9 --- /dev/null +++ b/requirements/appimage-tools.txt @@ -0,0 +1,44 @@ +# This file was autogenerated by uv via the following command: +# uv pip compile requirements/appimage-tools.in --universal --no-deps --generate-hashes -o requirements/appimage-tools.txt +cmake==3.31.10 \ + --hash=sha256:1debc3a0823ce5d8d1bc17154599bbbb337c2681f93622b618bc78f46576e42a \ + --hash=sha256:2f766bb46367e5e0559fa33184653754bce044583a06014dcaebf8e6dff8a1f1 \ + --hash=sha256:3c17bb24dba15f8ecc3fd706afe04264410ef88796f4115c119327c961d5dc57 \ + --hash=sha256:422a54711aa977af19d59b8f6010354cdda0b72a2e6d702b6d892e3e2cdf98a2 \ + --hash=sha256:4cefb0a28ac1268b4eed4b595bf3aaff8de9704089066027700ec36584eccab8 \ + --hash=sha256:678fc23db37cc69f01e18eb28790450ecc9401fd2fcd43364cc18f92330c12c2 \ + --hash=sha256:6970bb75c4dfc28cc31ff0cd848194d09094ae00d605181e1345b2ff70b61050 \ + --hash=sha256:7c300e4ae68fbc1414a85505f7feb262cee82ff3304a286885ebf803b11a997c \ + --hash=sha256:8d2ec5fc45d305227020c82213140a51a0cebe3c84f0299036f05716b3a52f60 \ + --hash=sha256:91410816db3beefe2f6032d721f9978c98dc7646e9992c0325486597164fab81 \ + --hash=sha256:a4a5615f31f692c9b9aa8b365704e4b76172348af6fa40e16fea3f118bb01194 \ + --hash=sha256:a7864605238759a4ae8e3bd1fda2bb03978e3e37df310852662dcf53866413c8 \ + --hash=sha256:ad697643a00d9ba85179590a383c4f7401169b55ebf4b8b2938daf28c6bdeb6d \ + --hash=sha256:b059a1810a2ce766b3e531bdc8d730bc192e260a9fa7dec7a0eb7a053d6063c7 \ + --hash=sha256:b331984de38dbda22d676f8812c8905526341ba7b397fe8c359255ff4d051193 \ + --hash=sha256:c2e5361dea9754ed3b06cf834894fb47dcbe7036d5e5d87acaeb10ff3dd5fd10 \ + --hash=sha256:e135d5f1e59f1dc1c80eea87321977d6c0eff86cb601c28e0965a1dd457ea587 \ + --hash=sha256:ec3d14a0e72e401b3665034dc37901df17f0b4e9c5b163be6cfedfb93470ac0f \ + --hash=sha256:f1ea1fe826355560e8976c3d5794d9357444209bc0e0d56676c71e6a571fd474 + # via -r requirements/appimage-tools.in +ninja==1.13.0 \ + --hash=sha256:11be2d22027bde06f14c343f01d31446747dbb51e72d00decca2eb99be911e2f \ + --hash=sha256:1c97223cdda0417f414bf864cfb73b72d8777e57ebb279c5f6de368de0062988 \ + --hash=sha256:3c0b40b1f0bba764644385319028650087b4c1b18cdfa6f45cb39a3669b81aa9 \ + --hash=sha256:3d00c692fb717fd511abeb44b8c5d00340c36938c12d6538ba989fe764e79630 \ + --hash=sha256:3d7d7779d12cb20c6d054c61b702139fd23a7a964ec8f2c823f1ab1b084150db \ + --hash=sha256:4a40ce995ded54d9dc24f8ea37ff3bf62ad192b547f6c7126e7e25045e76f978 \ + --hash=sha256:4be9c1b082d244b1ad7ef41eb8ab088aae8c109a9f3f0b3e56a252d3e00f42c1 \ + --hash=sha256:5f8e1e8a1a30835eeb51db05cf5a67151ad37542f5a4af2a438e9490915e5b72 \ + --hash=sha256:60056592cf495e9a6a4bea3cd178903056ecb0943e4de45a2ea825edb6dc8d3e \ + --hash=sha256:6739d3352073341ad284246f81339a384eec091d9851a886dfa5b00a6d48b3e2 \ + --hash=sha256:8cfbb80b4a53456ae8a39f90ae3d7a2129f45ea164f43fadfa15dc38c4aef1c9 \ + --hash=sha256:aa45b4037b313c2f698bc13306239b8b93b4680eb47e287773156ac9e9304714 \ + --hash=sha256:b4f2a072db3c0f944c32793e91532d8948d20d9ab83da9c0c7c15b5768072200 \ + --hash=sha256:be7f478ff9f96a128b599a964fc60a6a87b9fa332ee1bd44fa243ac88d50291c \ + --hash=sha256:d741a5e6754e0bda767e3274a0f0deeef4807f1fec6c0d7921a0244018926ae5 \ + --hash=sha256:e8bad11f8a00b64137e9b315b137d8bb6cbf3086fbdc43bf1f90fd33324d2e96 \ + --hash=sha256:fa2a8bfc62e31b08f83127d1613d10821775a0eb334197154c4d6067b7068ff1 \ + --hash=sha256:fb46acf6b93b8dd0322adc3a4945452a4e774b75b91293bafcc7b7f8e6517dfa \ + --hash=sha256:fb8ee8719f8af47fed145cced4a85f0755dd55d45b2bddaf7431fa89803c5f3e + # via -r requirements/appimage-tools.in diff --git a/requirements/appimage.in b/requirements/appimage.in new file mode 100644 index 0000000..abfbff9 --- /dev/null +++ b/requirements/appimage.in @@ -0,0 +1,10 @@ +# Python packages bundled into the AppImage on top of requirements/vad.txt. +# +# PyGObject is excluded from the lock exports (--no-emit-package pygobject) +# because every other install path takes it from the distro. The AppImage has no +# distro to take it from: it bundles a managed CPython, so it builds PyGObject +# against the base image's girepository, which caps it at the 3.50 line — +# uv.lock's 3.56 needs girepository-2.0 (glib 2.80+), and the base image the +# AppImage builds in ships glib 2.72. Compiled with --no-deps: pycairo is +# already pinned in requirements/runtime.txt and must not be resolved twice. +pygobject==3.50.2 diff --git a/requirements/appimage.txt b/requirements/appimage.txt new file mode 100644 index 0000000..07748a0 --- /dev/null +++ b/requirements/appimage.txt @@ -0,0 +1,5 @@ +# This file was autogenerated by uv via the following command: +# uv pip compile requirements/appimage.in --universal --no-deps --generate-hashes -o requirements/appimage.txt +pygobject==3.50.2 \ + --hash=sha256:ece6b860aab77cb649fdfc6e88d8a83765e7a62f7ffd39a628d6e2a0d397a7ff + # via -r requirements/appimage.in diff --git a/src/vocalinux/speech_recognition/recognition_manager.py b/src/vocalinux/speech_recognition/recognition_manager.py index 4a3ecb0..f098f0d 100644 --- a/src/vocalinux/speech_recognition/recognition_manager.py +++ b/src/vocalinux/speech_recognition/recognition_manager.py @@ -21,6 +21,7 @@ from typing import Callable, Optional from ..common_types import RecognitionState from ..ui.audio_feedback import play_error_sound, play_start_sound, play_stop_sound +from ..utils.host_process import host_env from ..utils.model_checksums import ( ChecksumError, expected_for, @@ -843,6 +844,7 @@ def _show_notification(title: str, message: str, icon: str = "dialog-warning"): ["notify-send", "-i", icon, "-a", "Vocalinux", title, message], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + env=host_env(), ) except (FileNotFoundError, OSError) as e: logger.debug(f"Could not show notification: {e}") diff --git a/src/vocalinux/text_injection/focused_window.py b/src/vocalinux/text_injection/focused_window.py index 641ad18..b93761d 100644 --- a/src/vocalinux/text_injection/focused_window.py +++ b/src/vocalinux/text_injection/focused_window.py @@ -17,6 +17,8 @@ import subprocess from dataclasses import dataclass from typing import Any, Optional +from ..utils.host_process import host_env + logger = logging.getLogger(__name__) _PROBE_TIMEOUT = 1.0 @@ -170,7 +172,7 @@ def _run_text(cmd: list[str], env: Optional[dict[str, str]] = None) -> str: text=True, timeout=_PROBE_TIMEOUT, check=False, - env=env, + env=host_env(env), ) except (OSError, subprocess.SubprocessError): return "" diff --git a/src/vocalinux/text_injection/ibus_engine.py b/src/vocalinux/text_injection/ibus_engine.py index 8c34363..6b918ff 100644 --- a/src/vocalinux/text_injection/ibus_engine.py +++ b/src/vocalinux/text_injection/ibus_engine.py @@ -30,12 +30,18 @@ from typing import Callable, Optional # Package import when loaded normally; absolute / inline fallbacks when this # file is executed by path (Vocalinux start_engine_process / IBus component exec). try: + from ..utils.host_process import host_env from ..utils.paths import xdg_data_home except ImportError: # pragma: no cover - exercised via script-path subprocess try: + from vocalinux.utils.host_process import host_env from vocalinux.utils.paths import xdg_data_home except ImportError: - # Last resort: same semantics as vocalinux.utils.paths.xdg_data_home. + # Last resort: same semantics as the vocalinux.utils originals, except + # that host_env cannot strip a bundle it can no longer import. + def host_env(base=None): + return dict(os.environ if base is None else base) + def xdg_data_home() -> str: return os.environ.get("XDG_DATA_HOME") or os.path.expanduser("~/.local/share") @@ -157,9 +163,7 @@ def is_ibus_daemon_running() -> bool: """ try: result = subprocess.run( - ["pgrep", "-x", "ibus-daemon"], - capture_output=True, - timeout=2, + ["pgrep", "-x", "ibus-daemon"], capture_output=True, timeout=2, env=host_env() ) return result.returncode == 0 except (subprocess.SubprocessError, FileNotFoundError): @@ -299,6 +303,7 @@ def start_ibus_daemon(): stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True, + env=host_env(), ) # Give daemon time to start time.sleep(0.5) @@ -322,10 +327,7 @@ def is_engine_active() -> bool: import subprocess result = subprocess.run( - ["ibus", "engine"], - capture_output=True, - text=True, - timeout=5, + ["ibus", "engine"], capture_output=True, text=True, timeout=5, env=host_env() ) return result.returncode == 0 and ENGINE_NAME in result.stdout.strip() except (subprocess.SubprocessError, FileNotFoundError): @@ -349,6 +351,7 @@ def _get_gnome_current_source() -> Optional[tuple[str, str]]: capture_output=True, text=True, timeout=5, + env=host_env(), ) if result.returncode != 0: logger.debug("gsettings get mru-sources failed; not using GNOME fallback") @@ -391,6 +394,7 @@ def _resolve_registered_xkb_engine(source_id: str) -> Optional[str]: capture_output=True, text=True, timeout=5, + env=host_env(), ) if result.returncode != 0: logger.debug("ibus list-engine failed; cannot verify the GNOME XKB source") @@ -461,10 +465,7 @@ def get_current_engine() -> Optional[str]: """ try: result = subprocess.run( - ["ibus", "engine"], - capture_output=True, - text=True, - timeout=5, + ["ibus", "engine"], capture_output=True, text=True, timeout=5, env=host_env() ) # Check for "No global engine" in stderr (ibus-daemon has no @@ -544,10 +545,7 @@ def get_current_xkb_layout() -> tuple: try: result = subprocess.run( - ["setxkbmap", "-query"], - capture_output=True, - text=True, - timeout=2, + ["setxkbmap", "-query"], capture_output=True, text=True, timeout=2, env=host_env() ) if result.returncode == 0: layout, variant, option = "us", "", "" @@ -606,12 +604,7 @@ def restore_xkb_layout(layout: str, variant: str = "", option: str = "") -> bool cmd = ["setxkbmap", "-option", ""] + cmd[1:] cmd.extend(["-option", option]) - result = subprocess.run( - cmd, - capture_output=True, - text=True, - timeout=2, - ) + result = subprocess.run(cmd, capture_output=True, text=True, timeout=2, env=host_env()) if result.returncode == 0: logger.info(f"Restored XKB layout: {layout} (variant: {variant}, option: {option})") return True @@ -669,6 +662,7 @@ def sync_xwayland_layout_from_gnome() -> bool: capture_output=True, text=True, timeout=2, + env=host_env(), ) if result.returncode == 0: logger.debug(f"Synced XWayland layout to GNOME source '{source_id}' (see #738)") @@ -764,6 +758,7 @@ def switch_engine(engine_name: str) -> bool: capture_output=True, text=True, timeout=5, + env=host_env(), ) # ibus engine command may return non-zero even on success # So we verify by checking the current engine @@ -821,8 +816,9 @@ def start_engine_process() -> bool: logger.info(f"Starting IBus engine process: {engine_script}") try: - # Start the engine process using the same Python interpreter - # and inherit the current environment (for venv compatibility) + # Same interpreter, environment untouched: this child is ours, and in an + # AppImage host_env() would strip the very paths it needs to find its + # stdlib, packages and GI stack. env = os.environ.copy() # Ensure PYTHONPATH includes current site-packages if in a venv if hasattr(sys, "prefix") and sys.prefix != sys.base_prefix: diff --git a/src/vocalinux/text_injection/text_injector.py b/src/vocalinux/text_injection/text_injector.py index 16a0f7f..b27b256 100644 --- a/src/vocalinux/text_injection/text_injector.py +++ b/src/vocalinux/text_injection/text_injector.py @@ -16,6 +16,7 @@ import time from enum import Enum from typing import Optional # noqa: F401 +from ..utils.host_process import host_env from ..utils.paths import config_dir from .focused_window import is_focused_window_terminal from .ibus_engine import ( @@ -183,11 +184,7 @@ class TextInjector: def _probe_wtype_support(self) -> subprocess.CompletedProcess: """Probe wtype support without typing visible text.""" return subprocess.run( - ["wtype", ""], - stderr=subprocess.PIPE, - text=True, - check=False, - timeout=2, + ["wtype", ""], stderr=subprocess.PIPE, text=True, check=False, timeout=2, env=host_env() ) def _detect_environment(self) -> DesktopEnvironment: @@ -269,6 +266,7 @@ class TextInjector: capture_output=True, text=True, timeout=2, + env=host_env(), ) except (subprocess.SubprocessError, FileNotFoundError) as e: logger.info( @@ -311,9 +309,7 @@ class TextInjector: """ try: result = subprocess.run( - ["pgrep", "-x", "ibus-wayland"], - capture_output=True, - timeout=2, + ["pgrep", "-x", "ibus-wayland"], capture_output=True, timeout=2, env=host_env() ) return result.returncode == 0 except (subprocess.SubprocessError, FileNotFoundError): @@ -452,6 +448,7 @@ class TextInjector: stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True, + env=host_env(), ) except OSError as e: logger.warning(f"Could not start ydotoold: {e}") @@ -697,6 +694,7 @@ class TextInjector: stderr=subprocess.DEVNULL, text=True, timeout=self._clipboard_timeout, + env=host_env(), ) return True @@ -709,6 +707,7 @@ class TextInjector: stderr=subprocess.DEVNULL, text=True, timeout=self._clipboard_timeout, + env=host_env(), ) return True @@ -721,6 +720,7 @@ class TextInjector: stderr=subprocess.DEVNULL, text=True, timeout=self._clipboard_timeout, + env=host_env(), ) return True @@ -750,6 +750,7 @@ class TextInjector: check=True, stderr=subprocess.PIPE, timeout=2, + env=host_env(), ) with self._state_lock: self.wayland_tool = "ydotool" @@ -798,7 +799,7 @@ class TextInjector: # Check if we can get active window (less intrusive test) window_id = subprocess.run( ["xdotool", "getwindowfocus"], - env=test_env, + env=host_env(test_env), stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, @@ -837,6 +838,7 @@ class TextInjector: check=True, stderr=subprocess.PIPE, timeout=2, + env=host_env(), ) self.wayland_tool = "ydotool" self.environment = DesktopEnvironment.WAYLAND @@ -922,6 +924,7 @@ class TextInjector: stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=self._clipboard_timeout, + env=host_env(), ) return True if tool == "xsel": @@ -931,6 +934,7 @@ class TextInjector: stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=self._clipboard_timeout, + env=host_env(), ) return True if tool == "xclip": @@ -942,6 +946,7 @@ class TextInjector: stderr=subprocess.DEVNULL, text=True, timeout=self._clipboard_timeout, + env=host_env(), ) return True except ( @@ -981,6 +986,7 @@ class TextInjector: ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + env=host_env(), ) except Exception as e: logger.debug(f"Could not show clipboard notification: {e}") @@ -1144,7 +1150,7 @@ class TextInjector: # Get current active window active_window = subprocess.run( ["xdotool", "getactivewindow"], - env=env, + env=host_env(env), stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, @@ -1156,7 +1162,7 @@ class TextInjector: # Focus explicitly on that window subprocess.run( ["xdotool", "windowactivate", "--sync", window_id], - env=env, + env=host_env(env), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False, @@ -1190,7 +1196,7 @@ class TextInjector: subprocess.run( cmd, - env=env, + env=host_env(env), check=True, stderr=subprocess.PIPE, text=True, @@ -1242,7 +1248,7 @@ class TextInjector: "Super_L", "Super_R", ], - env=env, + env=host_env(env), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False, @@ -1297,6 +1303,7 @@ class TextInjector: text=True, encoding="utf-8", errors="replace", + env=host_env(), ) if result.returncode == 0: return result.stdout @@ -1394,11 +1401,7 @@ class TextInjector: cmd, ) subprocess.run( - cmd, - check=True, - stderr=subprocess.PIPE, - text=True, - timeout=3, + cmd, check=True, stderr=subprocess.PIPE, text=True, timeout=3, env=host_env() ) logger.info(f"Text injected via clipboard paste: '{text[:20]}...' ({len(text)} chars)") except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e: @@ -1473,6 +1476,7 @@ class TextInjector: capture_output=True, text=True, timeout=2, + env=host_env(), ) help_text = f"{result.stdout or ''}{result.stderr or ''}" except (OSError, subprocess.SubprocessError) as e: @@ -1651,7 +1655,7 @@ class TextInjector: cmd = ["ydotool", "type", "--key-delay", key_delay, text] try: - subprocess.run(cmd, check=True, stderr=subprocess.PIPE, text=True) + subprocess.run(cmd, check=True, stderr=subprocess.PIPE, text=True, env=host_env()) except subprocess.CalledProcessError as e: # Re-raise with stderr preserved for better diagnostics raise subprocess.CalledProcessError( @@ -1707,7 +1711,7 @@ class TextInjector: try: cmd = ["xdotool", "key", "--clearmodifiers", shortcut] - subprocess.run(cmd, env=env, check=True, stderr=subprocess.PIPE, text=True) + subprocess.run(cmd, env=host_env(env), check=True, stderr=subprocess.PIPE, text=True) logger.debug(f"Keyboard shortcut '{shortcut}' injected successfully") return True except subprocess.CalledProcessError as e: @@ -1731,7 +1735,7 @@ class TextInjector: elif self.wayland_tool == "ydotool": try: cmd = ["ydotool", "key", shortcut] - subprocess.run(cmd, check=True, stderr=subprocess.PIPE, text=True) + subprocess.run(cmd, check=True, stderr=subprocess.PIPE, text=True, env=host_env()) logger.debug(f"Keyboard shortcut '{shortcut}' injected successfully") return True except subprocess.CalledProcessError as e: @@ -1768,7 +1772,7 @@ class TextInjector: # Get active window ID result = subprocess.run( ["xdotool", "getactivewindow"], - env=env, + env=host_env(env), stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, @@ -1781,7 +1785,7 @@ class TextInjector: # Get window name result = subprocess.run( ["xdotool", "getwindowname", window_id], - env=env, + env=host_env(env), stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, @@ -1794,7 +1798,7 @@ class TextInjector: # Get window class result = subprocess.run( ["xdotool", "getwindowclassname", window_id], - env=env, + env=host_env(env), stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, @@ -1807,7 +1811,7 @@ class TextInjector: # Get window PID result = subprocess.run( ["xdotool", "getwindowpid", window_id], - env=env, + env=host_env(env), stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, diff --git a/src/vocalinux/ui/audio_feedback.py b/src/vocalinux/ui/audio_feedback.py index 230957b..f7f1f29 100644 --- a/src/vocalinux/ui/audio_feedback.py +++ b/src/vocalinux/ui/audio_feedback.py @@ -45,6 +45,8 @@ def _is_ci_mode(): return not running_pytest +from ..utils.host_process import host_env + # Import the centralized resource manager from ..utils.resource_manager import ResourceManager # noqa: E402 @@ -162,24 +164,28 @@ def _play_sound_file(sound_path): [player, sound_path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + env=host_env(), ) elif player == "aplay": subprocess.Popen( [player, "-q", sound_path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + env=host_env(), ) elif player == "mplayer": subprocess.Popen( [player, "-really-quiet", sound_path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + env=host_env(), ) elif player == "play": subprocess.Popen( [player, "-q", sound_path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + env=host_env(), ) elif player == "ci_test_player": # This is a placeholder for CI tests - the subprocess call will be mocked @@ -187,6 +193,7 @@ def _play_sound_file(sound_path): ["ci_test_player", sound_path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + env=host_env(), ) return True except Exception as e: diff --git a/src/vocalinux/ui/keyboard_backends/layout_key_map.py b/src/vocalinux/ui/keyboard_backends/layout_key_map.py index 21ced3d..b000cc0 100644 --- a/src/vocalinux/ui/keyboard_backends/layout_key_map.py +++ b/src/vocalinux/ui/keyboard_backends/layout_key_map.py @@ -14,6 +14,8 @@ from ctypes import CDLL, POINTER, Structure, byref, c_char_p, c_int, c_uint32, c from functools import lru_cache from typing import Optional +from ...utils.host_process import host_env + logger = logging.getLogger(__name__) _XKB_EVDEV_OFFSET = 8 @@ -129,6 +131,7 @@ def _detect_gnome_layout() -> tuple[str, str]: text=True, timeout=2, check=False, + env=host_env(), ) if result.returncode != 0 or not result.stdout.strip(): continue diff --git a/src/vocalinux/ui/notifications.py b/src/vocalinux/ui/notifications.py index c986860..4de910f 100644 --- a/src/vocalinux/ui/notifications.py +++ b/src/vocalinux/ui/notifications.py @@ -10,6 +10,8 @@ import logging import subprocess from typing import Callable, Optional +from ..utils.host_process import host_env + logger = logging.getLogger(__name__) APP_NAME = "Vocalinux" @@ -67,6 +69,7 @@ def _notify_send(title: str, message: str, icon: str) -> None: ["notify-send", "-i", icon, "-a", APP_NAME, title, message], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + env=host_env(), ) except (FileNotFoundError, OSError) as e: logger.debug(f"Could not show notification: {e}") diff --git a/src/vocalinux/ui/tray_indicator.py b/src/vocalinux/ui/tray_indicator.py index f412b2a..d506172 100644 --- a/src/vocalinux/ui/tray_indicator.py +++ b/src/vocalinux/ui/tray_indicator.py @@ -39,6 +39,7 @@ from ..auto_pause_monitor import DEFAULT_POLL_INTERVAL_SECONDS, AutoPauseMonitor from ..common_types import RecognitionState, SpeechRecognitionManagerProtocol, TextInjectorProtocol from ..model_keepalive import DEFAULT_IDLE_TIMEOUT_SECONDS, ModelKeepAlive from ..suspend_handler import SuspendHandler +from ..utils.host_process import host_env from ..utils.resource_manager import ResourceManager from ..utils.update_checker import ReleaseInfo from ..utils.update_monitor import UpdateMonitor @@ -857,6 +858,7 @@ class TrayIndicator: ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + env=host_env(), ) except (FileNotFoundError, OSError) as exc: logger.debug("Could not show update notification: %s", exc) diff --git a/src/vocalinux/utils/host_process.py b/src/vocalinux/utils/host_process.py new file mode 100644 index 0000000..fc05fd8 --- /dev/null +++ b/src/vocalinux/utils/host_process.py @@ -0,0 +1,52 @@ +"""Environment for the host binaries Vocalinux shells out to. + +An AppImage puts its own libraries first on ``LD_LIBRARY_PATH``, and every child +process inherits that. A host tool linked against a newer GLib than the bundle +carries then dies before it does anything: ``ibus`` exits with ``undefined +symbol: g_free_sized``, the engine never switches, and dictated text goes +nowhere. The same applies to ``GI_TYPELIB_PATH``, ``PYTHONHOME`` and the GTK +module paths the AppImage exports. + +Almost nothing we spawn is ours, so strip the bundle out first. The exception is +the IBus engine -- this same interpreter re-executed -- which needs the bundle, so +``start_engine_process`` hands it the environment untouched. +""" + +import os +from typing import Dict, Mapping, Optional + +#: Kept so a child can still tell it came from a bundle. Everything else that +#: points inside it is removed. +_KEEP = ("APPDIR", "APPIMAGE") + + +def _points_into(value: str, appdir: str) -> bool: + if not value: + return False + return value == appdir or value.startswith(appdir + os.sep) + + +def host_env(base: Optional[Mapping[str, str]] = None) -> Dict[str, str]: + """Return ``base`` (default ``os.environ``) with bundle paths removed. + + Outside a bundle there is nothing to strip and the environment is returned + as-is, so callers can use this unconditionally. + """ + env = dict(os.environ if base is None else base) + appdir = env.get("APPDIR") + if not appdir: + return env + appdir = os.path.realpath(appdir) + + for name, value in list(env.items()): + if name in _KEEP or not value: + continue + entries = value.split(os.pathsep) + kept = [entry for entry in entries if not _points_into(os.path.realpath(entry), appdir)] + if len(kept) == len(entries): + continue + if kept: + env[name] = os.pathsep.join(kept) + else: + del env[name] + return env diff --git a/src/vocalinux/utils/whispercpp_model_info.py b/src/vocalinux/utils/whispercpp_model_info.py index da0e3e6..223dc4d 100644 --- a/src/vocalinux/utils/whispercpp_model_info.py +++ b/src/vocalinux/utils/whispercpp_model_info.py @@ -12,6 +12,7 @@ import subprocess from functools import lru_cache from typing import Optional +from .host_process import host_env from .model_checksums import whispercpp_revision from .paths import is_within_directory, models_dir @@ -225,12 +226,7 @@ def _run_vulkaninfo_stdout() -> str: commands = (["vulkaninfo", "--summary"], ["vulkaninfo"]) for args in commands: try: - result = subprocess.run( - args, - capture_output=True, - text=True, - timeout=8, - ) + result = subprocess.run(args, capture_output=True, text=True, timeout=8, env=host_env()) except FileNotFoundError: return "" except (subprocess.TimeoutExpired, OSError) as exc: @@ -341,6 +337,7 @@ def detect_cuda_support() -> tuple[bool, Optional[str]]: capture_output=True, text=True, timeout=5, + env=host_env(), ) if result.returncode == 0: gpu_info = result.stdout.strip().split(",") @@ -401,10 +398,7 @@ def detect_cpu_info() -> str: # Fallback to nproc try: result = subprocess.run( - ["nproc"], - capture_output=True, - text=True, - timeout=2, + ["nproc"], capture_output=True, text=True, timeout=2, env=host_env() ) if result.returncode == 0: cpu_count = result.stdout.strip() diff --git a/tests/test_appimage_packaging.py b/tests/test_appimage_packaging.py index d000b2b..3a16479 100644 --- a/tests/test_appimage_packaging.py +++ b/tests/test_appimage_packaging.py @@ -4,7 +4,37 @@ import re from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[1] -BUILD_SH = REPO_ROOT / "packaging" / "appimage" / "build.sh" +APPIMAGE = REPO_ROOT / "packaging" / "appimage" +BUILD_SH = APPIMAGE / "build.sh" +PINS = APPIMAGE / "tool_checksums.txt" +REQUIREMENTS = REPO_ROOT / "requirements" +WORKFLOWS = REPO_ROOT / ".github" / "workflows" +#: Workflows that build an AppImage, and so decide which distros it runs on. +APPIMAGE_WORKFLOWS = ("unified-pipeline.yml", "release.yml", "nightly.yml") + + +def _pins() -> dict: + """name -> (kind, value, source) from tool_checksums.txt.""" + pins = {} + for line in PINS.read_text(encoding="utf-8").splitlines(): + if not line.strip() or line.lstrip().startswith("#"): + continue + name, kind, value, source = line.split(None, 3) + pins[name] = (kind, value, source.strip()) + return pins + + +def _exported_versions(path: Path) -> dict: + """package -> version out of a hash-pinned requirements export.""" + return { + match.group(1).lower(): match.group(2) + for match in ( + re.match(r"^([A-Za-z0-9._-]+)==([^\s;\\]+)", line) + for line in path.read_text(encoding="utf-8").splitlines() + ) + if match + } + # Typelibs that must ship in the AppImage. xlib/Dbusmenu/GModule/fontconfig are # transitive GIR deps — pruning them broke Gtk on openSUSE (#585). @@ -41,6 +71,35 @@ def test_appimage_build_ships_transitive_typelibs(): assert "Need at least one of:" in text +def test_every_typelib_the_app_requires_is_seeded(): + """A required typelib the bundle does not seed sends GI to the host's search + path — the openSUSE/Fedora breakage from #585.""" + seeded = BUILD_SH.read_text(encoding="utf-8") + required = set() + for path in (REPO_ROOT / "src" / "vocalinux").rglob("*.py"): + for namespace, version in re.findall( + r"require_version\(\s*[\"']([\w]+)[\"']\s*,\s*[\"']([\d.]+)[\"']", path.read_text() + ): + required.add(f"{namespace}-{version}") + assert required, "no gi.require_version calls found; has the app stopped using GI?" + missing = sorted(name for name in required if name not in seeded) + assert not missing, f"the app requires these typelibs; TYPELIBS does not seed them: {missing}" + + +def test_bundled_typelibs_come_with_the_library_they_load(): + """Ship a typelib without the library it dlopens and GI loads the host's + copy against the bundle's older GLib. IBus did: on any host with GLib 2.76+ + the app died on `undefined symbol: g_task_set_static_name`. The build host + cannot see it — its libraries match what it bundled.""" + text = BUILD_SH.read_text(encoding="utf-8") + assert "libibus-1.0.so.5" in text, "the IBus typelib is seeded but its library is not bundled" + assert "verify_typelib_libraries" in text + assert "HOST_PROVIDED_LIBS" in text, ( + "the check needs its list of libraries the excludelist keeps on the host, " + "or harfbuzz fails the build" + ) + + def test_appimage_build_bundles_gi_runtime_libs(): text = BUILD_SH.read_text() for lib in ( @@ -70,19 +129,124 @@ def test_appimage_build_rebuilds_pywhispercpp_with_vulkan(): assert 'CXX="${CXX:-g++}"' in text -def test_appimage_pins_pywhispercpp_to_the_installer_version(): - """An unpinned source build takes whatever PyPI published that day. - - pywhispercpp 1.5.1 landed mid-review and failed to compile with Vulkan on - the runner, breaking both AppImage jobs while nothing in the repo changed. - """ - build = BUILD_SH.read_text(encoding="utf-8") - # Both installs: the bundle install resolves the wheel's dependency range and - # the Vulkan rebuild replaces it. Pinning one mixes two versions' libggml. - assert build.count("pywhispercpp==$PYWHISPERCPP_VERSION") == 2 - assert 'PYWHISPERCPP_VERSION="$(sed' in build, "the pin must come from install.sh" - +def test_pywhispercpp_is_pinned_once_and_the_pins_agree(): + """1.5.1 landed mid-review and broke both AppImage jobs with nothing in the + repo changed. The bundle and the Vulkan rebuild install the same sdist, so + install.sh and the lock export have to agree on which one.""" installer = (REPO_ROOT / "install.sh").read_text(encoding="utf-8") - assert re.search( - r'^PYWHISPERCPP_VERSION="\d+\.\d+\.\d+"', installer, re.M - ), "install.sh no longer declares the version build.sh reads" + declared = re.search(r'^PYWHISPERCPP_VERSION="([^"]+)"', installer, re.M) + assert declared, "install.sh no longer declares the version build.sh reads" + + exported = _exported_versions(REQUIREMENTS / "vad.txt").get("pywhispercpp") + assert exported == declared.group(1), ( + "install.sh and requirements/vad.txt pin different pywhispercpp releases; " + "change pyproject.toml and run `just lock`" + ) + + build = BUILD_SH.read_text(encoding="utf-8") + assert 'PYWHISPERCPP_VERSION="$(sed' in build, "the pin must come from install.sh" + assert "--no-binary pywhispercpp" in build, "the Vulkan rebuild must build from source" + + +def test_every_download_goes_through_the_verifying_helper(): + """`continuous` and `master` move under us, and nothing noticed.""" + text = BUILD_SH.read_text(encoding="utf-8") + assert ( + text.count("curl_retry -o") == 1 + ), "downloads must go through fetch_pinned, which checks the digest first" + assert "/continuous/" not in text + assert "linuxdeploy-plugin-gtk/master/" not in text + + pinned = set(_pins()) + for name in set(re.findall(r'fetch_pinned "([^"]+)"', text)): + wanted = ( + [name.replace("$ARCH", arch) for arch in ("x86_64", "aarch64")] + if "$ARCH" in name + else [name] + ) + for entry in wanted: + assert entry in pinned, f"build.sh fetches '{entry}', which {PINS.name} does not pin" + + +def test_the_pins_are_digests_rather_than_names(): + for name, (kind, value, source) in _pins().items(): + if kind == "sha256": + assert re.fullmatch(r"[0-9a-f]{64}", value), f"{name}: not a sha256" + assert source.startswith("https://"), f"{name}: {source}" + assert ( + "/continuous/" not in source and "/master/" not in source + ), f"{name} points at a moving ref, so its digest is a coincidence" + elif kind == "git": + assert re.fullmatch(r"[0-9a-f]{40}", value), f"{name}: not a commit" + elif kind == "docker": + assert "@sha256:" in value, f"{name}: a tag is not a pin" + elif kind == "version": + assert re.fullmatch(r"\d+(\.\d+)+", value), f"{name}: {value}" + else: + raise AssertionError(f"{name}: unknown pin kind {kind!r}") + + +def test_the_appimage_is_built_in_the_pinned_base_image(): + """An AppImage cannot run on a glibc older than the one that built it, and + ubuntu-latest (2.39) rules out Debian 12, Ubuntu 22.04 and RHEL 9 — which is + what we shipped as the universal option.""" + for workflow in APPIMAGE_WORKFLOWS: + text = (WORKFLOWS / workflow).read_text(encoding="utf-8") + assert ( + "packaging/appimage/build.sh" not in text + ), f"{workflow} runs build.sh on the runner; go through docker-build.sh" + assert "packaging/appimage/docker-build.sh" in text, workflow + + image = _pins()["base-image"][1] + assert "22.04" in _pins()["base-image"][2], ( + "raising the base image raises the glibc floor and silently drops distros; " + "if that is intended, update docs/INSTALL.md in the same change" + ) + assert image.startswith("docker.io/library/ubuntu@sha256:") + + +def test_the_bundle_is_installed_from_the_lock(): + text = BUILD_SH.read_text(encoding="utf-8") + assert "-m pip install" not in text, "pip resolves at build time; install from the exports" + assert "--require-hashes" in text + for export in ("requirements/vad.txt", "requirements/appimage.txt"): + assert export in text, f"{export} is not what the bundle is built from" + + +def test_the_vad_export_still_covers_the_runtime_export(): + """build.sh installs vad.txt alone, on the assumption it carries everything + runtime.txt does. Two resolutions that drift would ship a third one.""" + runtime = _exported_versions(REQUIREMENTS / "runtime.txt") + vad = _exported_versions(REQUIREMENTS / "vad.txt") + missing = {name: ver for name, ver in runtime.items() if vad.get(name) != ver} + assert not missing, f"requirements/vad.txt no longer covers runtime.txt: {missing}" + + +def test_pygobject_stays_on_the_line_the_base_image_can_build(): + """3.52 moved to girepository-2.0 (glib 2.80+) and the base image has 2.72, + so bumping this to the lock's version fails the build.""" + version = _exported_versions(REQUIREMENTS / "appimage.txt").get("pygobject") + assert version, "requirements/appimage.txt no longer pins PyGObject" + assert tuple(int(part) for part in version.split(".")[:2]) <= (3, 50), version + + +def test_vulkan_builds_against_pinned_headers_not_the_base_image_ones(): + """ggml-vulkan.cpp does not compile against the base image's headers + (1.3.204): PipelineRobustnessCreateInfoEXT and friends are younger. Only the + compile sees them — the bundle carries no loader.""" + assert "vulkan-headers" in _pins(), "the Vulkan headers are not pinned" + assert "-DVulkan_INCLUDE_DIR=" in BUILD_SH.read_text( + encoding="utf-8" + ), "the rebuild does not point ggml at the pinned headers" + + +def test_vulkan_shaders_get_the_compiler_the_base_image_lacks(): + """ggml demands glslc specifically, and Ubuntu 22.04 packages none.""" + text = BUILD_SH.read_text(encoding="utf-8") + assert "ensure_glslc" in text + assert "shaderc" in _pins() + for workflow in APPIMAGE_WORKFLOWS: + content = (WORKFLOWS / workflow).read_text(encoding="utf-8") + assert ( + "vocalinux-appimage" in content + ), f"{workflow} does not cache the built glslc; every run rebuilds it" diff --git a/tests/test_host_process.py b/tests/test_host_process.py new file mode 100644 index 0000000..2c59ecf --- /dev/null +++ b/tests/test_host_process.py @@ -0,0 +1,96 @@ +"""Guards for the environment Vocalinux hands to host binaries. + +An AppImage exports its own `LD_LIBRARY_PATH`, `GI_TYPELIB_PATH` and +`PYTHONHOME`, and every child process inherits them. A host `ibus` linked +against a newer GLib than the bundle carries then exits with `undefined symbol: +g_free_sized` before doing anything, which is how dictated text stopped reaching +the focused window. +""" + +import ast +from pathlib import Path + +from vocalinux.utils.host_process import host_env + +REPO_ROOT = Path(__file__).resolve().parents[1] +SOURCE = REPO_ROOT / "src" / "vocalinux" +SUBPROCESS_FUNCS = {"run", "Popen", "check_output", "check_call", "call"} + + +def test_outside_a_bundle_the_environment_is_handed_over_unchanged(): + env = {"LD_LIBRARY_PATH": "/usr/lib", "PATH": "/usr/bin"} + assert host_env(env) == env + + +def test_bundle_paths_are_stripped_and_host_ones_survive(): + env = { + "APPDIR": "/tmp/.mount_Vocali1234", + "LD_LIBRARY_PATH": "/tmp/.mount_Vocali1234/usr/lib:/usr/lib", + "GI_TYPELIB_PATH": "/tmp/.mount_Vocali1234/usr/lib/girepository-1.0", + "PYTHONHOME": "/tmp/.mount_Vocali1234/usr", + "PATH": "/usr/bin:/bin", + "GTK_THEME": "Adwaita", + } + cleaned = host_env(env) + + assert cleaned["LD_LIBRARY_PATH"] == "/usr/lib", "the host half must survive" + assert "GI_TYPELIB_PATH" not in cleaned, "nothing but bundle paths were in it" + assert "PYTHONHOME" not in cleaned + assert cleaned["PATH"] == "/usr/bin:/bin" + assert cleaned["GTK_THEME"] == "Adwaita", "non-path values are not paths into the bundle" + assert cleaned["APPDIR"] == env["APPDIR"], "a child may still need to know it came from one" + + +def _is_subprocess_call(node) -> bool: + func = getattr(node, "func", None) + return ( + isinstance(node, ast.Call) + and isinstance(func, ast.Attribute) + and func.attr in SUBPROCESS_FUNCS + and isinstance(func.value, ast.Name) + and func.value.id == "subprocess" + ) + + +def _spawns_our_interpreter(node) -> bool: + """True when the command starts with `sys.executable` — our own Python.""" + if not node.args or not isinstance(node.args[0], ast.List) or not node.args[0].elts: + return False + first = node.args[0].elts[0] + return ( + isinstance(first, ast.Attribute) + and first.attr == "executable" + and isinstance(first.value, ast.Name) + and first.value.id == "sys" + ) + + +def _env_argument(node): + return next((kw.value for kw in node.keywords if kw.arg == "env"), None) + + +def _is_host_env_call(value) -> bool: + return isinstance(value, ast.Call) and getattr(value.func, "id", None) == "host_env" + + +def test_host_binaries_get_a_stripped_environment_and_our_own_python_does_not(): + """Two mistakes, opposite directions: a host binary that inherits the bundle + dies on the wrong GLib, and our own interpreter stripped of it cannot find + its stdlib or GI stack.""" + inherit, stripped = [], [] + for path in sorted(SOURCE.rglob("*.py")): + for node in ast.walk(ast.parse(path.read_text(encoding="utf-8"))): + if not _is_subprocess_call(node): + continue + where = f"{path.relative_to(REPO_ROOT)}:{node.lineno}" + env = _env_argument(node) + if _spawns_our_interpreter(node): + if _is_host_env_call(env): + stripped.append(where) + elif not _is_host_env_call(env): + inherit.append(where) + + assert ( + not inherit + ), "these hand the bundle to a host binary; pass env=host_env():\n" + "\n".join(inherit) + assert not stripped, "these strip the bundle from our own interpreter:\n" + "\n".join(stripped) diff --git a/tests/test_ibus_engine.py b/tests/test_ibus_engine.py index ca973ee..13c5837 100644 --- a/tests/test_ibus_engine.py +++ b/tests/test_ibus_engine.py @@ -1926,6 +1926,11 @@ class TestTextInjectorWithIBus(unittest.TestCase): mock_injector_class, ): injector = TextInjector() + # IBus environment promotion happens in a background + # daemon thread (see _initialize_ibus_in_background), so + # wait for it rather than race the scheduler. + if injector._ibus_init_thread is not None: + injector._ibus_init_thread.join(timeout=5) # Should be using IBus self.assertEqual(injector.environment, DesktopEnvironment.WAYLAND_IBUS) @@ -1957,6 +1962,11 @@ class TestTextInjectorWithIBus(unittest.TestCase): mock_injector_class, ): injector = TextInjector() + # IBus environment promotion happens in a background + # daemon thread (see _initialize_ibus_in_background), so + # wait for it rather than race the scheduler. + if injector._ibus_init_thread is not None: + injector._ibus_init_thread.join(timeout=5) self.assertEqual(injector.environment, DesktopEnvironment.X11_IBUS) diff --git a/tests/test_ibus_engine_core.py b/tests/test_ibus_engine_core.py index c913c05..342014d 100644 --- a/tests/test_ibus_engine_core.py +++ b/tests/test_ibus_engine_core.py @@ -15,6 +15,7 @@ import threading import time import unittest from pathlib import Path +from unittest import mock from unittest.mock import MagicMock, Mock, call, patch # Add src to path for imports @@ -130,9 +131,7 @@ class TestIBusEngineModuleFunctions(unittest.TestCase): result = is_ibus_daemon_running() self.assertTrue(result) mock_run.assert_called_once_with( - ["pgrep", "-x", "ibus-daemon"], - capture_output=True, - timeout=2, + ["pgrep", "-x", "ibus-daemon"], capture_output=True, timeout=2, env=mock.ANY ) @patch("subprocess.run") @@ -1018,6 +1017,35 @@ class TestStartEngineProcess(unittest.TestCase): self.assertEqual(env.get("VIRTUAL_ENV"), "/venv") mock_pid.write_text.assert_called_with("4242") + def test_the_engine_keeps_the_bundle_environment(self): + """The engine is our own interpreter: host_env() would leave the + AppImage's Python without its stdlib, packages or GI stack.""" + from vocalinux.text_injection import ibus_engine + + bundle = { + "APPDIR": "/tmp/.mount_Vocali1234", + "PYTHONHOME": "/tmp/.mount_Vocali1234/usr", + "GI_TYPELIB_PATH": "/tmp/.mount_Vocali1234/usr/lib/girepository-1.0", + "LD_LIBRARY_PATH": "/tmp/.mount_Vocali1234/usr/lib", + } + mock_proc = MagicMock() + mock_proc.pid = 4242 + + with ( + patch.dict(os.environ, bundle, clear=False), + patch.object(ibus_engine, "is_engine_process_running", side_effect=iter([False, True])), + patch.object(ibus_engine, "ensure_ibus_dir"), + patch.object(ibus_engine, "PID_FILE"), + patch.object(ibus_engine, "VOCALINUX_IBUS_DIR", Path(tempfile.mkdtemp())), + patch.object(ibus_engine.subprocess, "Popen", return_value=mock_proc) as mock_popen, + patch.object(ibus_engine.time, "sleep"), + ): + self.assertTrue(ibus_engine.start_engine_process()) + + env = mock_popen.call_args.kwargs["env"] + for name, value in bundle.items(): + self.assertEqual(env.get(name), value, f"{name} was stripped from the engine spawn") + def test_failure_logs_stderr_and_clears_pid(self): from vocalinux.text_injection import ibus_engine diff --git a/tests/test_ibus_xkb_wayland.py b/tests/test_ibus_xkb_wayland.py index 293a4f9..6788c9d 100644 --- a/tests/test_ibus_xkb_wayland.py +++ b/tests/test_ibus_xkb_wayland.py @@ -12,7 +12,7 @@ set. That path must not go through ``restore_xkb_layout``. import sys import unittest -from unittest.mock import MagicMock, patch +from unittest.mock import ANY, MagicMock, patch # Mock GI so importing ibus_engine does not require a real IBus/GTK stack. _mock_gi = MagicMock() @@ -153,6 +153,7 @@ class TestSyncXwaylandLayoutFromGnome(unittest.TestCase): capture_output=True, text=True, timeout=2, + env=ANY, ) @patch("vocalinux.text_injection.ibus_engine.subprocess.run") @@ -173,6 +174,7 @@ class TestSyncXwaylandLayoutFromGnome(unittest.TestCase): capture_output=True, text=True, timeout=2, + env=ANY, ) @patch("vocalinux.text_injection.ibus_engine.subprocess.run") diff --git a/tests/test_text_injector.py b/tests/test_text_injector.py index f57a089..b350546 100644 --- a/tests/test_text_injector.py +++ b/tests/test_text_injector.py @@ -7,6 +7,7 @@ import subprocess import sys import threading import unittest +from unittest import mock from unittest.mock import MagicMock, patch # Update import path to use the new package structure @@ -123,6 +124,7 @@ class TestTextInjector(unittest.TestCase): text=True, check=False, timeout=2, + env=mock.ANY, ) wtype_calls = [ call.args[0] @@ -218,7 +220,11 @@ class TestTextInjector(unittest.TestCase): # Verify wtype was called correctly self.mock_subprocess.assert_any_call( - ["wtype", "Hello world"], check=True, stderr=subprocess.PIPE, text=True + ["wtype", "Hello world"], + check=True, + stderr=subprocess.PIPE, + text=True, + env=mock.ANY, ) def test_wayland_with_ydotool(self): @@ -243,6 +249,7 @@ class TestTextInjector(unittest.TestCase): check=True, stderr=subprocess.PIPE, text=True, + env=mock.ANY, ) def test_inject_special_characters(self): @@ -1720,7 +1727,7 @@ class TestIBusRuntimeFallback(unittest.TestCase): self.assertEqual(injector.environment, DesktopEnvironment.WAYLAND) self.assertEqual(injector.wayland_tool, "ydotool") self.mock_subprocess.assert_any_call( - ["ydotool", "type", ""], check=True, stderr=subprocess.PIPE, timeout=2 + ["ydotool", "type", ""], check=True, stderr=subprocess.PIPE, timeout=2, env=mock.ANY ) def test_switch_from_wayland_ibus_falls_back_when_ydotool_daemon_down(self): diff --git a/tests/test_text_injector_ext.py b/tests/test_text_injector_ext.py index 7d5edbf..f1d923e 100644 --- a/tests/test_text_injector_ext.py +++ b/tests/test_text_injector_ext.py @@ -6,6 +6,7 @@ import sys import threading import unittest from typing import Any, cast +from unittest import mock from unittest.mock import MagicMock, patch import pytest @@ -520,11 +521,7 @@ class TestRecoverFromFallback(unittest.TestCase): self.assertTrue(obj._try_recover_from_fallback()) mock_run.assert_called_once_with( - ["wtype", ""], - stderr=subprocess.PIPE, - text=True, - check=False, - timeout=2, + ["wtype", ""], stderr=subprocess.PIPE, text=True, check=False, timeout=2, env=mock.ANY ) diff --git a/tests/test_xkb_layout.py b/tests/test_xkb_layout.py index d45ec07..9b070a8 100644 --- a/tests/test_xkb_layout.py +++ b/tests/test_xkb_layout.py @@ -2,6 +2,7 @@ import subprocess import unittest +from unittest import mock from unittest.mock import MagicMock, patch @@ -27,10 +28,7 @@ class TestXkbLayoutFunctions(unittest.TestCase): self.assertEqual(result, ("us", "", "compose:menu,grp:caps_toggle")) mock_run.assert_called_once_with( - ["setxkbmap", "-query"], - capture_output=True, - text=True, - timeout=2, + ["setxkbmap", "-query"], capture_output=True, text=True, timeout=2, env=mock.ANY ) @patch("vocalinux.text_injection.ibus_engine.subprocess.run") @@ -135,10 +133,7 @@ class TestXkbLayoutFunctions(unittest.TestCase): self.assertTrue(result) mock_run.assert_called_once_with( - ["setxkbmap", "-layout", "de"], - capture_output=True, - text=True, - timeout=2, + ["setxkbmap", "-layout", "de"], capture_output=True, text=True, timeout=2, env=mock.ANY ) @patch("vocalinux.text_injection.ibus_engine.subprocess.run")