diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml
index ad87f93..305048e 100644
--- a/.github/workflows/nightly.yml
+++ b/.github/workflows/nightly.yml
@@ -42,7 +42,10 @@ jobs:
gobject-introspection \
gir1.2-gtk-3.0 \
gir1.2-appindicator3-0.1 \
- gir1.2-ibus-1.0
+ gir1.2-ayatanaappindicator3-0.1 \
+ gir1.2-ibus-1.0 \
+ gir1.2-rsvg-2.0 \
+ librsvg2-common
- name: Install build dependencies
run: |
@@ -284,7 +287,10 @@ jobs:
gobject-introspection \
gir1.2-gtk-3.0 \
gir1.2-appindicator3-0.1 \
- gir1.2-ibus-1.0
+ gir1.2-ayatanaappindicator3-0.1 \
+ gir1.2-ibus-1.0 \
+ gir1.2-rsvg-2.0 \
+ librsvg2-common
- name: Install build dependencies
run: |
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 30c152e..0125ab8 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -42,7 +42,10 @@ jobs:
gobject-introspection \
gir1.2-gtk-3.0 \
gir1.2-appindicator3-0.1 \
- gir1.2-ibus-1.0
+ gir1.2-ayatanaappindicator3-0.1 \
+ gir1.2-ibus-1.0 \
+ gir1.2-rsvg-2.0 \
+ librsvg2-common
- name: Install build dependencies
run: |
@@ -163,7 +166,10 @@ jobs:
gobject-introspection \
gir1.2-gtk-3.0 \
gir1.2-appindicator3-0.1 \
- gir1.2-ibus-1.0
+ gir1.2-ayatanaappindicator3-0.1 \
+ gir1.2-ibus-1.0 \
+ gir1.2-rsvg-2.0 \
+ librsvg2-common
- name: Install build dependencies
run: |
diff --git a/.github/workflows/unified-pipeline.yml b/.github/workflows/unified-pipeline.yml
index 5c1a194..3b3e4af 100644
--- a/.github/workflows/unified-pipeline.yml
+++ b/.github/workflows/unified-pipeline.yml
@@ -198,7 +198,10 @@ jobs:
sudo apt-get install -y \
xdotool \
gir1.2-appindicator3-0.1 \
+ gir1.2-ayatanaappindicator3-0.1 \
gir1.2-ibus-1.0 \
+ gir1.2-rsvg-2.0 \
+ librsvg2-common \
libcairo2-dev \
pkg-config \
python3-dev \
diff --git a/packaging/appimage/build.sh b/packaging/appimage/build.sh
index 61e5066..0d1975e 100755
--- a/packaging/appimage/build.sh
+++ b/packaging/appimage/build.sh
@@ -14,6 +14,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 note: the pip pywhispercpp wheel is CPU-only. Vulkan/CUDA rebuilds
+# stay on install.sh for now; the AppImage logs when GPU libs are absent.
set -euo pipefail
WHEEL="$1"
@@ -21,18 +24,28 @@ VERSION="$2"
OUTDIR="${3:-dist}"
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
ARCH="$(uname -m)"
+PYTHON="${PYTHON:-python3}"
case "$ARCH" in
x86_64|aarch64) ;;
*) echo "Unsupported architecture: $ARCH (need x86_64 or aarch64)" >&2; exit 1 ;;
esac
+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
+)
+
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}"
+
echo "== Fetching AppImage tooling ($ARCH) =="
curl -fsSL -o "$TOOLDIR/linuxdeploy" \
"https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-${ARCH}.AppImage"
@@ -42,31 +55,52 @@ curl -fsSL -o "$TOOLDIR/appimagetool" \
"https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-${ARCH}.AppImage"
chmod +x "$TOOLDIR/linuxdeploy" "$TOOLDIR/linuxdeploy-plugin-gtk.sh" "$TOOLDIR/appimagetool"
-echo "== Bundling Python runtime =="
-PY_PREFIX="$(python3 -c 'import sys; print(sys.prefix)')"
-PY_VER="$(python3 -c 'import sys; print("%d.%d" % sys.version_info[:2])')"
-cp -L "$(command -v python3)" "$APPDIR/usr/bin/python3"
+echo "== Bundling Python runtime ($PYTHON) =="
+# Use base_prefix so a venv builder still ships a full stdlib (encodings, etc.).
+PY_PREFIX="$("$PYTHON" -c 'import sys; print(sys.base_prefix)')"
+PY_VER="$("$PYTHON" -c 'import sys; print("%d.%d" % sys.version_info[:2])')"
+PY_BIN="$("$PYTHON" -c 'import sys; print(sys.base_prefix)')/bin/python${PY_VER}"
+if [ ! -x "$PY_BIN" ]; then
+ PY_BIN="$("$PYTHON" -c 'import sys; print(sys.base_prefix)')/bin/python3"
+fi
+if [ ! -x "$PY_BIN" ]; then
+ PY_BIN="$("$PYTHON" -c 'import sys; print(sys.executable)')"
+fi
+cp -L "$PY_BIN" "$APPDIR/usr/bin/python3"
cp -r "$PY_PREFIX/lib/python${PY_VER}" "$APPDIR/usr/lib/python${PY_VER}"
rm -rf "$APPDIR/usr/lib/python${PY_VER}/site-packages"
-echo "== Installing Vocalinux + PyGObject into the bundle =="
-python3 -m pip install --no-cache-dir --prefix "$APPDIR/usr" "$WHEEL" PyGObject pycairo
+echo "== Installing Vocalinux + runtime deps into the bundle =="
+# --ignore-installed: pip otherwise treats the builder env's packages as
+# satisfying deps and skips copying vosk/pywhispercpp/etc. into AppDir.
+"$PYTHON" -m pip install --no-cache-dir --ignore-installed --prefix "$APPDIR/usr" \
+ "$WHEEL" PyGObject pycairo onnxruntime
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"
+ mkdir -p "$dest"
+ local typelib found
+ for typelib in "${TYPELIBS[@]}"; do
+ found="$(find /usr/lib -name "${typelib}.typelib" 2>/dev/null | head -1 || true)"
+ if [ -n "$found" ]; then
+ cp "$found" "$dest/"
+ fi
+ done
+}
+
echo "== Copying GObject-Introspection typelibs (not handled by linuxdeploy-plugin-gtk) =="
-mkdir -p "$APPDIR/usr/lib/girepository-1.0"
-for typelib in 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; do
- found="$(find /usr/lib -name "${typelib}.typelib" 2>/dev/null | head -1)"
- if [ -n "$found" ]; then
- cp "$found" "$APPDIR/usr/lib/girepository-1.0/"
- fi
-done
+copy_typelibs "$APPDIR/usr/lib/girepository-1.0"
echo "== Writing AppRun =="
cat > "$APPDIR/AppRun" << 'APPRUN'
@@ -81,7 +115,7 @@ 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 -s "python${PY_VER}" "$APPDIR/usr/lib/python3"
+ln -sfn "python${PY_VER}" "$APPDIR/usr/lib/python3"
chmod +x "$APPDIR/AppRun"
echo "== Running linuxdeploy (resolves the shared-library closure + GTK theming) =="
@@ -92,6 +126,46 @@ 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"
+
+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.
+ 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 "== Packaging AppImage =="
OUTPUT="$OUTDIR/Vocalinux-${VERSION}-${ARCH}.AppImage"
ARCH="$ARCH" "$TOOLDIR/appimagetool" "$APPDIR" "$OUTPUT"
diff --git a/resources/icons/scalable/vocalinux-microphone-off.svg b/resources/icons/scalable/vocalinux-microphone-off.svg
index a5f8b2f..c7ec58b 100644
--- a/resources/icons/scalable/vocalinux-microphone-off.svg
+++ b/resources/icons/scalable/vocalinux-microphone-off.svg
@@ -1,8 +1,12 @@
-
diff --git a/resources/icons/scalable/vocalinux-microphone-process.svg b/resources/icons/scalable/vocalinux-microphone-process.svg
index 6fa2d08..6bc3572 100644
--- a/resources/icons/scalable/vocalinux-microphone-process.svg
+++ b/resources/icons/scalable/vocalinux-microphone-process.svg
@@ -1,16 +1,14 @@
-
diff --git a/resources/icons/scalable/vocalinux-microphone.svg b/resources/icons/scalable/vocalinux-microphone.svg
index a3f86d0..33225bf 100644
--- a/resources/icons/scalable/vocalinux-microphone.svg
+++ b/resources/icons/scalable/vocalinux-microphone.svg
@@ -1,9 +1,15 @@
-
diff --git a/src/vocalinux/main.py b/src/vocalinux/main.py
index 45c9225..eb3362e 100644
--- a/src/vocalinux/main.py
+++ b/src/vocalinux/main.py
@@ -263,6 +263,22 @@ def main():
logger.error("Cannot start Vocalinux due to missing dependencies")
sys.exit(1)
+ # Identity for WM class / AppIndicator title (otherwise shows as main.py)
+ import gi
+
+ gi.require_version("GLib", "2.0")
+ from gi.repository import GLib
+
+ GLib.set_prgname("vocalinux")
+ GLib.set_application_name("Vocalinux")
+ try:
+ gi.require_version("Gdk", "3.0")
+ from gi.repository import Gdk
+
+ Gdk.set_program_class("Vocalinux")
+ except Exception:
+ pass
+
# Check if display is available before creating any GTK widgets
if not check_display_available():
sys.exit(1)
diff --git a/src/vocalinux/resources/icons/scalable/vocalinux-microphone-off.svg b/src/vocalinux/resources/icons/scalable/vocalinux-microphone-off.svg
index a5f8b2f..c7ec58b 100644
--- a/src/vocalinux/resources/icons/scalable/vocalinux-microphone-off.svg
+++ b/src/vocalinux/resources/icons/scalable/vocalinux-microphone-off.svg
@@ -1,8 +1,12 @@
-
diff --git a/src/vocalinux/resources/icons/scalable/vocalinux-microphone-process.svg b/src/vocalinux/resources/icons/scalable/vocalinux-microphone-process.svg
index 6fa2d08..6bc3572 100644
--- a/src/vocalinux/resources/icons/scalable/vocalinux-microphone-process.svg
+++ b/src/vocalinux/resources/icons/scalable/vocalinux-microphone-process.svg
@@ -1,16 +1,14 @@
-
diff --git a/src/vocalinux/resources/icons/scalable/vocalinux-microphone.svg b/src/vocalinux/resources/icons/scalable/vocalinux-microphone.svg
index a3f86d0..33225bf 100644
--- a/src/vocalinux/resources/icons/scalable/vocalinux-microphone.svg
+++ b/src/vocalinux/resources/icons/scalable/vocalinux-microphone.svg
@@ -1,9 +1,15 @@
-
diff --git a/src/vocalinux/ui/about_dialog.py b/src/vocalinux/ui/about_dialog.py
index 2ee2103..962b2a9 100644
--- a/src/vocalinux/ui/about_dialog.py
+++ b/src/vocalinux/ui/about_dialog.py
@@ -186,6 +186,11 @@ class AboutDialog(Gtk.Dialog):
color: @theme_unfocused_fg_color;
font-size: 0.85em;
}
+
+ .about-dialog button {
+ min-height: 32px;
+ padding: 6px 14px;
+ }
"""
def __init__(self, parent: Gtk.Window = None):
diff --git a/src/vocalinux/ui/settings_dialog.py b/src/vocalinux/ui/settings_dialog.py
index 7e615cd..61c6e4a 100644
--- a/src/vocalinux/ui/settings_dialog.py
+++ b/src/vocalinux/ui/settings_dialog.py
@@ -89,7 +89,7 @@ ENGINE_MODELS = {
ENGINE_DISPLAY_NAMES = {
"vosk": "Vosk",
"whisper": "Whisper",
- "whisper_cpp": "Whisper_cpp",
+ "whisper_cpp": "whisper.cpp",
"remote_api": "Remote API",
}
@@ -3657,8 +3657,7 @@ class SettingsDialog(Gtk.Dialog):
def _update_advanced_tab_sensitivity(self):
"""Enable or disable advanced settings based on selected engine."""
- engine_text = self.engine_combo.get_active_text()
- is_whispercpp = engine_text and engine_text.lower() == "whisper_cpp"
+ is_whispercpp = self._get_selected_engine() == "whisper_cpp"
widgets = [
self.advanced_no_timestamps_switch,
diff --git a/src/vocalinux/ui/tray_indicator.py b/src/vocalinux/ui/tray_indicator.py
index 246f2f8..c225266 100644
--- a/src/vocalinux/ui/tray_indicator.py
+++ b/src/vocalinux/ui/tray_indicator.py
@@ -117,6 +117,18 @@ class TrayIndicator:
}
self.icon_names = _themed_icon_names()
+ # Prefer absolute icon paths outside Flatpak. StatusNotifier hosts often
+ # resolve IconName via the system hicolor theme and ignore IconThemePath,
+ # so a stale ~/.local/share/icons copy of vocalinux-microphone-off.svg
+ # (the old red placeholder) would win over the AppImage's bundled icons.
+ self._icon_keys = {
+ "default": (self.icon_names["default"] if FLATPAK_ID else self.icon_paths["default"]),
+ "active": (self.icon_names["active"] if FLATPAK_ID else self.icon_paths["active"]),
+ "processing": (
+ self.icon_names["processing"] if FLATPAK_ID else self.icon_paths["processing"]
+ ),
+ }
+
# Register for speech recognition state changes
self.speech_engine.register_state_callback(self._on_recognition_state_changed)
@@ -222,7 +234,7 @@ class TrayIndicator:
exists = os.path.exists(path)
logger.info(f"Icon '{name}' ({path}): {'exists' if exists else 'missing'}")
- initial_icon = _themed_icon_names()["default"]
+ initial_icon = self._icon_keys["default"]
try:
if FLATPAK_ID:
self.indicator = AppIndicator3.Indicator.new(
@@ -239,6 +251,7 @@ class TrayIndicator:
)
self.indicator.set_icon_theme_path(ICON_DIR)
self.indicator.set_status(AppIndicator3.IndicatorStatus.ACTIVE)
+ self.indicator.set_title("Vocalinux")
except Exception as e:
logger.error(f"Failed to create AppIndicator: {e}")
GLib.idle_add(self._show_appindicator_error_dialog, str(e))
@@ -463,19 +476,19 @@ class TrayIndicator:
return False
if state == RecognitionState.IDLE:
- self.indicator.set_icon_full(self.icon_names["default"], "Microphone off")
+ self.indicator.set_icon_full(self._icon_keys["default"], "Microphone off")
self._set_menu_item_enabled("Start Voice Typing", True)
self._set_menu_item_enabled("Stop Voice Typing", False)
elif state == RecognitionState.LISTENING:
- self.indicator.set_icon_full(self.icon_names["active"], "Microphone on")
+ self.indicator.set_icon_full(self._icon_keys["active"], "Microphone on")
self._set_menu_item_enabled("Start Voice Typing", False)
self._set_menu_item_enabled("Stop Voice Typing", True)
elif state == RecognitionState.PROCESSING:
- self.indicator.set_icon_full(self.icon_names["processing"], "Processing speech")
+ self.indicator.set_icon_full(self._icon_keys["processing"], "Processing speech")
self._set_menu_item_enabled("Start Voice Typing", False)
self._set_menu_item_enabled("Stop Voice Typing", True)
elif state == RecognitionState.ERROR:
- self.indicator.set_icon_full(self.icon_names["default"], "Error")
+ self.indicator.set_icon_full(self._icon_keys["default"], "Error")
self._set_menu_item_enabled("Start Voice Typing", True)
self._set_menu_item_enabled("Stop Voice Typing", False)
diff --git a/tests/test_engine_display_names.py b/tests/test_engine_display_names.py
new file mode 100644
index 0000000..bda5d0f
--- /dev/null
+++ b/tests/test_engine_display_names.py
@@ -0,0 +1,18 @@
+"""Tests for speech-engine display name helpers."""
+
+from vocalinux.ui.settings_dialog import (
+ ENGINE_DISPLAY_NAMES,
+ _engine_display_name,
+ _engine_from_display,
+)
+
+
+def test_whisper_cpp_display_name():
+ assert ENGINE_DISPLAY_NAMES["whisper_cpp"] == "whisper.cpp"
+ assert _engine_display_name("whisper_cpp") == "whisper.cpp"
+ assert _engine_from_display("whisper.cpp") == "whisper_cpp"
+
+
+def test_engine_display_roundtrip():
+ for engine_id in ENGINE_DISPLAY_NAMES:
+ assert _engine_from_display(_engine_display_name(engine_id)) == engine_id
diff --git a/tests/test_tray_indicator.py b/tests/test_tray_indicator.py
index baaab71..6f0f745 100644
--- a/tests/test_tray_indicator.py
+++ b/tests/test_tray_indicator.py
@@ -323,7 +323,7 @@ class TestTrayIndicator(unittest.TestCase):
result = self.tray_indicator._update_ui(self.RecognitionState.LISTENING)
self.tray_indicator.indicator.set_icon_full.assert_called_once_with(
- "vocalinux-microphone", "Microphone on"
+ self.tray_indicator._icon_keys["active"], "Microphone on"
)
self.assertEqual(result, False)
@@ -340,7 +340,7 @@ class TestTrayIndicator(unittest.TestCase):
result = self.tray_indicator._update_ui(self.RecognitionState.PROCESSING)
self.tray_indicator.indicator.set_icon_full.assert_called_once_with(
- "vocalinux-microphone-process", "Processing speech"
+ self.tray_indicator._icon_keys["processing"], "Processing speech"
)
self.assertEqual(result, False)
@@ -357,7 +357,7 @@ class TestTrayIndicator(unittest.TestCase):
result = self.tray_indicator._update_ui(self.RecognitionState.ERROR)
self.tray_indicator.indicator.set_icon_full.assert_called_once_with(
- "vocalinux-microphone-off", "Error"
+ self.tray_indicator._icon_keys["default"], "Error"
)
self.assertEqual(result, False)
@@ -515,7 +515,12 @@ class TestTrayIndicator(unittest.TestCase):
from vocalinux.ui.tray_indicator import TrayIndicator
tray = TrayIndicator.__new__(TrayIndicator)
- tray.icon_paths = {}
+ tray.icon_paths = {
+ "default": "/tmp/fake-off.svg",
+ "active": "/tmp/fake-on.svg",
+ "processing": "/tmp/fake-process.svg",
+ }
+ tray._icon_keys = dict(tray.icon_paths)
tray._show_appindicator_error_dialog = MagicMock(return_value=False)
with patch("vocalinux.ui.tray_indicator.os.path.exists", return_value=False):
diff --git a/web/public/vocalinux-microphone-off.svg b/web/public/vocalinux-microphone-off.svg
index a5f8b2f..c7ec58b 100644
--- a/web/public/vocalinux-microphone-off.svg
+++ b/web/public/vocalinux-microphone-off.svg
@@ -1,8 +1,12 @@
-
diff --git a/web/public/vocalinux-microphone-process.svg b/web/public/vocalinux-microphone-process.svg
index 6fa2d08..6bc3572 100644
--- a/web/public/vocalinux-microphone-process.svg
+++ b/web/public/vocalinux-microphone-process.svg
@@ -1,16 +1,14 @@
-
diff --git a/web/public/vocalinux-microphone.svg b/web/public/vocalinux-microphone.svg
index a3f86d0..33225bf 100644
--- a/web/public/vocalinux-microphone.svg
+++ b/web/public/vocalinux-microphone.svg
@@ -1,9 +1,15 @@
-