fix(appimage): smoke-test fixes for tray, labels, and packaging (#602)

* fix(ui): AppImage smoke fixes for tray icons, labels, and identity

Replace placeholder mic tray SVGs with opaque branded icons, show
whisper.cpp instead of Whisper_cpp, set GLib/AppIndicator identity to
Vocalinux (not main.py), and give About dialog buttons enough padding
so labels are not clipped under Adwaita.

* fix(packaging): harden AppImage build for Wayland and tray deps

Pin the build interpreter, install onnxruntime into the bundle, set
desktop Exec=AppRun + StartupWMClass, prune typelibs to an allowlist
after linuxdeploy, and stop forcing GDK_BACKEND=x11 on Wayland
(VOCALINUX_FORCE_X11=1 remains as an escape hatch). CI AppImage jobs
also pull Ayatana AppIndicator and librsvg SVG loader packages.

* fix(packaging): bundle full stdlib from base_prefix in AppImage

When build.sh runs under a venv, sys.prefix only has site-packages, so
the AppImage was missing encodings and crashed on start. Copy the
interpreter and stdlib from sys.base_prefix, force pip
--ignore-installed into AppDir, set Gdk program class to Vocalinux, and
tone down the muted tray slash so small StatusNotifier icons stay slate.

* fix(tray): use absolute icon paths so stale hicolor icons lose

XFCE StatusNotifier resolves IconName through the system theme and can
ignore IconThemePath. A leftover ~/.local/share/icons placeholder
(vocalinux-microphone-off.svg filled #e74c3c) was winning over the
AppImage's bundled icons. Outside Flatpak, pass absolute SVG paths to
set_icon_full / new_with_path instead of bare theme names.
This commit is contained in:
Jatin K Malik
2026-07-27 19:57:07 -07:00
committed by GitHub
parent e7022f83dd
commit 4fad897f14
19 changed files with 282 additions and 113 deletions
+8 -2
View File
@@ -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: |
+8 -2
View File
@@ -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: |
+3
View File
@@ -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 \
+90 -16
View File
@@ -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"
@@ -1,8 +1,12 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
<!-- Placeholder for microphone off icon -->
<circle cx="64" cy="64" r="60" fill="#e74c3c" />
<rect x="44" y="40" width="40" height="60" rx="20" fill="#7f8c8d" />
<line x1="40" y1="88" x2="88" y2="40" stroke="#e74c3c" stroke-width="8" />
<!-- This is a placeholder. Replace with actual custom design. -->
<!-- Idle / muted: slate disc; keep slash subtle so 22px trays don't look like a red badge -->
<circle cx="64" cy="64" r="60" fill="#1e293b"/>
<!-- Mic body -->
<rect x="52" y="28" width="24" height="40" rx="12" fill="#94a3b8"/>
<!-- Mic stand -->
<path d="M40 68c0 13.255 10.745 24 24 24s24-10.745 24-24" fill="none" stroke="#94a3b8" stroke-width="6" stroke-linecap="round"/>
<line x1="64" y1="92" x2="64" y2="102" stroke="#94a3b8" stroke-width="6" stroke-linecap="round"/>
<line x1="50" y1="102" x2="78" y2="102" stroke="#94a3b8" stroke-width="6" stroke-linecap="round"/>
<!-- Mute slash: light outline only so the slate disc stays the dominant color -->
<line x1="36" y1="96" x2="92" y2="32" stroke="#f8fafc" stroke-width="8" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 465 B

After

Width:  |  Height:  |  Size: 869 B

@@ -1,16 +1,14 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
<!-- Placeholder for microphone processing icon -->
<circle cx="64" cy="64" r="60" fill="#f39c12" />
<rect x="44" y="40" width="40" height="60" rx="20" fill="#2c3e50" />
<circle cx="64" cy="40" r="8" fill="#f39c12">
<animate attributeName="cy" values="40;50;40" dur="1s" repeatCount="indefinite" />
</circle>
<circle cx="48" cy="60" r="8" fill="#f39c12">
<animate attributeName="cy" values="60;70;60" dur="1s" repeatCount="indefinite" />
</circle>
<circle cx="80" cy="60" r="8" fill="#f39c12">
<animate attributeName="cy" values="60;70;60" dur="1s" repeatCount="indefinite" />
</circle>
<!-- This is a placeholder. Replace with actual custom design. -->
<!-- Processing: amber disc, opaque for StatusNotifier trays -->
<circle cx="64" cy="64" r="60" fill="#d97706"/>
<!-- Mic body -->
<rect x="52" y="28" width="24" height="40" rx="12" fill="#1c1917"/>
<!-- Mic stand -->
<path d="M40 68c0 13.255 10.745 24 24 24s24-10.745 24-24" fill="none" stroke="#1c1917" stroke-width="6" stroke-linecap="round"/>
<line x1="64" y1="92" x2="64" y2="102" stroke="#1c1917" stroke-width="6" stroke-linecap="round"/>
<line x1="50" y1="102" x2="78" y2="102" stroke="#1c1917" stroke-width="6" stroke-linecap="round"/>
<!-- Processing dots -->
<circle cx="44" cy="52" r="6" fill="#fffbeb"/>
<circle cx="64" cy="44" r="6" fill="#fffbeb"/>
<circle cx="84" cy="52" r="6" fill="#fffbeb"/>
</svg>

Before

Width:  |  Height:  |  Size: 836 B

After

Width:  |  Height:  |  Size: 829 B

@@ -1,9 +1,15 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
<!-- Placeholder for microphone on/active icon -->
<circle cx="64" cy="64" r="60" fill="#2ecc71" />
<rect x="44" y="40" width="40" height="60" rx="20" fill="#2c3e50" />
<path d="M 40,78 Q 64,88 88,78" stroke="#2ecc71" stroke-width="4" fill="none" />
<path d="M 40,88 Q 64,98 88,88" stroke="#2ecc71" stroke-width="4" fill="none" />
<!-- This is a placeholder. Replace with actual custom design. -->
<!-- Active / listening: branded teal disc, opaque for StatusNotifier trays -->
<circle cx="64" cy="64" r="60" fill="#0d9488"/>
<!-- Mic body -->
<rect x="52" y="28" width="24" height="40" rx="12" fill="#0f172a"/>
<!-- Mic stand -->
<path d="M40 68c0 13.255 10.745 24 24 24s24-10.745 24-24" fill="none" stroke="#0f172a" stroke-width="6" stroke-linecap="round"/>
<line x1="64" y1="92" x2="64" y2="102" stroke="#0f172a" stroke-width="6" stroke-linecap="round"/>
<line x1="50" y1="102" x2="78" y2="102" stroke="#0f172a" stroke-width="6" stroke-linecap="round"/>
<!-- Sound waves -->
<path d="M86 48c4 4 6 9 6 16s-2 12-6 16" fill="none" stroke="#ecfdf5" stroke-width="5" stroke-linecap="round"/>
<path d="M96 40c7 7 11 15 11 24s-4 17-11 24" fill="none" stroke="#ecfdf5" stroke-width="5" stroke-linecap="round"/>
<path d="M42 48c-4 4-6 9-6 16s2 12 6 16" fill="none" stroke="#ecfdf5" stroke-width="5" stroke-linecap="round"/>
<path d="M32 40c-7 7-11 15-11 24s4 17 11 24" fill="none" stroke="#ecfdf5" stroke-width="5" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 560 B

After

Width:  |  Height:  |  Size: 1.1 KiB

+16
View File
@@ -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)
@@ -1,8 +1,12 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
<!-- Placeholder for microphone off icon -->
<circle cx="64" cy="64" r="60" fill="#e74c3c" />
<rect x="44" y="40" width="40" height="60" rx="20" fill="#7f8c8d" />
<line x1="40" y1="88" x2="88" y2="40" stroke="#e74c3c" stroke-width="8" />
<!-- This is a placeholder. Replace with actual custom design. -->
<!-- Idle / muted: slate disc; keep slash subtle so 22px trays don't look like a red badge -->
<circle cx="64" cy="64" r="60" fill="#1e293b"/>
<!-- Mic body -->
<rect x="52" y="28" width="24" height="40" rx="12" fill="#94a3b8"/>
<!-- Mic stand -->
<path d="M40 68c0 13.255 10.745 24 24 24s24-10.745 24-24" fill="none" stroke="#94a3b8" stroke-width="6" stroke-linecap="round"/>
<line x1="64" y1="92" x2="64" y2="102" stroke="#94a3b8" stroke-width="6" stroke-linecap="round"/>
<line x1="50" y1="102" x2="78" y2="102" stroke="#94a3b8" stroke-width="6" stroke-linecap="round"/>
<!-- Mute slash: light outline only so the slate disc stays the dominant color -->
<line x1="36" y1="96" x2="92" y2="32" stroke="#f8fafc" stroke-width="8" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 465 B

After

Width:  |  Height:  |  Size: 869 B

@@ -1,16 +1,14 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
<!-- Placeholder for microphone processing icon -->
<circle cx="64" cy="64" r="60" fill="#f39c12" />
<rect x="44" y="40" width="40" height="60" rx="20" fill="#2c3e50" />
<circle cx="64" cy="40" r="8" fill="#f39c12">
<animate attributeName="cy" values="40;50;40" dur="1s" repeatCount="indefinite" />
</circle>
<circle cx="48" cy="60" r="8" fill="#f39c12">
<animate attributeName="cy" values="60;70;60" dur="1s" repeatCount="indefinite" />
</circle>
<circle cx="80" cy="60" r="8" fill="#f39c12">
<animate attributeName="cy" values="60;70;60" dur="1s" repeatCount="indefinite" />
</circle>
<!-- This is a placeholder. Replace with actual custom design. -->
<!-- Processing: amber disc, opaque for StatusNotifier trays -->
<circle cx="64" cy="64" r="60" fill="#d97706"/>
<!-- Mic body -->
<rect x="52" y="28" width="24" height="40" rx="12" fill="#1c1917"/>
<!-- Mic stand -->
<path d="M40 68c0 13.255 10.745 24 24 24s24-10.745 24-24" fill="none" stroke="#1c1917" stroke-width="6" stroke-linecap="round"/>
<line x1="64" y1="92" x2="64" y2="102" stroke="#1c1917" stroke-width="6" stroke-linecap="round"/>
<line x1="50" y1="102" x2="78" y2="102" stroke="#1c1917" stroke-width="6" stroke-linecap="round"/>
<!-- Processing dots -->
<circle cx="44" cy="52" r="6" fill="#fffbeb"/>
<circle cx="64" cy="44" r="6" fill="#fffbeb"/>
<circle cx="84" cy="52" r="6" fill="#fffbeb"/>
</svg>

Before

Width:  |  Height:  |  Size: 836 B

After

Width:  |  Height:  |  Size: 829 B

@@ -1,9 +1,15 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
<!-- Placeholder for microphone on/active icon -->
<circle cx="64" cy="64" r="60" fill="#2ecc71" />
<rect x="44" y="40" width="40" height="60" rx="20" fill="#2c3e50" />
<path d="M 40,78 Q 64,88 88,78" stroke="#2ecc71" stroke-width="4" fill="none" />
<path d="M 40,88 Q 64,98 88,88" stroke="#2ecc71" stroke-width="4" fill="none" />
<!-- This is a placeholder. Replace with actual custom design. -->
<!-- Active / listening: branded teal disc, opaque for StatusNotifier trays -->
<circle cx="64" cy="64" r="60" fill="#0d9488"/>
<!-- Mic body -->
<rect x="52" y="28" width="24" height="40" rx="12" fill="#0f172a"/>
<!-- Mic stand -->
<path d="M40 68c0 13.255 10.745 24 24 24s24-10.745 24-24" fill="none" stroke="#0f172a" stroke-width="6" stroke-linecap="round"/>
<line x1="64" y1="92" x2="64" y2="102" stroke="#0f172a" stroke-width="6" stroke-linecap="round"/>
<line x1="50" y1="102" x2="78" y2="102" stroke="#0f172a" stroke-width="6" stroke-linecap="round"/>
<!-- Sound waves -->
<path d="M86 48c4 4 6 9 6 16s-2 12-6 16" fill="none" stroke="#ecfdf5" stroke-width="5" stroke-linecap="round"/>
<path d="M96 40c7 7 11 15 11 24s-4 17-11 24" fill="none" stroke="#ecfdf5" stroke-width="5" stroke-linecap="round"/>
<path d="M42 48c-4 4-6 9-6 16s2 12 6 16" fill="none" stroke="#ecfdf5" stroke-width="5" stroke-linecap="round"/>
<path d="M32 40c-7 7-11 15-11 24s4 17 11 24" fill="none" stroke="#ecfdf5" stroke-width="5" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 560 B

After

Width:  |  Height:  |  Size: 1.1 KiB

+5
View File
@@ -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):
+2 -3
View File
@@ -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,
+18 -5
View File
@@ -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)
+18
View File
@@ -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
+9 -4
View File
@@ -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):
+10 -6
View File
@@ -1,8 +1,12 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
<!-- Placeholder for microphone off icon -->
<circle cx="64" cy="64" r="60" fill="#e74c3c" />
<rect x="44" y="40" width="40" height="60" rx="20" fill="#7f8c8d" />
<line x1="40" y1="88" x2="88" y2="40" stroke="#e74c3c" stroke-width="8" />
<!-- This is a placeholder. Replace with actual custom design. -->
<!-- Idle / muted: slate disc; keep slash subtle so 22px trays don't look like a red badge -->
<circle cx="64" cy="64" r="60" fill="#1e293b"/>
<!-- Mic body -->
<rect x="52" y="28" width="24" height="40" rx="12" fill="#94a3b8"/>
<!-- Mic stand -->
<path d="M40 68c0 13.255 10.745 24 24 24s24-10.745 24-24" fill="none" stroke="#94a3b8" stroke-width="6" stroke-linecap="round"/>
<line x1="64" y1="92" x2="64" y2="102" stroke="#94a3b8" stroke-width="6" stroke-linecap="round"/>
<line x1="50" y1="102" x2="78" y2="102" stroke="#94a3b8" stroke-width="6" stroke-linecap="round"/>
<!-- Mute slash: light outline only so the slate disc stays the dominant color -->
<line x1="36" y1="96" x2="92" y2="32" stroke="#f8fafc" stroke-width="8" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 465 B

