Files
voculinux/packaging/appimage/build.sh
T
Sergey SilaevandGitHub b59797f1af chore(dev): widen uv range and harden Fedora/AppImage builds (#952)
* chore(deps): allow uv versions below 0.14

* fix(ci): align uv version and generated exports

* fix(dev): check Fedora build headers and AppImage GI bundle

Fail early with distro-specific native dependency hints before dev sync. Verify the AppImage's PyGObject installation and Python path after linuxdeploy, and report isolated GI import failures with actionable path diagnostics. Guard the CI uv pin and cover both preflights with tests.
2026-10-23 22:16:16 -07:00

803 lines
33 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)
}
# ggml's instruction-set floor for every CPU-side pywhispercpp build this
# script ships; CMAKE_ARGS inlines it below so the native-cache id keeps
# hashing it. GGML_NATIVE is ggml's default and compiles libggml-cpu with
# -march=native, tuned to whichever runner the build lands on: a runner with
# AVX-512 ships a libggml-cpu.so that dies with SIGILL on any CPU without it
# (#938), and the native cache then serves the same binary to every later
# build. GGML_NATIVE=OFF alone is not the portable baseline either — while
# the native default is on, ggml turns every GGML_* SIMD option on and still
# compiles -mavx2 -mfma, dying the same way on pre-Haswell CPUs (#957). Each
# option is pinned off for the plain x86-64 baseline; the Vulkan path is
# unaffected because its shaders run on the GPU.
PORTABLE_GGML_CMAKE_ARGS="-DGGML_NATIVE=OFF -DGGML_SSE42=OFF -DGGML_AVX=OFF -DGGML_AVX2=OFF -DGGML_BMI2=OFF -DGGML_FMA=OFF -DGGML_F16C=OFF"
# 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.
# GGML_VULKAN=1 in the caller's env selects the Vulkan build; without it the
# compile is CPU-only and any -DVulkan_INCLUDE_DIR is dead.
_install_pinned_pywhispercpp() {
local pip_log="$1"
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.
CC="${CC:-gcc}" CXX="${CXX:-g++}" \
GGML_VULKAN="${GGML_VULKAN:-0}" \
CMAKE_ARGS="-DCMAKE_INSTALL_RPATH=\$ORIGIN -DCMAKE_BUILD_WITH_INSTALL_RPATH=ON $PORTABLE_GGML_CMAKE_ARGS${VULKAN_INCLUDE_DIR:+ -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
}
# CPU-only fallback for the Vulkan path. The PyPI wheel the bundle started
# with is the same AVX2+FMA build the SIGILL reports come from, so keeping it
# is no fallback at all on pre-Haswell CPUs — rebuild the sdist at the
# baseline instead.
_compile_pywhispercpp_baseline() {
local pip_log="$WORKDIR/pywhispercpp-baseline.log"
export CMAKE_BUILD_PARALLEL_LEVEL="${CMAKE_BUILD_PARALLEL_LEVEL:-$(nproc)}"
ensure_build_tools
echo "== Rebuilding pywhispercpp at the portable CPU baseline =="
remove_appdir_pywhispercpp
if ! _install_pinned_pywhispercpp "$pip_log"; then
# The wheel is already gone, so "continue" would ship an AppImage with
# no speech engine at all; fail the build instead.
echo "Baseline pywhispercpp rebuild failed; refusing to ship without the speech engine." >&2
tail -n 40 "$pip_log" 2>/dev/null | sed 's/^/ /' >&2 || true
return 1
fi
copy_whisper_native_libs_to_usr_lib
}
# 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 a CPU-only baseline pywhispercpp build." >&2
_compile_pywhispercpp_baseline || return 1
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 a CPU-only baseline pywhispercpp build." >&2
_compile_pywhispercpp_baseline || return 1
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)}"
if ! GGML_VULKAN=1 _install_pinned_pywhispercpp "$pip_log"; 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 a CPU-only baseline pywhispercpp build." >&2
_compile_pywhispercpp_baseline || return 1
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
# The GGML_VULKAN=1 compile is already the portable baseline even when no
# Vulkan library comes out of it, so this build is safe to keep.
echo "Warning: AppImage will use CPU-only baseline pywhispercpp." >&2
copy_whisper_native_libs_to_usr_lib
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"
# The plugin can change the bundle after PyGObject was installed. Check the
# package and the version-agnostic Python path that AppRun and the GI smoke use
# before entering a new mount/user namespace; a missing module there is not a
# missing python3-gi package on the builder's host.
echo "== Checking bundled PyGObject after linuxdeploy =="
GI_SITE="$APPDIR/usr/lib/python${PY_VER}/site-packages"
if [ ! -f "$GI_SITE/gi/__init__.py" ]; then
echo "PyGObject is missing from $GI_SITE after linuxdeploy." >&2
echo "Check the earlier uv install of requirements/appimage.txt." >&2
exit 1
fi
if [ -d "$APPDIR/usr/lib/python3" ] && [ ! -L "$APPDIR/usr/lib/python3" ]; then
echo "Expected usr/lib/python3 to be a symlink to python${PY_VER}, not a directory." >&2
exit 1
fi
# Restore this link if linuxdeploy replaced it; a broken alias hides gi even
# though its versioned site-packages directory is present in the bundle.
ln -sfn "python${PY_VER}" "$APPDIR/usr/lib/python3"
if ! PYTHONHOME="$APPDIR/usr" \
PYTHONPATH="$APPDIR/usr/lib/python3:$APPDIR/usr/lib/python3/site-packages" \
LD_LIBRARY_PATH="$APPDIR/usr/lib" \
"$APPDIR/usr/bin/python3" -c 'import gi; print(" gi:", gi.__file__)'; then
echo "The bundled Python cannot import gi before the isolated smoke test." >&2
exit 1
fi
# 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'
try:
import gi
except ModuleNotFoundError:
import os
import pathlib
import stat
import sys
print('Bundled gi is not importable inside the isolated smoke test.', file=sys.stderr)
print(f' python: {sys.executable}, uid: {os.geteuid()}', file=sys.stderr)
print(' PYTHONHOME:', os.environ.get('PYTHONHOME'), file=sys.stderr)
print(' PYTHONPATH:', os.environ.get('PYTHONPATH'), file=sys.stderr)
print(f' sys.path: {sys.path}', file=sys.stderr)
lib = pathlib.Path(sys.prefix) / 'lib'
versioned = lib / f'python{sys.version_info.major}.{sys.version_info.minor}'
for candidate in (lib / 'python3', versioned / 'site-packages' / 'gi',
lib / 'python3' / 'site-packages' / 'gi'):
try:
details = candidate.stat()
except OSError as exc:
print(f' {candidate}: {exc}', file=sys.stderr)
else:
print(f' {candidate}: mode={stat.S_IMODE(details.st_mode):o}, '
f'uid={details.st_uid}, accessible={os.access(candidate, os.R_OK | os.X_OK)}',
file=sys.stderr)
raise
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"