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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
"
|
||||
|
||||
Executable
+37
@@ -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
|
||||
Executable
+40
@@ -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 "$@"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user