After

Width:  |  Height:  |  Size: 869 B

+12 -14
View File
@@ -1,16 +1,14 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
<!-- Placeholder for microphone processing icon -->
<circle cx="64" cy="64" r="60" fill="#f39c12" />
<rect x="44" y="40" width="40" height="60" rx="20" fill="#2c3e50" />
<circle cx="64" cy="40" r="8" fill="#f39c12">
<animate attributeName="cy" values="40;50;40" dur="1s" repeatCount="indefinite" />
</circle>
<circle cx="48" cy="60" r="8" fill="#f39c12">
<animate attributeName="cy" values="60;70;60" dur="1s" repeatCount="indefinite" />
</circle>
<circle cx="80" cy="60" r="8" fill="#f39c12">
<animate attributeName="cy" values="60;70;60" dur="1s" repeatCount="indefinite" />
</circle>
<!-- This is a placeholder. Replace with actual custom design. -->
<!-- Processing: amber disc, opaque for StatusNotifier trays -->
<circle cx="64" cy="64" r="60" fill="#d97706"/>
<!-- Mic body -->
<rect x="52" y="28" width="24" height="40" rx="12" fill="#1c1917"/>
<!-- Mic stand -->
<path d="M40 68c0 13.255 10.745 24 24 24s24-10.745 24-24" fill="none" stroke="#1c1917" stroke-width="6" stroke-linecap="round"/>
<line x1="64" y1="92" x2="64" y2="102" stroke="#1c1917" stroke-width="6" stroke-linecap="round"/>
<line x1="50" y1="102" x2="78" y2="102" stroke="#1c1917" stroke-width="6" stroke-linecap="round"/>
<!-- Processing dots -->
<circle cx="44" cy="52" r="6" fill="#fffbeb"/>
<circle cx="64" cy="44" r="6" fill="#fffbeb"/>
<circle cx="84" cy="52" r="6" fill="#fffbeb"/>
</svg>

