Files
voculinux/packaging/appimage/build.sh
T
ed0ed8ba49 fix(packaging): build pywhispercpp at the portable x86-64 baseline (#959)
* fix(packaging): build pywhispercpp at the portable x86-64 baseline

Every packaged channel shipped a libggml-cpu compiled for AVX2+FMA:
the manylinux PyPI wheel itself carries -mavx2 -mfma -mbmi2 code, so
any package that bundles it dies with SIGILL when whisper.cpp loads a
model on a pre-Haswell CPU (the reporter's i5-3337U). GGML_NATIVE=OFF
alone is not the portable baseline either; while the native default
is on, ggml turns every GGML_* SIMD option on, so each is pinned off.

- Snap: force the sdist (PIP_NO_BINARY) with the flag floor via
  CMAKE_ARGS in the part's build-environment.
- Flatpak: extend the module's CMAKE_ARGS with the same floor.
- Native .deb/.rpm: the build still vendors the wheels, then rebuilds
  libggml-cpu alone from the same hash-pinned sdist and swaps it into
  the vendored copies, asserting no AVX/FMA survives; the container
  image gains cmake, g++, make and binutils.
- AppImage: extract the pinned-sdist reinstall into a shared helper
  and route every CPU-only fallback of the Vulkan path through a new
  baseline rebuild at the same floor; the Vulkan compile keeps its
  own flags via the same helper. Keeping the wheel on those paths was
  the bug's exact binary.

Fixes #957

* style(tests): apply black/isort to the new packaging guards

* fix(native): keep the wheel's libggml providers, alias its base/gomp sonames

Swapping the whole libggml set for the baseline build broke the import
smoke: the wheel's auditwheel-mangled core lib doubles as the
extension's __cxa_call_terminate provider, and its NEEDED entries are
the mangled names. Swap only libggml-cpu, and satisfy the baseline
library's own unmangled NEEDEDs (libggml-base.so.0, libgomp.so.1) with
aliases of the vendored copies, which carry no generated SIMD and are
the same upstream version. Fail closed when the vendored set has
restructured instead of silently shipping the AVX2 wheel.

* fix(packaging): fail builds that lose the speech engine, unblock the ISA assert

Greptile findings on the baseline work:

- A failed baseline rebuild in the AppImage returned success after
  remove_appdir_pywhispercpp, so the bundle would ship with no speech
  engine at all while logging that the wheel was kept. Propagate the
  failure through every CPU-only fallback so the build stops instead.
- The native libggml-cpu assertion used grep -q under pipefail, where
  grep's early exit SIGPIPEs objdump and the pipeline reports a miss,
  letting an AVX2 library through. Read the full disassembly instead.

* fix(installer): build pywhispercpp from sdist on the CPU path

install_cpu_pywhispercpp let pip pick the manylinux wheel, whose
libggml-cpu is compiled for AVX2+FMA and SIGILLs on pre-Haswell CPUs
(#957). Pass --no-binary pywhispercpp like the Vulkan/CUDA paths so the
sdist compiles against this machine's own instruction set.

* fix(installer): install C++ toolchain on distros that ship without one

Forcing the pywhispercpp sdist on the CPU path needs a compiler, and the
fedora, mageia, alpine, void and solus package maps did not request one.
Add gcc-c++/make (rpm), build-base (alpine), base-devel (void) and
gcc/g++/make (solus) so source installs compile on minimal systems.

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
2026-10-16 09:24:15 -07:00

752 lines
31 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"
# 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"