fix(appimage): build against a glibc floor users actually have (#743)

* build(appimage): pin the interpreter, packages and tooling

* ci(appimage): build inside a pinned ubuntu 22.04 image

* test(appimage): guard the pins and the base image

* docs: state the AppImage glibc floor

* fix(appimage): bundle libibus with its typelib

* ci(appimage): let the container run the isolated GI smoke test

* fix(appimage): keep the bundle's libraries out of host binaries

* fix(appimage): bundle libgpg-error with the libgcrypt that needs it

* docs(agents): require host_env() for spawned binaries

* ci: run the AppImage jobs when its other inputs change

* fix(appimage): check every bundled typelib, not just the seeded ones

* fix(ibus): keep the bundle when respawning our own interpreter

* fix(injection): pass host_env() to setxkbmap and notify-send

* test(ibus): wait for the IBus warmup thread before asserting
This commit is contained in:
Sergey
2026-08-29 14:46:50 -07:00
committed by GitHub
parent 6b14338d1e
commit 65381b4582
32 changed files with 1042 additions and 300 deletions
+18 -60
View File
@@ -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
+18 -60
View File
@@ -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
+21 -37
View File
@@ -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
+5 -1
View File
@@ -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`)
+4
View File
@@ -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
+14
View File
@@ -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:
+310 -47
View File
@@ -4,13 +4,20 @@
#
# Usage: build.sh <path-to-wheel> <version> [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("<I", blob, SHARED_LIBRARY_OFFSET)[0]
if not offset:
return []
return [name for name in blob[offset : blob.index(b"\x00", offset)].decode().split(",") if name]
# Every typelib that ships, not only the seeded ones: linuxdeploy keeps the
# host's extras, and an extra loads the host's library just as readily.
checked, missing = 0, []
for typelib in sorted(typelib_dir.glob("*.typelib")):
namespace = typelib.stem
checked += 1
for library in shared_libraries(typelib):
if library in host_provided or (lib_dir / library).exists():
continue
missing.append(f"{namespace}.typelib needs {library}")
if missing:
print("Typelibs bundled without the library they load:", file=sys.stderr)
for line in missing:
print(f" - {line}", file=sys.stderr)
print("GI would load the host's copy against the bundled GLib.", file=sys.stderr)
print("Add it to GI_RUNTIME_LIBS, or to HOST_PROVIDED_LIBS if the", file=sys.stderr)
print("AppImage excludelist says it has to come from the host.", file=sys.stderr)
raise SystemExit(1)
print(f" {checked} typelibs checked, every library present")
PY
}
verify_typelib_libraries
echo "== Patching linuxdeploy GTK AppRun hook (Wayland-friendly GDK backend) =="
GTK_HOOK="$APPDIR/apprun-hooks/linuxdeploy-plugin-gtk.sh"
if [ -f "$GTK_HOOK" ]; then
# Upstream forces GDK_BACKEND=x11 even on Wayland, which breaks hover/focus
# for GTK widgets under Plasma (XWayland). Prefer native Wayland unless the
# user opts into X11 via VOCALINUX_FORCE_X11=1.
python3 - "$GTK_HOOK" <<'PY'
"$APPDIR/usr/bin/python3" - "$GTK_HOOK" <<'PY'
import pathlib, re, sys
path = pathlib.Path(sys.argv[1])
text = path.read_text()
@@ -389,6 +646,12 @@ except (ImportError, ValueError):
except (ImportError, ValueError):
gi.require_version('AppIndicator3', '0.1')
from gi.repository import AppIndicator3 # noqa: F401
# ibus_engine.py subclasses IBus.Engine at import time; a failed libibus load
# leaves a stub whose GType is 'void', so check the type, not just the import.
gi.require_version('IBus', '1.0')
from gi.repository import IBus
if IBus.Engine.__gtype__.name in ('void', 'invalid'):
raise SystemExit('IBus typelib loaded but libibus did not')
print('GI smoke OK')
PY
"
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env bash
# Install what build.sh needs on a bare base image, then run it.
#
# The AppImage inherits the glibc and the GTK stack of whatever host builds it,
# so the host is a pinned input like any other: `base-image` in
# tool_checksums.txt. This script is the part that runs inside that image; the
# way in is docker-build.sh, which both CI and `just appimage` call:
#
# bash packaging/appimage/docker-build.sh dist/vocalinux-0.16.0-py3-none-any.whl 0.16.0 dist
#
# Only the interpreter and the Python packages are pinned by digest; these apt
# packages are whatever the pinned image resolves to. That is the trade the base
# image makes: its archive is frozen enough for this to be stable, and pinning
# every .deb would cost more than it protects.
set -euo pipefail
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y -qq --no-install-recommends \
ca-certificates curl git xz-utils file squashfs-tools desktop-file-utils \
build-essential pkg-config python3 \
libcairo2-dev libgirepository1.0-dev portaudio19-dev \
libgtk-3-0 gir1.2-gtk-3.0 \
gir1.2-ayatanaappindicator3-0.1 libayatana-appindicator3-1 \
gir1.2-notify-0.7 libnotify4 \
gir1.2-dbusmenu-glib-0.4 libdbusmenu-gtk3-4 \
gir1.2-ibus-1.0 gir1.2-rsvg-2.0 librsvg2-common \
libvulkan-dev
bash "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/build.sh" "$@"
# A container builds as root, so everything it wrote is root-owned. Hand the
# results back to whoever started it (docker-build.sh passes its own ids).
if [ -n "${HOST_UID:-}" ]; then
chown -R "${HOST_UID}:${HOST_GID:-$HOST_UID}" "${3:-dist}" \
"${VOCALINUX_APPIMAGE_CACHE:-/cache}" 2>/dev/null || true
fi
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env bash
# Run the AppImage build inside the pinned base image.
#
# Usage: docker-build.sh <path-to-wheel> <version> [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 "$@"
+47
View File
@@ -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 <source> must hash to <value>
# git clone <source>, check out commit <value> (a commit is its own digest)
# docker build inside image <value>; <source> 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 <source> URL, download it, and replace
# <value> 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
+10
View File
@@ -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
+44
View File
@@ -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
+10
View File
@@ -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
+5
View File
@@ -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
@@ -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}")
@@ -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 ""
+20 -24
View File
@@ -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:
+29 -25
View File
@@ -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,
+7
View File
@@ -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:
@@ -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
+3
View File
@@ -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}")
+2
View File
@@ -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)
+52
View File
@@ -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
+4 -10
View File
@@ -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()
+180 -16
View File
@@ -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"
+96
View File
@@ -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)
+10
View File
@@ -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)
+31 -3
View File
@@ -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
+3 -1
View File
@@ -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")
+9 -2
View File
@@ -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):
+2 -5
View File
@@ -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
)
+3 -8
View File
@@ -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")