Import the vocalinux source tree (tray app, Next.js site in web/, packaging, installers, docs and tests) on top of the Gitea template commit. The project README replaces the template README, and the template CI files (.gitea/workflows/ci.yml, .jscpd.json, CI.md) are kept.
803 lines
33 KiB
Bash
Executable File
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"
|