#!/usr/bin/env bash # Build a relocatable AppImage for Voculinux, natively for whichever # architecture this script runs on (x86_64 or aarch64) - no cross-compiling. # # Usage: build.sh [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 VOCULINUX_APPIMAGE_REQUIRE_VULKAN=1 in CI so a failed rebuild fails # the job instead of shipping a CPU-only image. Set # VOCULINUX_APPIMAGE_SKIP_VULKAN=1 to force the CPU wheel. # A build that produced libggml-vulkan.so is kept in VOCULINUX_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 Voculinux 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 Voculinux + 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/voculinux.desktop" "$APPDIR/usr/share/applications/voculinux.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/voculinux.desktop" printf 'StartupWMClass=voculinux\n' >> "$APPDIR/usr/share/applications/voculinux.desktop" install -Dm644 "$REPO_ROOT/resources/icons/scalable/voculinux.svg" \ "$APPDIR/usr/share/icons/hicolor/scalable/apps/voculinux.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 VOCULINUX_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="${VOCULINUX_APPIMAGE_CACHE:-${XDG_CACHE_HOME:-$HOME/.cache}/voculinux-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 "VOCULINUX_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="${VOCULINUX_APPIMAGE_REQUIRE_VULKAN:-0}" if [ "${VOCULINUX_APPIMAGE_SKIP_VULKAN:-0}" = "1" ]; then echo "== Skipping pywhispercpp Vulkan rebuild (VOCULINUX_APPIMAGE_SKIP_VULKAN=1) ==" return 0 fi # The Voculinux 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/voculinux.desktop" \ -i "$APPDIR/usr/share/icons/hicolor/scalable/apps/voculinux.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("&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/voculinux-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/voculinux-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/Voculinux-${VERSION}-${ARCH}.AppImage" ARCH="$ARCH" "$TOOLDIR/appimagetool" "$APPDIR" "$OUTPUT" echo "Built $OUTPUT"