Files
voculinux/packaging/appimage/boot-test.sh
T
Jatin K MalikandGitHub fb9166ed9e feat(settings): run local VocaGateway from Advanced (podman-first) (#774)
* feat(settings): run local VocaGateway from Speech Engine (podman-first)

Add an optional Local VocaGateway panel next to Remote Server so Linux users
can start the pinned v0.1.0 compose project with podman (docker fallback),
watch Live/Pairable/Ready status, pair a phone without loopback QR URLs, and
preset remote_api to the gateway transcriptions endpoint. Stop preserves
volumes; Flatpak fails closed; tray offers Stop only when this session manages
the gateway. Default on-device engine stays whisper.cpp.

* fix(gateway-embed): drop nonexistent compose cpu profile

VocaGateway v0.1.0 exposes default service gateway plus native/cuda/vulkan
profiles, not cpu. Start and stop the default service without --profile, keep
the latest image pin rejection, and prefer save_config for the remote_api preset.

* fix(gateway-embed): keep compose --profile cpu per embed contract

v0.1.0 has no cpu profile entry, but Compose still starts the unprofiled
gateway service when --profile cpu is set, and keeps cuda/vulkan/native off.
That matches the frozen desktop embed contract and stays forward-compatible.

* fix(gateway-embed): harden embed path for settings and compose

Keep podman/docker probing off the GTK thread, reject link-local and
loopback public URLs for phone QR, sanitize compose .env image overrides,
and cap bootstrap token reads so a hostile file cannot blow memory.

* fix(gateway-embed): keep LAN toggle and phone QR in sync

When Allow LAN flips while compose is already managed, rewrite publish
host and PUBLIC_URL and recreate the gateway before Pairable comes back.
Also reject default docker/podman bridge addresses for phone QR.

* test(gateway-embed): pin bridge and LAN-gate pairing cases

Cover 172.17.0.2 / link-local not becoming Pairable, and the mid-run
LAN toggle path that must wait for compose republish before advertising
a phone URL.

* style(gateway-embed): black format embed modules

* fix(gateway-embed): reject short-form loopback and localhost.* for QR

Align Pairable/QR host checks with vocagateway desktop-embed policy so
127.1 / localhost.* never become phone-reachable display URLs.

* fix(gateway-embed): detach Settings status listener on destroy

Gateway poll callbacks were left registered after Settings closed, so
idle_add could touch destroyed GTK widgets while compose stayed up.

* fix(gateway-embed): pass host_env to bridge ip probe

_bridge_iface_ips shells out to host ip; without host_env the AST
gate fails and AppImage bundles can hand the wrong GLib to ip.

* fix(packaging): ship gateway_embed in the AppImage

The explicit setuptools packages list omitted the new package, so the
bundle booted from source trees but died with ModuleNotFoundError on
every distro boot test.

* style(packaging): black the gateway_embed package guard

* fix(settings): pack local VocaGateway into the Advanced island

The rebase onto main auto-merged textually onto the simple Speech Model
page. Keep the gateway card on advanced_box, next to Remote Server.

* fix(gateway-embed): treat missing Compose as unavailable

When podman or docker is present but Compose is not, stop advertising Run.
Return empty compose_args, show an install-Compose hint, and cover it with a unit test.

Also add module loggers on the new gateway_embed modules and annotate the
gateway Settings/tray callbacks Greptile flagged. just lock found no lockfile drift.

* fix(gateway-embed): keep Allow LAN bind matched to the switch

Snapshot the LAN flag for start/republish, record what compose actually
got, then republish again if the switch moved mid-flight so 0.0.0.0 never
lingers while Allow LAN reads off.

* fix(gateway-embed): detect leftover compose after Quit

Quit does not stop compose, so a LAN-published gateway can keep running.
Settings and the tray treated that as Stopped because managed_by_us is
only process memory. Probe compose after runtime detection, show Stop
when it is actually up, and let Stop work even if this session did not
start it.

* fix(gateway-embed): restore LAN switch from leftover compose

Quit leaves compose running, so the next launch now reads
VOCAGATEWAY_PUBLISH_HOST from the embed .env, adopts the project as
managed, and keeps Allow LAN honest so republish can close 0.0.0.0.

* fix(gateway-embed): restore .env if LAN republish fails

compose recreate can fail after write_env_file already pointed
VOCAGATEWAY_PUBLISH_HOST at loopback. Restore the previous .env so
orphan adopt still sees a live 0.0.0.0 bind, and snap Allow LAN back
to the running publish mode.

* style(gateway-embed): type helper args and black MagicMock

* refactor(types): move Readable protocol to common_types

AGENTS.md keeps Protocol interfaces in common_types. Pairing now imports
the shared Readable surface instead of a local _Readable helper.

* fix(gateway-embed): satisfy mypy on rebased embed code

* fix(audio): keep duck restore point when a set reports failure
2026-09-29 15:33:27 -07:00

159 lines
6.0 KiB
Bash
Executable File

#!/usr/bin/env bash
# Start the finished AppImage in a distro container and check it comes up.
#
# Usage: boot-test.sh <path-to-appimage>
#
# The build host cannot catch what this catches: its libraries match what it
# bundled. Both #743 regressions reached a user for that reason — one when the
# bundle loaded a GI library the host also had (libibus), one when the app
# handed its own library path to a host binary (ibus). Old distros prove the
# glibc floor; new ones prove the bundle does not poison what it shells out to.
set -euo pipefail
APPIMAGE="$(readlink -f "${1:?usage: boot-test.sh <path-to-appimage>}")"
export APPIMAGE_EXTRACT_AND_RUN=1
WORKDIR="$(mktemp -d)"
trap 'rm -rf "$WORKDIR"' EXIT
export XDG_CONFIG_HOME="$WORKDIR/config" XDG_DATA_HOME="$WORKDIR/data" \
XDG_CACHE_HOME="$WORKDIR/cache" HOME="$WORKDIR"
mkdir -p "$XDG_CONFIG_HOME" "$XDG_DATA_HOME" "$XDG_CACHE_HOME"
. /etc/os-release
DISTRO="$PRETTY_NAME"
echo "== $DISTRO (glibc $(ldd --version | head -1 | grep -oE '[0-9]+\.[0-9]+$')) =="
# Xvfb and a session bus for the tray, a GLib CLI to prove host binaries still
# run, the distro's GTK 3 runtime, and xdotool. The last two are not the AppImage
# failing
# to be self-contained: the AppImage excludelist deliberately leaves libX11,
# libharfbuzz and friends to the host, because bundling them breaks more than it
# fixes, and xdotool is a documented host prerequisite for every install path
# except the Flatpak. Any desktop has them; a bare container does not.
echo "== Installing test prerequisites =="
case "${ID}${ID_LIKE:+ $ID_LIKE}" in
*debian*|ubuntu*)
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y -qq --no-install-recommends \
xvfb xauth dbus-x11 libglib2.0-bin ca-certificates libgtk-3-0 xdotool >/dev/null
;;
*fedora*|*rhel*)
dnf install -y -q xorg-x11-server-Xvfb xorg-x11-xauth dbus-x11 glib2 gtk3 xdotool >/dev/null
;;
*arch*)
pacman -Sy --noconfirm --quiet xorg-server-xvfb xorg-xauth dbus glib2 gtk3 xdotool >/dev/null
;;
*suse*)
# openSUSE splits these differently: xvfb-run ships on its own, the session
# bus is in dbus-1-daemon, and the GTK 3 runtime is libgtk-3-0.
zypper -n -q in xorg-x11-server-Xvfb xvfb-run xauth dbus-1-daemon \
glib2-tools libgtk-3-0 xdotool >/dev/null
;;
*)
echo "No package recipe for '$ID'; add one to boot-test.sh" >&2
exit 1
;;
esac
fail() {
echo "FAIL: $*" >&2
exit 1
}
echo "== It runs at all =="
"$APPIMAGE" --version || fail "the AppImage does not start on $DISTRO"
# Unpack once so the checks below can use the bundle's own interpreter.
cd "$WORKDIR"
"$APPIMAGE" --appimage-extract >/dev/null
BUNDLE="$WORKDIR/squashfs-root"
bundle_python() {
APPDIR="$BUNDLE" \
PYTHONHOME="$BUNDLE/usr" \
PYTHONPATH="$BUNDLE/usr/lib/python3:$BUNDLE/usr/lib/python3/site-packages" \
GI_TYPELIB_PATH="$BUNDLE/usr/lib/girepository-1.0" \
LD_LIBRARY_PATH="$BUNDLE/usr/lib" \
"$BUNDLE/usr/bin/python3" "$@"
}
echo "== Its GI stack is the bundled one =="
# A typelib whose library is missing loads the host's copy against the bundle's
# older GLib. That is how libibus turned IBus.Engine into a 'void' GType.
bundle_python - <<'PY' || fail "the bundled GI stack does not load"
import gi
gi.require_version("Gtk", "3.0")
gi.require_version("IBus", "1.0")
from gi.repository import Gtk, IBus # noqa: F401
if IBus.Engine.__gtype__.name in ("void", "invalid"):
raise SystemExit("IBus typelib loaded but its library did not")
from vocalinux.text_injection import text_injector # noqa: F401
from vocalinux import gateway_embed # noqa: F401
print(" Gtk 3.0, IBus, text_injection and gateway_embed all import")
PY
echo "== Host binaries still run when the app spawns them =="
# The bundle's LD_LIBRARY_PATH reaches every child process, so a host tool built
# against a newer GLib dies before it does anything: `ibus` exited with
# `undefined symbol: g_free_sized` and dictated text reached no window.
bundle_python - <<'PY' || fail "a host binary cannot run from inside the bundle"
import subprocess
from vocalinux.utils.host_process import host_env
PROBE = ["gsettings", "--version"]
inherited = subprocess.run(PROBE, capture_output=True, text=True)
sanitized = subprocess.run(PROBE, capture_output=True, text=True, env=host_env())
if sanitized.returncode != 0:
raise SystemExit(f"gsettings failed with a sanitized environment: {sanitized.stderr.strip()}")
if inherited.returncode != 0:
print(" host GLib is newer than the bundle's; host_env() is what keeps it working")
print(f" gsettings {sanitized.stdout.strip()} runs")
PY
echo "== It reaches the tray =="
LOG="$WORKDIR/boot.log"
set +e
timeout -s TERM 40 xvfb-run -a dbus-run-session -- \
"$APPIMAGE" --debug --start-minimized >"$LOG" 2>&1
status=$?
set -e
# 124 is the timeout we asked for: the app is a tray application, so staying up
# until killed is the pass condition, not an early exit.
if [ "$status" -ne 124 ] && [ "$status" -ne 0 ] && [ "$status" -ne 143 ]; then
echo "--- last 30 lines ---" >&2
tail -n 30 "$LOG" >&2
fail "the app exited with $status instead of running"
fi
grep -q "Initializing Vocalinux" "$LOG" || {
tail -n 30 "$LOG" >&2
fail "startup never reached component initialisation"
}
grep -q "Initializing system tray indicator" "$LOG" || {
tail -n 30 "$LOG" >&2
fail "startup never reached the tray indicator"
}
if grep -q "Failed to create AppIndicator" "$LOG"; then
tail -n 30 "$LOG" >&2
fail "the tray indicator could not be created"
fi
# A traceback before the tray is a failed startup. One after it is the noise
# pynput's X listener makes when we TERM the app under Xvfb, so it is reported
# rather than fatal — the process staying up to the timeout is the real signal.
startup="$WORKDIR/startup.log"
sed -n '1,/Initializing system tray indicator/p' "$LOG" > "$startup"
if grep -q "^Traceback" "$startup"; then
grep -A 15 "^Traceback" "$startup" | head -n 20 >&2
fail "startup raised an exception"
fi
if grep -q "^Traceback" "$LOG"; then
echo " note: traceback on the shutdown path, after the tray came up" >&2
fi
echo "PASS: $DISTRO"