Before

Width:  |  Height:  |  Size: 836 B

After

Width:  |  Height:  |  Size: 829 B

+13 -7
View File
@@ -1,9 +1,15 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
<!-- Placeholder for microphone on/active icon -->
<circle cx="64" cy="64" r="60" fill="#2ecc71" />
<rect x="44" y="40" width="40" height="60" rx="20" fill="#2c3e50" />
<path d="M 40,78 Q 64,88 88,78" stroke="#2ecc71" stroke-width="4" fill="none" />
<path d="M 40,88 Q 64,98 88,88" stroke="#2ecc71" stroke-width="4" fill="none" />
<!-- This is a placeholder. Replace with actual custom design. -->
<!-- Active / listening: branded teal disc, opaque for StatusNotifier trays -->
<circle cx="64" cy="64" r="60" fill="#0d9488"/>
<!-- Mic body -->
<rect x="52" y="28" width="24" height="40" rx="12" fill="#0f172a"/>
<!-- Mic stand -->
<path d="M40 68c0 13.255 10.745 24 24 24s24-10.745 24-24" fill="none" stroke="#0f172a" stroke-width="6" stroke-linecap="round"/>
<line x1="64" y1="92" x2="64" y2="102" stroke="#0f172a" stroke-width="6" stroke-linecap="round"/>
<line x1="50" y1="102" x2="78" y2="102" stroke="#0f172a" stroke-width="6" stroke-linecap="round"/>
<!-- Sound waves -->
<path d="M86 48c4 4 6 9 6 16s-2 12-6 16" fill="none" stroke="#ecfdf5" stroke-width="5" stroke-linecap="round"/>
<path d="M96 40c7 7 11 15 11 24s-4 17-11 24" fill="none" stroke="#ecfdf5" stroke-width="5" stroke-linecap="round"/>
<path d="M42 48c-4 4-6 9-6 16s2 12 6 16" fill="none" stroke="#ecfdf5" stroke-width="5" stroke-linecap="round"/>
<path d="M32 40c-7 7-11 15-11 24s4 17 11 24" fill="none" stroke="#ecfdf5" stroke-width="5" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 560 B

After

Width:  |  Height:  |  Size: 1.1 KiB