From 45acb10d13b808c40191c2395de6999469047534 Mon Sep 17 00:00:00 2001 From: Jatin K Malik Date: Mon, 3 Aug 2026 18:37:48 -0700 Subject: [PATCH] fix(appimage): ship transitive GI typelibs for non-Debian hosts (#637) * fix(appimage): ship transitive GI typelibs for non-Debian hosts The 0.15.0 AppImage pruned girepository down to a partial allowlist and dropped xlib/Dbusmenu/GModule. On Ubuntu the missing typelibs fell back to /usr/lib/x86_64-linux-gnu/girepository-1.0; on openSUSE/Fedora that path does not exist, so Gtk failed to import and startup reported missing GTK3/AppIndicator (#585). Keep linuxdeploy's typelib extras, seed the full transitive set, bundle AppIndicator/Notify shared libs, and smoke-test GI imports with host typelibs hidden. Co-authored-by: Jatin K Malik * style(test): format appimage packaging regression test Co-authored-by: Jatin K Malik * fix(ci): avoid libappindicator/ayatana apt conflict Ubuntu packages libappindicator3-1 and libayatana-appindicator3-1 conflict. Rely on gir1.2-* to pull a single implementation and keep libdbusmenu/libnotify explicit for bundling. Co-authored-by: Jatin K Malik * fix(ci): install gir1.2-dbusmenu-glib for AppImage typelib seed Dbusmenu-0.4 is a required transitive typelib for AppIndicator; without the GIR package the build fails the required-typelib check. Co-authored-by: Jatin K Malik * fix(appimage): require only one AppIndicator typelib variant Runtime accepts AppIndicator3 or AyatanaAppIndicator3. Treat them as alternates in the AppImage typelib check so single-stack build hosts do not fail require_all. Co-authored-by: Jatin K Malik * fix(appimage): prefer Ayatana after rebase onto main Align AppImage typelib seeding and GI smoke order with main's Ayatana-first tray preference from #621/#638. Co-authored-by: Jatin K Malik * fix(appimage): accept lowercase AyatanaAppindicator3 typelib Match tray_indicator.py's three-step fallback so AppImage builds and GI smoke succeed on hosts that only ship AyatanaAppindicator3-0.1. Co-authored-by: Jatin K Malik * fix(main): accept lowercase AyatanaAppindicator3 in dep check Align check_dependencies with tray_indicator.py and the AppImage GI smoke path so a lowercase-only typelib host does not fail at startup. Co-authored-by: Jatin K Malik --------- Co-authored-by: Cursor Agent Co-authored-by: Jatin K Malik --- .github/workflows/nightly.yml | 12 ++- .github/workflows/release.yml | 12 ++- .github/workflows/unified-pipeline.yml | 4 + packaging/appimage/build.sh | 142 +++++++++++++++++++++++-- src/vocalinux/main.py | 29 +++-- tests/test_appimage_packaging.py | 58 ++++++++++ tests/test_main.py | 39 ++++++- 7 files changed, 271 insertions(+), 25 deletions(-) create mode 100644 tests/test_appimage_packaging.py diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 1251835..e291f77 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -43,9 +43,13 @@ jobs: gir1.2-gtk-3.0 \ gir1.2-appindicator3-0.1 \ gir1.2-ayatanaappindicator3-0.1 \ + gir1.2-notify-0.7 \ + gir1.2-dbusmenu-glib-0.4 \ gir1.2-ibus-1.0 \ gir1.2-rsvg-2.0 \ - librsvg2-common + librsvg2-common \ + libdbusmenu-gtk3-4 \ + libnotify4 - name: Install build dependencies run: | @@ -288,9 +292,13 @@ jobs: gir1.2-gtk-3.0 \ gir1.2-appindicator3-0.1 \ gir1.2-ayatanaappindicator3-0.1 \ + gir1.2-notify-0.7 \ + gir1.2-dbusmenu-glib-0.4 \ gir1.2-ibus-1.0 \ gir1.2-rsvg-2.0 \ - librsvg2-common + librsvg2-common \ + libdbusmenu-gtk3-4 \ + libnotify4 - name: Install build dependencies run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1e971f7..d174c8c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -43,9 +43,13 @@ jobs: gir1.2-gtk-3.0 \ gir1.2-appindicator3-0.1 \ gir1.2-ayatanaappindicator3-0.1 \ + gir1.2-notify-0.7 \ + gir1.2-dbusmenu-glib-0.4 \ gir1.2-ibus-1.0 \ gir1.2-rsvg-2.0 \ - librsvg2-common + librsvg2-common \ + libdbusmenu-gtk3-4 \ + libnotify4 - name: Install build dependencies run: | @@ -167,9 +171,13 @@ jobs: gir1.2-gtk-3.0 \ gir1.2-appindicator3-0.1 \ gir1.2-ayatanaappindicator3-0.1 \ + gir1.2-notify-0.7 \ + gir1.2-dbusmenu-glib-0.4 \ gir1.2-ibus-1.0 \ gir1.2-rsvg-2.0 \ - librsvg2-common + librsvg2-common \ + libdbusmenu-gtk3-4 \ + libnotify4 - name: Install build dependencies run: | diff --git a/.github/workflows/unified-pipeline.yml b/.github/workflows/unified-pipeline.yml index 199a696..0677527 100644 --- a/.github/workflows/unified-pipeline.yml +++ b/.github/workflows/unified-pipeline.yml @@ -199,9 +199,13 @@ jobs: xdotool \ gir1.2-appindicator3-0.1 \ gir1.2-ayatanaappindicator3-0.1 \ + gir1.2-notify-0.7 \ + gir1.2-dbusmenu-glib-0.4 \ gir1.2-ibus-1.0 \ gir1.2-rsvg-2.0 \ librsvg2-common \ + libdbusmenu-gtk3-4 \ + libnotify4 \ libcairo2-dev \ pkg-config \ python3-dev \ diff --git a/packaging/appimage/build.sh b/packaging/appimage/build.sh index 0d1975e..d7dbbee 100755 --- a/packaging/appimage/build.sh +++ b/packaging/appimage/build.sh @@ -31,10 +31,32 @@ case "$ARCH" in *) echo "Unsupported architecture: $ARCH (need x86_64 or aarch64)" >&2; exit 1 ;; esac +# 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 - Pango-1.0 PangoCairo-1.0 cairo-1.0 HarfBuzz-0.0 Atk-1.0 freetype2-2.0 - AppIndicator3-0.1 AyatanaAppIndicator3-0.1 Notify-0.7 IBus-1.0 Rsvg-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 +) + +# 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 +) + +# Shared libs loaded via GI at runtime (not linked into python3), so +# linuxdeploy will not discover them from -e python3 alone. +GI_RUNTIME_LIBS=( + libappindicator3.so.1 + libayatana-appindicator3.so.1 + libdbusmenu-glib.so.4 + libdbusmenu-gtk3.so.4 + libnotify.so.4 ) WORKDIR="$(mktemp -d)" @@ -89,18 +111,76 @@ install -Dm644 "$REPO_ROOT/resources/icons/scalable/vocalinux.svg" \ copy_typelibs() { local dest="$1" + local require_all="${2:-0}" mkdir -p "$dest" - local typelib found + local typelib found missing=() indicator_found=0 for typelib in "${TYPELIBS[@]}"; do - found="$(find /usr/lib -name "${typelib}.typelib" 2>/dev/null | head -1 || true)" + 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. + local hard_missing=() + for typelib in "${missing[@]}"; do + case " ${INDICATOR_TYPELIBS[*]} " in + *" ${typelib} "*) + if [ "$indicator_found" -eq 0 ]; then + hard_missing+=("$typelib") + fi + ;; + *) + hard_missing+=("$typelib") + ;; + 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" + mkdir -p "$dest" + local lib found + for lib in "${GI_RUNTIME_LIBS[@]}"; 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 } echo "== Copying GObject-Introspection typelibs (not handled by linuxdeploy-plugin-gtk) ==" -copy_typelibs "$APPDIR/usr/lib/girepository-1.0" +copy_typelibs "$APPDIR/usr/lib/girepository-1.0" 0 + +echo "== Copying GI runtime shared libraries (AppIndicator/Notify) ==" +copy_gi_runtime_libs "$APPDIR/usr/lib" echo "== Writing AppRun ==" cat > "$APPDIR/AppRun" << 'APPRUN' @@ -126,9 +206,10 @@ export DEPLOY_GTK_VERSION=3 -d "$APPDIR/usr/share/applications/vocalinux.desktop" \ -i "$APPDIR/usr/share/icons/hicolor/scalable/apps/vocalinux.svg" -echo "== Pruning typelibs back to the allowlist (linuxdeploy copies the host set) ==" -rm -rf "$APPDIR/usr/lib/girepository-1.0" -copy_typelibs "$APPDIR/usr/lib/girepository-1.0" +# 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 echo "== Patching linuxdeploy GTK AppRun hook (Wayland-friendly GDK backend) ==" GTK_HOOK="$APPDIR/apprun-hooks/linuxdeploy-plugin-gtk.sh" @@ -166,6 +247,51 @@ 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 +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" diff --git a/src/vocalinux/main.py b/src/vocalinux/main.py index 4087264..8fec11e 100644 --- a/src/vocalinux/main.py +++ b/src/vocalinux/main.py @@ -104,28 +104,39 @@ def check_dependencies(): gi.require_version("Gtk", "3.0") from gi.repository import Gtk # noqa: F401 - except (ImportError, ValueError): + except (ImportError, ValueError) as e: + logger.debug("GTK import failed: %s", e) missing_system_deps.append( "GTK3 (install with: sudo apt install python3-gi gir1.2-gtk-3.0)" ) # Prefer Ayatana AppIndicator (maintained; registers on KDE Plasma). - # Legacy Canonical AppIndicator3 is only accepted as a fallback. + # Match tray_indicator.py: canonical Ayatana, rare lowercase typelib, then + # legacy Canonical AppIndicator3 as last resort. try: import gi gi.require_version("AyatanaAppIndicator3", "0.1") from gi.repository import AyatanaAppIndicator3 # noqa: F401 - except (ImportError, ValueError): + except (ImportError, ValueError) as e: + logger.debug("AyatanaAppIndicator3 import failed: %s", e) try: import gi - gi.require_version("AppIndicator3", "0.1") - from gi.repository import AppIndicator3 # noqa: F401 - except (ImportError, ValueError): - missing_system_deps.append( - "AppIndicator3/AyatanaAppIndicator3 - Required for system tray icon" - ) + gi.require_version("AyatanaAppindicator3", "0.1") + from gi.repository import AyatanaAppindicator3 # noqa: F401 + except (ImportError, ValueError) as e2: + logger.debug("AyatanaAppindicator3 import failed: %s", e2) + try: + import gi + + gi.require_version("AppIndicator3", "0.1") + from gi.repository import AppIndicator3 # noqa: F401 + except (ImportError, ValueError) as e3: + logger.debug("AppIndicator3 import failed: %s", e3) + missing_system_deps.append( + "AppIndicator3/AyatanaAppIndicator3 - Required for system tray icon" + ) # Keyboard backends are optional and checked lazily by the shortcut manager. # Importing pynput can fail on Wayland/X-less sessions even when installed. diff --git a/tests/test_appimage_packaging.py b/tests/test_appimage_packaging.py new file mode 100644 index 0000000..848ebc0 --- /dev/null +++ b/tests/test_appimage_packaging.py @@ -0,0 +1,58 @@ +"""Regression guards for AppImage packaging.""" + +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +BUILD_SH = REPO_ROOT / "packaging" / "appimage" / "build.sh" + +# Typelibs that must ship in the AppImage. xlib/Dbusmenu/GModule/fontconfig are +# transitive GIR deps — pruning them broke Gtk on openSUSE (#585). +REQUIRED_TYPELIBS = ( + "Gtk-3.0", + "Gdk-3.0", + "xlib-2.0", + "GModule-2.0", + "Dbusmenu-0.4", + "fontconfig-2.0", + "Notify-0.7", +) + +# Tray stacks are alternates (same order as tray_indicator.py). +INDICATOR_TYPELIBS = ( + "AyatanaAppIndicator3-0.1", + "AyatanaAppindicator3-0.1", + "AppIndicator3-0.1", +) + + +def test_appimage_build_ships_transitive_typelibs(): + text = BUILD_SH.read_text() + assert "TYPELIBS=(" in text + # Must not wipe linuxdeploy's typelib set down to a partial allowlist. + assert 'rm -rf "$APPDIR/usr/lib/girepository-1.0"' not in text + assert "keeping linuxdeploy extras" in text or "Ensuring required typelibs" in text + for typelib in REQUIRED_TYPELIBS: + assert typelib in text, f"missing typelib seed {typelib} in {BUILD_SH}" + for typelib in INDICATOR_TYPELIBS: + assert typelib in text, f"missing indicator typelib seed {typelib}" + assert "INDICATOR_TYPELIBS=" in text + assert "indicator_found" in text + assert "Need at least one of:" in text + + +def test_appimage_build_bundles_gi_runtime_libs(): + text = BUILD_SH.read_text() + for lib in ( + "libappindicator3.so.1", + "libayatana-appindicator3.so.1", + "libdbusmenu-glib.so.4", + "libdbusmenu-gtk3.so.4", + "libnotify.so.4", + ): + assert lib in text, f"missing GI runtime lib {lib} in {BUILD_SH}" + + +def test_appimage_build_smokes_gi_without_host_typelibs(): + text = BUILD_SH.read_text() + assert "smoke_gi_imports" in text + assert "unshare --user --mount" in text diff --git a/tests/test_main.py b/tests/test_main.py index 0721303..ee66d19 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -732,8 +732,8 @@ class TestCheckDependencies(unittest.TestCase): """Test when Ayatana is missing but legacy AppIndicator3 is available.""" def require_version_side_effect(name, version): - if name == "AyatanaAppIndicator3": - raise ValueError("AyatanaAppIndicator3 not found") + if name in ("AyatanaAppIndicator3", "AyatanaAppindicator3"): + raise ValueError(f"{name} not found") mock_gi = MagicMock() mock_gi.require_version = MagicMock(side_effect=require_version_side_effect) @@ -755,12 +755,43 @@ class TestCheckDependencies(unittest.TestCase): result = check_dependencies() self.assertTrue(result) + def test_check_dependencies_falls_back_to_lowercase_ayatana(self): + """Test rare lowercase AyatanaAppindicator3 typelib is accepted.""" + + def require_version_side_effect(name, version): + if name == "AyatanaAppIndicator3": + raise ValueError("AyatanaAppIndicator3 not found") + + mock_gi = MagicMock() + mock_gi.require_version = MagicMock(side_effect=require_version_side_effect) + mock_gtk = MagicMock() + mock_ayatana_lower = MagicMock() + mock_pynput = MagicMock() + mock_requests = MagicMock() + + with patch.dict( + "sys.modules", + { + "gi": mock_gi, + "gi.repository": MagicMock(Gtk=mock_gtk, AyatanaAppindicator3=mock_ayatana_lower), + "pynput": mock_pynput, + "requests": mock_requests, + }, + ): + with patch("vocalinux.main.logger"): + result = check_dependencies() + self.assertTrue(result) + def test_check_dependencies_missing_both_appindicators(self): """Test when both AppIndicator3 and AyatanaAppIndicator3 are missing.""" - # Make gi.require_version raise ValueError for both AppIndicator variants + # Make gi.require_version raise ValueError for all AppIndicator variants def require_version_side_effect(name, version): - if name in ("AppIndicator3", "AyatanaAppIndicator3"): + if name in ( + "AppIndicator3", + "AyatanaAppIndicator3", + "AyatanaAppindicator3", + ): raise ValueError(f"{name} not found") mock_gi = MagicMock()