Files
voculinux/packaging/appimage/build.sh
T
Jatin K MalikandGitHub d53300841d fix(appimage): reuse the Vulkan pywhispercpp build across CI runs (#903)
* fix(appimage): reuse the Vulkan pywhispercpp build across CI runs

The x86_64 Build AppImage step spends most of its time recompiling
pywhispercpp with Vulkan. That compile depends on the base image,
tool_checksums.txt, and the pywhispercpp pin, not on the commit.
Cache the native tree under one key shared by CI, Nightly, and Release.

* fix(appimage): reject a partial Vulkan cache and key the build tools

A failed copy could leave a tree that still contained libggml-vulkan.so,
and the next run would reuse it. Check each copy, and keep the previous
cache until the new one is in place.

The cache id now also covers the pinned cmake and ninja export and the
Vulkan compile flags. The hit path is what the build calls, and a test
runs it with a stub compile command.

* chore(ci): rerun AppImage against the saved Vulkan cache

The review fix changed the native cache id, so run 37586979294
compiled pywhispercpp again and saved
appimage-native-X64-ebf478b87986e2f6b9d80c97c2f5869fe3120c2bcd48f5d8f5a65fe06ec03015.
This commit leaves the base image, tool checksums, pywhispercpp pin,
installer tool pins, and Vulkan compile flags alone.
2026-10-07 01:04:18 -07:00

706 lines
28 KiB
Bash
Executable File

#!/usr/bin/env bash
# Build a relocatable AppImage for Vocalinux, natively for whichever
# architecture this script runs on (x86_64 or aarch64) - no cross-compiling.
#
# Usage: build.sh <path-to-wheel> <version> [output-dir]
#
# 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.
#
# GPU: pip wheels of pywhispercpp are CPU-only. This script rebuilds
# pywhispercpp from source with GGML_VULKAN=1 when Vulkan headers and a
# shader compiler are on the build host. The AppImage uses the host Vulkan
# loader/ICDs at runtime (do not bundle NVIDIA/AMD driver ICDs).
# Set VOCALINUX_APPIMAGE_REQUIRE_VULKAN=1 in CI so a failed rebuild fails
# the job instead of shipping a CPU-only image. Set
# VOCALINUX_APPIMAGE_SKIP_VULKAN=1 to force the CPU wheel.
# A build that produced libggml-vulkan.so is kept in VOCALINUX_APPIMAGE_CACHE
# and reused while the base image, tool_checksums.txt, and the pywhispercpp
# pin are unchanged. A CPU-only build is not cached.
set -euo pipefail
WHEEL="$1"
VERSION="$2"
OUTDIR="${3:-dist}"
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
# shellcheck source=native-cache.sh
source "$(dirname "${BASH_SOURCE[0]}")/native-cache.sh"
# Same pywhispercpp release install.sh pins. Read from there rather than repeated
# here: unpinned, this build picks up whatever PyPI published today, which is how
# a 1.5.1 released mid-PR broke the Vulkan rebuild.
PYWHISPERCPP_VERSION="$(sed -n 's/^PYWHISPERCPP_VERSION="\([^"]*\)"/\1/p' "$REPO_ROOT/install.sh" | head -n1)"
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)"
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
# does not exist on Fedora/openSUSE/Arch — startup then reports "missing GTK3".
TYPELIBS=(
Gtk-3.0 Gdk-3.0 GdkX11-3.0 GdkPixbuf-2.0 GLib-2.0 GObject-2.0 Gio-2.0
GModule-2.0 Pango-1.0 PangoCairo-1.0 cairo-1.0 HarfBuzz-0.0 Atk-1.0
freetype2-2.0 fontconfig-2.0 xlib-2.0
AyatanaAppIndicator3-0.1 AyatanaAppindicator3-0.1 AppIndicator3-0.1
Dbusmenu-0.4 Notify-0.7 IBus-1.0 Rsvg-2.0 GtkLayerShell-0.1
)
# Runtime only needs one tray stack (same order as tray_indicator.py). Prefer
# Ayatana; accept the rare lowercase typelib; legacy AppIndicator3 last.
INDICATOR_TYPELIBS=(
AyatanaAppIndicator3-0.1 AyatanaAppindicator3-0.1 AppIndicator3-0.1
)
# Soft dependencies the app itself falls back from: dictation_overlay.py uses a
# plain GTK window when layer shell is absent, so these are copied when found
# but must never block a direct build on a host without them.
OPTIONAL_TYPELIBS=(
GtkLayerShell-0.1
)
# 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
libgtk-layer-shell.so.0
# 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
# Layer shell is optional end to end (see OPTIONAL_TYPELIBS): when its
# library cannot be bundled, the host's copy is the right one — the app
# falls back to a plain GTK window when no host copy exists either.
libgtk-layer-shell.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)"
trap 'rm -rf "$WORKDIR"' EXIT
APPDIR="$WORKDIR/AppDir"
TOOLDIR="$WORKDIR/tools"
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}"
# 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 "== 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
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 =="
# 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"
# AppImage desktop integration expects Exec=AppRun; set WM class for the tray app.
sed -i \
-e 's|^Exec=.*|Exec=AppRun|' \
-e '/^StartupWMClass=/d' \
"$APPDIR/usr/share/applications/vocalinux.desktop"
printf 'StartupWMClass=vocalinux\n' >> "$APPDIR/usr/share/applications/vocalinux.desktop"
install -Dm644 "$REPO_ROOT/resources/icons/scalable/vocalinux.svg" \
"$APPDIR/usr/share/icons/hicolor/scalable/apps/vocalinux.svg"
copy_typelibs() {
local dest="$1"
local require_all="${2:-0}"
mkdir -p "$dest"
local typelib found missing=() indicator_found=0
for typelib in "${TYPELIBS[@]}"; do
found="$(find /usr/lib /usr/lib64 -name "${typelib}.typelib" 2>/dev/null | head -1 || true)"
if [ -n "$found" ]; then
cp "$found" "$dest/"
case " ${INDICATOR_TYPELIBS[*]} " in
*" ${typelib} "*) indicator_found=1 ;;
esac
else
missing+=("$typelib")
fi
done
# Tray indicator typelibs are alternates; drop them from the hard-fail list
# when at least one copied successfully. Optional typelibs never hard-fail.
local hard_missing=()
for typelib in "${missing[@]}"; do
case " ${INDICATOR_TYPELIBS[*]} " in
*" ${typelib} "*)
if [ "$indicator_found" -eq 0 ]; then
hard_missing+=("$typelib")
fi
;;
*)
case " ${OPTIONAL_TYPELIBS[*]} " in
*" ${typelib} "*) ;;
*) hard_missing+=("$typelib") ;;
esac
;;
esac
done
if [ "$require_all" = "1" ] && [ "${#hard_missing[@]}" -gt 0 ]; then
echo "Missing required typelibs on the build host:" >&2
printf ' - %s\n' "${hard_missing[@]}" >&2
if [ "$indicator_found" -eq 0 ]; then
echo "Need at least one of: ${INDICATOR_TYPELIBS[*]}" >&2
fi
echo "Install the matching gir1.2-* packages and retry." >&2
exit 1
fi
if [ "${#missing[@]}" -gt 0 ]; then
echo "Warning: typelibs not found on build host (optional/alternate): ${missing[*]}" >&2
fi
}
copy_gi_runtime_libs() {
local dest="$1"
shift
mkdir -p "$dest"
local lib found
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/"
# Prefer versioned real files so linuxdeploy can resolve the SONAME.
if [ -L "$found" ]; then
real="$(readlink -f "$found")"
cp -aL "$real" "$dest/" 2>/dev/null || true
fi
echo " bundled $lib from $found"
else
echo "Warning: $lib not found on build host (tray/notify may need host libs)" >&2
fi
done
}
has_vulkan_build_deps() {
if [ -f /usr/include/vulkan/vulkan.h ] || pkg-config --exists vulkan 2>/dev/null; then
:
else
return 1
fi
command -v g++ >/dev/null 2>&1 || command -v clang++ >/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 \
-name 'pywhispercpp.libs' -o \
-name 'pywhispercpp-*.dist-info' -o \
-name '_pywhispercpp*' \
\) -exec rm -rf {} + 2>/dev/null || true
}
copy_whisper_native_libs_to_usr_lib() {
mkdir -p "$APPDIR/usr/lib"
local lib
while IFS= read -r lib; do
[ -n "$lib" ] || continue
cp -aL "$lib" "$APPDIR/usr/lib/" 2>/dev/null || cp -a "$lib" "$APPDIR/usr/lib/"
echo " staged $(basename "$lib") -> usr/lib"
done < <(find "$APPDIR/usr" \( -name 'libggml*.so*' -o -name 'libwhisper.so*' \) ! -path '*/usr/lib/*' 2>/dev/null || true)
}
# The miss path of rebuild_pywhispercpp_vulkan. require_vulkan, cache_dir, and
# site are locals of that caller; bash shows them to this function.
_compile_pywhispercpp_vulkan() {
local vk_lib
if ! has_vulkan_build_deps; then
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
fi
echo "Warning: AppImage will use the CPU-only pywhispercpp wheel." >&2
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 -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
echo "Warning: continuing with the CPU-only pywhispercpp wheel." >&2
return 0
fi
vk_lib="$(find "$APPDIR/usr" -name 'libggml-vulkan.so*' 2>/dev/null | head -1 || true)"
if [ -z "$vk_lib" ]; then
echo "Vulkan rebuild did not produce libggml-vulkan.so." >&2
if [ "$require_vulkan" = "1" ]; then
tail -n 80 "$pip_log" 2>/dev/null | sed 's/^/ /' >&2 || true
exit 1
fi
echo "Warning: AppImage will use CPU-only pywhispercpp." >&2
return 0
fi
echo " found $vk_lib"
copy_whisper_native_libs_to_usr_lib
if ! pywhispercpp_cache_publish "$site" "$cache_dir"; then
echo "Warning: Vulkan build succeeded but was not cached for the next run." >&2
fi
}
rebuild_pywhispercpp_vulkan() {
local require_vulkan="${VOCALINUX_APPIMAGE_REQUIRE_VULKAN:-0}"
if [ "${VOCALINUX_APPIMAGE_SKIP_VULKAN:-0}" = "1" ]; then
echo "== Skipping pywhispercpp Vulkan rebuild (VOCALINUX_APPIMAGE_SKIP_VULKAN=1) =="
return 0
fi
# The Vocalinux wheel was installed above and is not part of this cache.
# A hit still replaces the CPU pywhispercpp wheel the lock just installed,
# and it does not run _compile_pywhispercpp_vulkan.
local cache_dir site
cache_dir="$(pywhispercpp_native_cache_dir "$ARCH")"
site="$APPDIR/usr/lib/python${PY_VER}/site-packages"
pywhispercpp_restore_or_run "$cache_dir" "$site" copy_whisper_native_libs_to_usr_lib \
_compile_pywhispercpp_vulkan
}
echo "== Copying GObject-Introspection typelibs (not handled by linuxdeploy-plugin-gtk) =="
copy_typelibs "$APPDIR/usr/lib/girepository-1.0" 0
echo "== Copying GI runtime shared libraries (AppIndicator/Notify) =="
copy_gi_runtime_libs "$APPDIR/usr/lib" \
"${GI_RUNTIME_LIBS[@]}" "${BUNDLED_DEPENDENCY_LIBS[@]}"
echo "== Writing AppRun =="
cat > "$APPDIR/AppRun" << 'APPRUN'
#!/usr/bin/env bash
HERE="$(dirname "$(readlink -f "${0}")")"
export PYTHONHOME="$HERE/usr"
export PYTHONPATH="$HERE/usr/lib/python3:$HERE/usr/lib/python3/site-packages"
export GI_TYPELIB_PATH="$HERE/usr/lib/girepository-1.0"
export LD_LIBRARY_PATH="$HERE/usr/lib:${LD_LIBRARY_PATH:-}"
export XDG_DATA_DIRS="$HERE/usr/share:${XDG_DATA_DIRS:-/usr/share}"
exec "$HERE/usr/bin/python3" -m vocalinux.main "$@"
APPRUN
# PYTHONPATH above uses a version-agnostic symlink so AppRun doesn't need
# to know the exact interpreter minor version at runtime.
ln -sfn "python${PY_VER}" "$APPDIR/usr/lib/python3"
chmod +x "$APPDIR/AppRun"
rebuild_pywhispercpp_vulkan
echo "== Running linuxdeploy (resolves the shared-library closure + GTK theming) =="
export DEPLOY_GTK_VERSION=3
"$TOOLDIR/linuxdeploy" --appdir "$APPDIR" \
--plugin gtk \
-e "$APPDIR/usr/bin/python3" \
-d "$APPDIR/usr/share/applications/vocalinux.desktop" \
-i "$APPDIR/usr/share/icons/hicolor/scalable/apps/vocalinux.svg"
# linuxdeploy copies the host's full typelib set; keep those extras (transitive
# GIR deps vary by distro) and re-assert our required seed on top.
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.
"$APPDIR/usr/bin/python3" - "$GTK_HOOK" <<'PY'
import pathlib, re, sys
path = pathlib.Path(sys.argv[1])
text = path.read_text()
replacement = (
'# Prefer Wayland when available; force X11 only when requested.\n'
'if [ "${VOCALINUX_FORCE_X11:-0}" = "1" ]; then\n'
' export GDK_BACKEND=x11\n'
'elif [ -n "${WAYLAND_DISPLAY:-}" ] || [ "${XDG_SESSION_TYPE:-}" = "wayland" ]; then\n'
' export GDK_BACKEND=wayland\n'
'else\n'
' export GDK_BACKEND=x11\n'
'fi'
)
new, n = re.subn(
r'^[ \t]*export GDK_BACKEND=x11[^\n]*$',
replacement,
text,
count=1,
flags=re.M,
)
if n != 1:
raise SystemExit(f"failed to patch GDK_BACKEND in {path} (matches={n})")
path.write_text(new)
print(f"Patched {path}")
PY
else
echo "Warning: GTK AppRun hook not found at $GTK_HOOK" >&2
fi
echo "== Smoke-testing GI imports without host typelibs =="
# Catch the openSUSE/Fedora class of failure before packaging: Gtk needs xlib
# (and friends) from the bundle when the host GI search path is not Debian's.
smoke_gi_imports() {
local appdir="$1"
unshare --user --mount --map-root-user bash -c "
set -euo pipefail
mkdir -p /tmp/vocalinux-empty-gi
for d in /usr/lib/x86_64-linux-gnu/girepository-1.0 \
/usr/lib/aarch64-linux-gnu/girepository-1.0 \
/usr/lib/girepository-1.0 \
/usr/lib64/girepository-1.0; do
if [ -d \"\$d\" ]; then
mount --bind /tmp/vocalinux-empty-gi \"\$d\"
fi
done
export PYTHONHOME='$appdir/usr'
export PYTHONPATH='$appdir/usr/lib/python3:$appdir/usr/lib/python3/site-packages'
export GI_TYPELIB_PATH='$appdir/usr/lib/girepository-1.0'
export LD_LIBRARY_PATH='$appdir/usr/lib'
'$appdir/usr/bin/python3' - <<'PY'
import gi
gi.require_version('Gtk', '3.0')
from gi.repository import Gtk # noqa: F401
try:
gi.require_version('AyatanaAppIndicator3', '0.1')
from gi.repository import AyatanaAppIndicator3 # noqa: F401
except (ImportError, ValueError):
try:
gi.require_version('AyatanaAppindicator3', '0.1')
from gi.repository import AyatanaAppindicator3 # noqa: F401
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
"
}
if command -v unshare >/dev/null 2>&1 && unshare --user --mount --map-root-user true 2>/dev/null; then
smoke_gi_imports "$APPDIR"
else
echo "Warning: unshare unavailable; skipping isolated GI smoke test" >&2
fi
echo "== Packaging AppImage =="
OUTPUT="$OUTDIR/Vocalinux-${VERSION}-${ARCH}.AppImage"
ARCH="$ARCH" "$TOOLDIR/appimagetool" "$APPDIR" "$OUTPUT"
echo "Built $OUTPUT"