diff --git a/.github/workflows/flatpak.yml b/.github/workflows/flatpak.yml new file mode 100644 index 0000000..28b7526 --- /dev/null +++ b/.github/workflows/flatpak.yml @@ -0,0 +1,59 @@ +name: Flatpak + +on: + push: + branches: [ main, master ] + paths: + - 'packaging/flatpak/**' + - 'src/**' + - 'pyproject.toml' + - '.github/workflows/flatpak.yml' + pull_request: + branches: [ main, master ] + paths: + - 'packaging/flatpak/**' + - 'src/**' + - 'pyproject.toml' + - '.github/workflows/flatpak.yml' + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + name: Build Flatpak (${{ matrix.arch }}) + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + - arch: x86_64 + runner: ubuntu-latest + - arch: aarch64 + runner: ubuntu-24.04-arm + container: + image: ghcr.io/flathub-infra/flatpak-github-actions:gnome-50 + options: --privileged + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Build and bundle + uses: flatpak/flatpak-github-actions/flatpak-builder@v6 + with: + bundle: vocalinux-${{ matrix.arch }}.flatpak + manifest-path: packaging/flatpak/com.vocalinux.Vocalinux.yml + cache-key: flatpak-${{ matrix.arch }}-${{ github.sha }} + arch: ${{ matrix.arch }} + # Flathub mirrors screenshots at submission time; mirror locally so the + # builddir linter does not flag the external screenshot URL. + mirror-screenshots-url: https://dl.flathub.org/media + + - name: Upload bundle + uses: actions/upload-artifact@v4 + with: + name: vocalinux-flatpak-${{ matrix.arch }} + path: vocalinux-${{ matrix.arch }}.flatpak + if-no-files-found: error diff --git a/.gitignore b/.gitignore index 9e4b6ee..881bbe1 100644 --- a/.gitignore +++ b/.gitignore @@ -83,6 +83,12 @@ out/ dist/ build/ +# Flatpak build artifacts +.flatpak-builder/ +build-dir/ +/repo/ +*.flatpak + # Environment variables .env .env.local diff --git a/README.md b/README.md index a57f65d..0a60993 100644 --- a/README.md +++ b/README.md @@ -195,6 +195,23 @@ yay -S vocalinux See [docs/AUR.md](docs/AUR.md). +### Flatpak (any distro) + +For a sandboxed, distro-independent install (great for NixOS, Fedora Silverblue, +Steam Deck, and anywhere else), build the Flatpak from the bundled manifest: + +```bash +flatpak install flathub org.gnome.Platform//50 org.gnome.Sdk//50 +flatpak-builder --user --install --force-clean build-dir \ + packaging/flatpak/com.vocalinux.Vocalinux.yml +flatpak run com.vocalinux.Vocalinux +``` + +The Flatpak ships the whisper.cpp engine with Vulkan GPU support and runs through +XWayland on Wayland sessions. See [`packaging/flatpak/README.md`](packaging/flatpak/README.md) +for build details, permissions, and Flathub submission notes. Flathub publishing +is in progress. + ### Alternative: Install from Source ```bash @@ -412,6 +429,7 @@ This script generates all three sounds using the same smooth glide algorithm. Yo - [x] ~~Vulkan GPU support~~ ✅ - [ ] In-app update mechanism - [ ] Application-specific commands +- [x] ~~Flatpak packaging~~ ✅ (Flathub submission in progress) - [ ] Debian/Ubuntu package (.deb) - [x] ~~Wayland support via IBus~~ ✅ - [ ] Voice command customization diff --git a/docs/DISTRO_COMPATIBILITY.md b/docs/DISTRO_COMPATIBILITY.md index 844a312..8fd19b7 100644 --- a/docs/DISTRO_COMPATIBILITY.md +++ b/docs/DISTRO_COMPATIBILITY.md @@ -12,7 +12,7 @@ The cross-distribution compatibility improvements have been implemented in phase | Phase 4 | ✅ Complete | CI test matrix, pkg-config as core dependency | | **Phase 5** | ✅ **Complete** | **Fixed remaining hardcoded GI_TYPELIB_PATH values in install.sh and CI workflow** | | **Phase 6** | ✅ **Complete** | **Added wrapper script verification tests, updated documentation** | -| Phase 7 | 📋 Planned | Flatpak packaging for universal distribution support | +| **Phase 7** | ✅ **Complete** | **Flatpak packaging (whisper.cpp engine) for universal distribution support — see [`packaging/flatpak/`](../packaging/flatpak/README.md). Flathub submission in progress.** | | Phase 8 | 📋 Planned | Snap packaging for Ubuntu Software Store | ## Technical Implementation diff --git a/packaging/flatpak/README.md b/packaging/flatpak/README.md new file mode 100644 index 0000000..0024d06 --- /dev/null +++ b/packaging/flatpak/README.md @@ -0,0 +1,99 @@ +# Vocalinux Flatpak Packaging + +Manifest and AppStream metadata for building Vocalinux as a Flatpak. + +## Current Scope + +The manifest ships the default `whisper_cpp` engine only. VOSK is omitted because +the PyPI package is wheel-only (no sdist), so a Flathub-ready VOSK build would +need to compile VOSK and its native deps from source. + +Global keyboard shortcuts use **evdev** (`/dev/input`). +Text injection uses **wl-copy** + **ydotool Ctrl+V** (instant paste into native +Wayland apps). Character-by-character `ydotool type` is only a fallback. +`xdotool` remains for pure X11/XWayland clients. + +Permissions: `--socket=wayland` (clipboard), `--socket=x11` (xdotool fallback), +`--device=all` (evdev hotkeys + uinput; Flatpak has no narrower uinput flag). + +## Local Build + +```bash +# one-time runtime/SDK +flatpak install flathub org.gnome.Platform//50 org.gnome.Sdk//50 + +# from repo root — add flags as needed: +# --user --install install for the current user +# --repo=repo export a local repo (then bundle or lint) +# --compose-url-policy=full --mirror-screenshots-url=https://dl.flathub.org/media +flatpak-builder --force-clean build-dir packaging/flatpak/com.vocalinux.Vocalinux.yml + +flatpak run com.vocalinux.Vocalinux --debug + +# single-file bundle (after a --repo=repo build) +flatpak build-bundle repo vocalinux.flatpak com.vocalinux.Vocalinux +# elsewhere: flatpak install --user vocalinux.flatpak +``` + +Lint (needs `org.flatpak.Builder`): + +```bash +flatpak run --command=flatpak-builder-lint org.flatpak.Builder manifest packaging/flatpak/com.vocalinux.Vocalinux.yml +flatpak run --command=flatpak-builder-lint org.flatpak.Builder builddir build-dir +flatpak run --command=flatpak-builder-lint org.flatpak.Builder repo repo # if you used --repo=repo +``` + +Prefer `flatpak run` after `--install` for GUI testing. On GNOME runtimes with +glycin loaders, `flatpak-builder --run` can fail to load themed SVG icons for an +uninstalled stable app ID; it is still fine for non-GUI smokes: + +```bash +flatpak-builder --run build-dir packaging/flatpak/com.vocalinux.Vocalinux.yml \ + python3 -c 'from pywhispercpp.model import Model; print("pywhispercpp ok")' +``` + +## Python Dependencies + +`python3-dependencies.yaml` is generated with `flatpak-pip-generator` (offline +builds). Regenerate when `pyproject.toml` deps change: + +```bash +pipx run flatpak-pip-generator \ + --runtime org.gnome.Sdk//50 \ + --yaml \ + --ignore-pkg 'vosk>=0.3.45' "PyGObject; sys_platform == 'linux'" \ + --output packaging/flatpak/python3-dependencies \ + pywhispercpp pydub pynput evdev requests tqdm numpy pyaudio python-xlib psutil lxml \ + meson-python pyproject-metadata +``` + +`python3-build-dependencies.yaml` is a small hand-maintained helper so +`--no-build-isolation` builds can import `mesonpy` before NumPy is built. + +## Flathub Submission Notes + +1. Change the `vocalinux` module source from `type: dir` to a tagged release: + + ```yaml + sources: + - type: git + url: https://github.com/jatinkrmalik/vocalinux.git + tag: v0.14.0-beta + commit: + ``` + +2. Keep build-time network access disabled; only declared sources. +3. Re-run AppStream validation and `flatpak-builder` locally. +4. Be ready to justify sandbox permissions (IBus and StatusNotifier D-Bus talk names). + +## Manifest Details + +- Runtime / SDK: `org.gnome.Platform//50`, `org.gnome.Sdk//50` +- Mic: `--socket=pulseaudio` · GPU: `--device=dri` · models: `--share=network` +- Input: `--device=all` (evdev hotkeys + ydotool/`uinput`) +- Injection: packaged `wl-copy`, `ydotool`/`ydotoold`, plus `xdotool`/`xsel` fallback +- Display: `--socket=wayland` (clipboard) and `--socket=x11` (xdotool fallback) +- IBus: `--talk-name=org.freedesktop.IBus` +- Tray: `--talk-name=org.kde.StatusNotifierWatcher` + +Config and models use Flatpak XDG dirs under `~/.var/app/com.vocalinux.Vocalinux/`. diff --git a/packaging/flatpak/com.vocalinux.Vocalinux.desktop b/packaging/flatpak/com.vocalinux.Vocalinux.desktop new file mode 100644 index 0000000..d1a0f04 --- /dev/null +++ b/packaging/flatpak/com.vocalinux.Vocalinux.desktop @@ -0,0 +1,14 @@ +[Desktop Entry] +Version=1.0 +Type=Application +Name=Vocalinux +Comment=Free, offline voice dictation for Linux +GenericName=Voice Typing +Exec=vocalinux +Icon=com.vocalinux.Vocalinux +Terminal=false +Categories=Utility;Accessibility;AudioVideo; +Keywords=voice;typing;dictation;speech;recognition;transcription;accessibility; +StartupNotify=true +StartupWMClass=com.vocalinux.Vocalinux +X-GNOME-UsesNotifications=true diff --git a/packaging/flatpak/com.vocalinux.Vocalinux.metainfo.xml b/packaging/flatpak/com.vocalinux.Vocalinux.metainfo.xml new file mode 100644 index 0000000..4b2436b --- /dev/null +++ b/packaging/flatpak/com.vocalinux.Vocalinux.metainfo.xml @@ -0,0 +1,129 @@ + + + com.vocalinux.Vocalinux + + Vocalinux + Free, offline voice dictation for Linux + + Jatin K Malik + + + CC0-1.0 + GPL-3.0-only + + +

+ Vocalinux is a privacy-first, fully offline voice dictation system for Linux. + Speak naturally and your words appear in any application — on X11 or Wayland — + using local speech recognition. No cloud, no data leaves your machine. + Speech recognition models are downloaded on first run. +

+

Features:

+
    +
  • Toggle or push-to-talk activation modes
  • +
  • Real-time transcription with minimal latency
  • +
  • Universal compatibility across all Linux applications
  • +
  • 100% offline operation for privacy and reliability
  • +
  • whisper.cpp by default — high-performance C++ speech recognition
  • +
  • Universal GPU support via Vulkan (AMD, Intel, NVIDIA)
  • +
  • System tray integration with visual status indicators
  • +
  • Start on login support via XDG autostart
  • +
  • Pleasant audio feedback with smooth gliding tones
  • +
  • Graphical settings dialog for easy configuration
  • +
+

+ This Flatpak ships the whisper.cpp engine. On a Wayland session, text is + injected through XWayland. +

+
+ + com.vocalinux.Vocalinux.desktop + + + Utility + Accessibility + AudioVideo + + + + voice + typing + dictation + speech + recognition + transcription + accessibility + + + + + Real-time voice-to-text transcription + https://vocalinux.com/screenshots/00-transcription.png + + + Speech engine settings + https://vocalinux.com/screenshots/settings-speech-engine.png + + + System tray indicator + https://vocalinux.com/screenshots/02-system-tray.png + + + + https://vocalinux.com + https://github.com/jatinkrmalik/vocalinux/issues + https://github.com/jatinkrmalik/vocalinux + + + none + none + none + none + none + none + none + none + none + none + none + none + none + none + none + none + none + none + none + none + none + none + none + none + none + none + none + + + + + +

Vocalinux 0.14.0-beta as Flatpak: whisper.cpp with Vulkan, XDG config/models/logs, XWayland text injection, and system tray integration.

+
+
+ + +

First Flatpak release: whisper.cpp engine with Vulkan GPU support, XDG-compliant data paths, and XWayland text injection.

+
+
+ + +

Guided whisper.cpp model variants, hotplug keyboard rescanning, and IBus setup refinements.

+
+
+ + +

Non-ASCII text injection, IBus Wayland detection fixes, Pop!_OS dependency support, and code quality improvements.

+
+
+
+
diff --git a/packaging/flatpak/com.vocalinux.Vocalinux.yml b/packaging/flatpak/com.vocalinux.Vocalinux.yml new file mode 100644 index 0000000..be73a46 --- /dev/null +++ b/packaging/flatpak/com.vocalinux.Vocalinux.yml @@ -0,0 +1,129 @@ +app-id: com.vocalinux.Vocalinux +runtime: org.gnome.Platform +runtime-version: "50" +sdk: org.gnome.Sdk +command: vocalinux + +build-options: + env: + NO_REPAIR: "1" + PYWHISPERCPP_VERSION: "1.4.1" + +finish-args: + # Download speech-recognition models on first run. + - --share=network + # Shared memory for X11 / XWayland. + - --share=ipc + # X11 for xdotool fallback; Wayland for wl-copy (instant paste into native apps). + # Injection itself uses ydotool/uinput (not the Wayland virtual-keyboard protocol). + - --socket=x11 + - --socket=wayland + # Microphone capture (works against PulseAudio and PipeWire-pulse). + - --socket=pulseaudio + # GPU render node for the Vulkan-accelerated whisper.cpp build. + - --device=dri + # /dev/input for global hotkeys (evdev) and /dev/uinput for ydotool (Ctrl+V / + # type). Flatpak has no narrower flag than "all" for uinput. + - --device=all + # IBus Unicode injection backend. + - --talk-name=org.freedesktop.IBus + # Desktop notifications. + - --talk-name=org.freedesktop.Notifications + # System tray / status-notifier icon. + - --talk-name=org.kde.StatusNotifierWatcher + - --talk-name=org.gnome.Shell + # Find the bundled AppIndicator typelib under /app (searched before the runtime). + - --env=GI_TYPELIB_PATH=/app/lib/girepository-1.0 + +modules: + - name: portaudio + buildsystem: cmake-ninja + config-opts: + - -DCMAKE_BUILD_TYPE=Release + - -DCMAKE_INSTALL_PREFIX=/app + - -DCMAKE_POLICY_VERSION_MINIMUM=3.5 + cleanup: + - /include + - /lib/cmake + - /lib/pkgconfig + - "*.a" + - "*.la" + sources: + - type: archive + url: https://github.com/PortAudio/portaudio/archive/refs/tags/v19.7.0.tar.gz + sha256: 5af29ba58bbdbb7bbcefaaecc77ec8fc413f0db6f4c4e286c40c3e1b83174fa0 + + - shared-modules/libayatana-appindicator/libayatana-appindicator-gtk3.json + - name: xsel + buildsystem: simple + build-commands: + - autoreconf -fi + - ./configure --prefix=/app + - make + - make install + cleanup: + - /share/man + sources: + - type: archive + url: https://github.com/kfish/xsel/archive/refs/tags/1.2.1.tar.gz + sha256: 18487761f5ca626a036d65ef2db8ad9923bf61685e06e7533676c56d7d60eb14 + + - name: xdotool + buildsystem: simple + build-commands: + - make + - make install PREFIX=/app + sources: + - type: archive + url: https://github.com/jordansissel/xdotool/archive/refs/tags/v3.20211022.1.tar.gz + sha256: 82b15a944a5e82fee15e0f6116bd9f642bc3d0bb6989fc0ca5ad9dfe35de0847 + + # Wayland clipboard for bulk paste (wl-copy + ydotool Ctrl+V). + - name: wl-clipboard + buildsystem: meson + config-opts: + - -Dfishcompletiondir=no + - -Dzshcompletiondir=no + cleanup: + - /share/man + - /share/bash-completion + sources: + - type: archive + url: https://github.com/bugaevc/wl-clipboard/archive/refs/tags/v2.2.1.tar.gz + sha256: 6eb8081207fb5581d1d82c4bcd9587205a31a3d47bea3ebeb7f41aa1143783eb + + # uinput-based typing / paste chords for native Wayland clients. + - name: ydotool + buildsystem: cmake-ninja + config-opts: + - -DCMAKE_BUILD_TYPE=Release + # CMake 4.x in GNOME SDK rejects projects declaring min < 3.5. + - -DCMAKE_POLICY_VERSION_MINIMUM=3.5 + cleanup: + - /share/man + sources: + - type: archive + url: https://github.com/ReimuNotMoe/ydotool/archive/refs/tags/v1.0.4.tar.gz + sha256: ba075a43aa6ead51940e892ecffa4d0b8b40c241e4e2bc4bd9bd26b61fde23bd + - type: shell + commands: + # scdoc is not in the SDK; man pages are not needed in the sandbox. + - sed -i '/add_subdirectory(manpage)/d' CMakeLists.txt + - sed -i 's/project(ydotool C)/project(ydotool C)\nadd_definitions(-DVERSION=\\"1.0.4\\")/' CMakeLists.txt + + - python3-build-dependencies.yaml + - python3-dependencies.yaml + + - name: vocalinux + buildsystem: simple + build-commands: + - pip3 install --no-deps --no-build-isolation --prefix=/app . + - install -Dm644 packaging/flatpak/com.vocalinux.Vocalinux.desktop -t /app/share/applications/ + - install -Dm644 resources/icons/scalable/vocalinux.svg /app/share/icons/hicolor/scalable/apps/com.vocalinux.Vocalinux.svg + - install -Dm644 resources/icons/scalable/vocalinux-microphone-off.svg /app/share/icons/hicolor/scalable/apps/com.vocalinux.Vocalinux-microphone-off.svg + - install -Dm644 resources/icons/scalable/vocalinux-microphone.svg /app/share/icons/hicolor/scalable/apps/com.vocalinux.Vocalinux-microphone.svg + - install -Dm644 resources/icons/scalable/vocalinux-microphone-process.svg /app/share/icons/hicolor/scalable/apps/com.vocalinux.Vocalinux-microphone-process.svg + - install -Dm644 packaging/flatpak/com.vocalinux.Vocalinux.metainfo.xml -t /app/share/metainfo/ + sources: + - type: dir + path: ../.. diff --git a/packaging/flatpak/python3-build-dependencies.yaml b/packaging/flatpak/python3-build-dependencies.yaml new file mode 100644 index 0000000..2efbf10 --- /dev/null +++ b/packaging/flatpak/python3-build-dependencies.yaml @@ -0,0 +1,11 @@ +name: python3-build-dependencies +buildsystem: simple +build-commands: + - pip3 install --verbose --exists-action=i --no-index --find-links="file://${PWD}" --prefix=${FLATPAK_DEST} meson-python pyproject-metadata +sources: + - type: file + url: https://files.pythonhosted.org/packages/16/7f/d1b0c65b267a1463d752b324f11d3470e30889daefc4b9ec83029bfa30b5/meson_python-0.19.0-py3-none-any.whl + sha256: 67b5906c37404396d23c195e12c8825506074460d4a2e7083266b845d14f0298 + - type: file + url: https://files.pythonhosted.org/packages/1d/0b/da4851b1e2d9c40c9bd74c0abd94510a7d797da9ccde0a90e8953751ed4a/pyproject_metadata-0.11.0-py3-none-any.whl + sha256: 85bbecca8694e2c00f63b492c96921d6c228454057c88e7c352b2077fcaa4096 diff --git a/packaging/flatpak/python3-dependencies.yaml b/packaging/flatpak/python3-dependencies.yaml new file mode 100644 index 0000000..9af234f --- /dev/null +++ b/packaging/flatpak/python3-dependencies.yaml @@ -0,0 +1,180 @@ +# Generated with flatpak-pip-generator --runtime org.gnome.Sdk//50 --yaml --ignore-pkg vosk>=0.3.45 PyGObject; sys_platform == 'linux' --output /tmp/vocalinux-flatpak-gen3/python3-dependencies pywhispercpp pydub pynput evdev requests tqdm numpy pyaudio python-xlib psutil lxml meson-python pyproject-metadata +name: python3-dependencies +buildsystem: simple +build-commands: [] +modules: + - name: python3-pywhispercpp + buildsystem: simple + build-commands: + # The PyPI sdist derives its version from git metadata, which is absent in + # the offline Flatpak build, so inject the version explicitly. --no-same-owner + # stops GNU tar from trying to restore the archive's uid inside the build + # sandbox (where chown to an arbitrary uid is not permitted). + - tar --no-same-owner -xzf pywhispercpp-1.4.1.tar.gz + - python3 -c "from pathlib import Path; p = Path('pywhispercpp-1.4.1/setup.py'); s = p.read_text(); p.write_text(s.replace('setup(\\n name=\"pywhispercpp\",', 'setup(\\n name=\"pywhispercpp\",\\n version=\"1.4.1\",'))" + - tar --owner=0 --group=0 -czf pywhispercpp-1.4.1.tar.gz pywhispercpp-1.4.1 + - PYWHISPERCPP_VERSION=1.4.1 NO_REPAIR=1 GGML_VULKAN=1 pip3 install --verbose --exists-action=i --no-index --find-links="file://${PWD}" + --prefix=${FLATPAK_DEST} "pywhispercpp" --no-build-isolation + # whisper.cpp installs its shared libraries next to the extension module. + # Symlink them onto the default loader path so the extension resolves them + # without a runtime LD_LIBRARY_PATH, regardless of the SDK's Python minor + # version or the exact whisper.cpp soname. + - | + set -eu + sitedir="$(find "${FLATPAK_DEST}/lib" -maxdepth 2 -type d -name site-packages | head -n1)" + echo "Linking whisper.cpp libraries from: ${sitedir}" + find "${sitedir}" -maxdepth 2 \( -name 'libggml*.so*' -o -name 'libwhisper*.so*' \) \ + -exec ln -sf {} "${FLATPAK_DEST}/lib/" \; + ls -l "${FLATPAK_DEST}/lib/" | grep -E 'libggml|libwhisper' || true + sources: + - &id002 + type: file + url: https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl + sha256: 3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a + - &id003 + type: file + url: https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl + sha256: 3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d + - &id004 + type: file + url: https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl + sha256: 892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3 + - &id008 + type: file + url: https://files.pythonhosted.org/packages/d7/9f/b8cef5bffa569759033adda9481211426f12f53299629b410340795c2514/numpy-2.4.4.tar.gz + sha256: 2d390634c5182175533585cc89f3608a4682ccb173cc9bb940b2881c8d6f8fa0 + - type: file + url: https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl + sha256: e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917 + - type: file + url: https://files.pythonhosted.org/packages/f6/7d/fe4b2b190d6cdcf0d5e4ca2b946d73bc449a586606a24d25ff50ffec5a5c/pywhispercpp-1.4.1.tar.gz + sha256: 520ce9275bb6fc81e0daa2e08144a80ee006b117a18058a77860c5b1c9c122f4 + - &id005 + type: file + url: https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl + sha256: 4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a + - &id007 + type: file + url: https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl + sha256: ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf + - &id006 + type: file + url: https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl + sha256: bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4 + - name: python3-pydub + buildsystem: simple + build-commands: + - pip3 install --verbose --exists-action=i --no-index --find-links="file://${PWD}" + --prefix=${FLATPAK_DEST} "pydub" --no-build-isolation + sources: + - type: file + url: https://files.pythonhosted.org/packages/a6/53/d78dc063216e62fc55f6b2eebb447f6a4b0a59f55c8406376f76bf959b08/pydub-0.25.1-py2.py3-none-any.whl + sha256: 65617e33033874b59d87db603aa1ed450633288aefead953b30bded59cb599a6 + - name: python3-pynput + buildsystem: simple + build-commands: + - pip3 install --verbose --exists-action=i --no-index --find-links="file://${PWD}" + --prefix=${FLATPAK_DEST} "pynput" --no-build-isolation + sources: + - &id001 + type: file + url: https://files.pythonhosted.org/packages/a5/f5/397b61091120a9ca5001041dd7bf76c385b3bfd67a0e5bcb74b852bd22a4/evdev-1.9.3.tar.gz + sha256: 2c140e01ac8437758fa23fe5c871397412461f42d421aa20241dc8fe8cfccbc9 + - type: file + url: https://files.pythonhosted.org/packages/59/4f/ac3fa906ae8a375a536b12794128c5efacade9eaa917a35dfd27ce0c7400/pynput-1.8.1-py2.py3-none-any.whl + sha256: 42dfcf27404459ca16ca889c8fb8ffe42a9fe54f722fd1a3e130728e59e768d2 + - &id009 + type: file + url: https://files.pythonhosted.org/packages/fc/b8/ff33610932e0ee81ae7f1269c890f697d56ff74b9f5b2ee5d9b7fa2c5355/python_xlib-0.33-py2.py3-none-any.whl + sha256: c3534038d42e0df2f1392a1b30a15a4ff5fdc2b86cfa94f072bf11b10a164398 + - &id010 + type: file + url: https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl + sha256: 4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 + - name: python3-evdev + buildsystem: simple + build-commands: + - pip3 install --verbose --exists-action=i --no-index --find-links="file://${PWD}" + --prefix=${FLATPAK_DEST} "evdev" --no-build-isolation + sources: + - *id001 + - name: python3-requests + buildsystem: simple + build-commands: + - pip3 install --verbose --exists-action=i --no-index --find-links="file://${PWD}" + --prefix=${FLATPAK_DEST} "requests" --no-build-isolation + sources: + - *id002 + - *id003 + - *id004 + - *id005 + - *id006 + - name: python3-tqdm + buildsystem: simple + build-commands: + - pip3 install --verbose --exists-action=i --no-index --find-links="file://${PWD}" + --prefix=${FLATPAK_DEST} "tqdm" --no-build-isolation + sources: + - *id007 + - name: python3-numpy + buildsystem: simple + build-commands: + - pip3 install --verbose --exists-action=i --no-index --find-links="file://${PWD}" + --prefix=${FLATPAK_DEST} "numpy" --no-build-isolation + sources: + - *id008 + - name: python3-pyaudio + buildsystem: simple + build-commands: + - pip3 install --verbose --exists-action=i --no-index --find-links="file://${PWD}" + --prefix=${FLATPAK_DEST} "pyaudio" --no-build-isolation + sources: + - type: file + url: https://files.pythonhosted.org/packages/26/1d/8878c7752febb0f6716a7e1a52cb92ac98871c5aa522cba181878091607c/PyAudio-0.2.14.tar.gz + sha256: 78dfff3879b4994d1f4fc6485646a57755c6ee3c19647a491f790a0895bd2f87 + - name: python3-python-xlib + buildsystem: simple + build-commands: + - pip3 install --verbose --exists-action=i --no-index --find-links="file://${PWD}" + --prefix=${FLATPAK_DEST} "python-xlib" --no-build-isolation + sources: + - *id009 + - *id010 + - name: python3-psutil + buildsystem: simple + build-commands: + - pip3 install --verbose --exists-action=i --no-index --find-links="file://${PWD}" + --prefix=${FLATPAK_DEST} "psutil" --no-build-isolation + sources: + - type: file + url: https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz + sha256: 0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372 + - name: python3-lxml + buildsystem: simple + build-commands: + - pip3 install --verbose --exists-action=i --no-index --find-links="file://${PWD}" + --prefix=${FLATPAK_DEST} "lxml" --no-build-isolation + sources: + - type: file + url: https://files.pythonhosted.org/packages/28/30/9abc9e34c657c33834eaf6cd02124c61bdf5944d802aa48e69be8da3585d/lxml-6.1.0.tar.gz + sha256: bfd57d8008c4965709a919c3e9a98f76c2c7cb319086b3d26858250620023b13 + - name: python3-meson-python + buildsystem: simple + build-commands: + - pip3 install --verbose --exists-action=i --no-index --find-links="file://${PWD}" + --prefix=${FLATPAK_DEST} "meson-python" --no-build-isolation + sources: + - type: file + url: https://files.pythonhosted.org/packages/16/7f/d1b0c65b267a1463d752b324f11d3470e30889daefc4b9ec83029bfa30b5/meson_python-0.19.0-py3-none-any.whl + sha256: 67b5906c37404396d23c195e12c8825506074460d4a2e7083266b845d14f0298 + - &id011 + type: file + url: https://files.pythonhosted.org/packages/1d/0b/da4851b1e2d9c40c9bd74c0abd94510a7d797da9ccde0a90e8953751ed4a/pyproject_metadata-0.11.0-py3-none-any.whl + sha256: 85bbecca8694e2c00f63b492c96921d6c228454057c88e7c352b2077fcaa4096 + - name: python3-pyproject-metadata + buildsystem: simple + build-commands: + - pip3 install --verbose --exists-action=i --no-index --find-links="file://${PWD}" + --prefix=${FLATPAK_DEST} "pyproject-metadata" --no-build-isolation + sources: + - *id011 diff --git a/packaging/flatpak/shared-modules/intltool/intltool-0.51.json b/packaging/flatpak/shared-modules/intltool/intltool-0.51.json new file mode 100644 index 0000000..b396912 --- /dev/null +++ b/packaging/flatpak/shared-modules/intltool/intltool-0.51.json @@ -0,0 +1,20 @@ +{ + "name": "intltool", + "cleanup": [ "*" ], + "sources": [ + { + "type": "archive", + "url": "https://launchpad.net/intltool/trunk/0.51.0/+download/intltool-0.51.0.tar.gz", + "mirror-urls": [ + "https://sources.suse.com/SUSE:SLE-15-SP7:Update:BCI/000product/ca527df4d03bf96a67854a8ed2f0c64b/INCLUDED/SUSE:SLE-15-SP6:GA::intltool::45f77273c3f2ef792fa697b444183beb/intltool-0.51.0.tar.gz", + "https://mirror-hk.koddos.net/lfs/lfs-packages/12.0/intltool-0.51.0.tar.gz", + "https://gstreamer.freedesktop.org/src/mirror/intltool-0.51.0.tar.gz" + ], + "sha256": "67c74d94196b153b774ab9f89b2fa6c6ba79352407037c8c14d5aeb334e959cd" + }, + { + "type": "patch", + "path": "intltool-perl5.26-regex-fixes.patch" + } + ] +} diff --git a/packaging/flatpak/shared-modules/intltool/intltool-perl5.26-regex-fixes.patch b/packaging/flatpak/shared-modules/intltool/intltool-perl5.26-regex-fixes.patch new file mode 100644 index 0000000..26c5e7b --- /dev/null +++ b/packaging/flatpak/shared-modules/intltool/intltool-perl5.26-regex-fixes.patch @@ -0,0 +1,59 @@ +Description: Escape "{", to prevent complaints from perl 5.22 and 5.26 +Author: Roderich Schupp +Author: gregor herrmann +Bug-Debian: https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=788705 +Bug-Debian: https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=826471 +Bug-Upstream: https://bugs.launchpad.net/intltool/+bug/1490906 + +Index: intltool-0.51.0/intltool-update.in +=================================================================== +--- intltool-0.51.0.orig/intltool-update.in 2017-07-23 17:24:35.113169465 +0200 ++++ intltool-0.51.0/intltool-update.in 2017-07-23 17:24:35.109169052 +0200 +@@ -1062,13 +1062,13 @@ + } + } + +- if ($str =~ /^(.*)\${?([A-Z_]+)}?(.*)$/) ++ if ($str =~ /^(.*)\$\{?([A-Z_]+)}?(.*)$/) + { + my $rest = $3; + my $untouched = $1; + my $sub = ""; + # Ignore recursive definitions of variables +- $sub = $varhash{$2} if defined $varhash{$2} and $varhash{$2} !~ /\${?$2}?/; ++ $sub = $varhash{$2} if defined $varhash{$2} and $varhash{$2} !~ /\$\{?$2}?/; + + return SubstituteVariable ("$untouched$sub$rest"); + } +@@ -1190,10 +1190,10 @@ + $name =~ s/\(+$//g; + $version =~ s/\(+$//g; + +- $varhash{"PACKAGE_NAME"} = $name if (not $name =~ /\${?AC_PACKAGE_NAME}?/); +- $varhash{"PACKAGE"} = $name if (not $name =~ /\${?PACKAGE}?/); +- $varhash{"PACKAGE_VERSION"} = $version if (not $name =~ /\${?AC_PACKAGE_VERSION}?/); +- $varhash{"VERSION"} = $version if (not $name =~ /\${?VERSION}?/); ++ $varhash{"PACKAGE_NAME"} = $name if (not $name =~ /\$\{?AC_PACKAGE_NAME}?/); ++ $varhash{"PACKAGE"} = $name if (not $name =~ /\$\{?PACKAGE}?/); ++ $varhash{"PACKAGE_VERSION"} = $version if (not $name =~ /\$\{?AC_PACKAGE_VERSION}?/); ++ $varhash{"VERSION"} = $version if (not $name =~ /\$\{?VERSION}?/); + } + + if ($conf_source =~ /^AC_INIT\(([^,\)]+),([^,\)]+)[,]?([^,\)]+)?/m) +@@ -1219,11 +1219,11 @@ + $version =~ s/\(+$//g; + $bugurl =~ s/\(+$//g if (defined $bugurl); + +- $varhash{"PACKAGE_NAME"} = $name if (not $name =~ /\${?AC_PACKAGE_NAME}?/); +- $varhash{"PACKAGE"} = $name if (not $name =~ /\${?PACKAGE}?/); +- $varhash{"PACKAGE_VERSION"} = $version if (not $name =~ /\${?AC_PACKAGE_VERSION}?/); +- $varhash{"VERSION"} = $version if (not $name =~ /\${?VERSION}?/); +- $varhash{"PACKAGE_BUGREPORT"} = $bugurl if (defined $bugurl and not $bugurl =~ /\${?\w+}?/); ++ $varhash{"PACKAGE_NAME"} = $name if (not $name =~ /\$\{?AC_PACKAGE_NAME}?/); ++ $varhash{"PACKAGE"} = $name if (not $name =~ /\$\{?PACKAGE}?/); ++ $varhash{"PACKAGE_VERSION"} = $version if (not $name =~ /\$\{?AC_PACKAGE_VERSION}?/); ++ $varhash{"VERSION"} = $version if (not $name =~ /\$\{?VERSION}?/); ++ $varhash{"PACKAGE_BUGREPORT"} = $bugurl if (defined $bugurl and not $bugurl =~ /\$\{?\w+}?/); + } + + # \s makes this not work, why? diff --git a/packaging/flatpak/shared-modules/libayatana-appindicator/0001-Fix-HAVE_VALGRIND-AM_CONDITIONAL.patch b/packaging/flatpak/shared-modules/libayatana-appindicator/0001-Fix-HAVE_VALGRIND-AM_CONDITIONAL.patch new file mode 100644 index 0000000..9105984 --- /dev/null +++ b/packaging/flatpak/shared-modules/libayatana-appindicator/0001-Fix-HAVE_VALGRIND-AM_CONDITIONAL.patch @@ -0,0 +1,50 @@ +From 729546c51806a1b3ea6cb6efb7a115b1baa811f1 Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?Stefan=20Br=C3=BCns?= +Date: Mon, 18 Nov 2019 19:58:53 +0100 +Subject: [PATCH 1/1] Fix HAVE_VALGRIND AM_CONDITIONAL + +The AM_CONDITIONAL should also be run with --disable-tests, otherwise +HAVE_VALGRIND is undefined. +--- + configure | 4 ++-- + configure.ac | 2 +- + 2 files changed, 3 insertions(+), 3 deletions(-) + +diff --git a/configure b/configure +index 831a3bb..8913b9b 100644 +--- a/configure ++++ b/configure +@@ -14801,6 +14801,8 @@ else + { $as_echo "$as_me:${as_lineno-$LINENO}: result: yes" >&5 + $as_echo "yes" >&6; } + have_valgrind=yes ++fi ++ + fi + if test "x$have_valgrind" = "xyes"; then + HAVE_VALGRIND_TRUE= +@@ -14811,8 +14813,6 @@ else + fi + + +-fi +- + + + +diff --git a/configure.ac b/configure.ac +index ace54d1..cbd38a6 100644 +--- a/configure.ac ++++ b/configure.ac +@@ -120,8 +120,8 @@ PKG_CHECK_MODULES(DBUSMENUTESTS, json-glib-1.0 >= $JSON_GLIB_REQUIRED_VERSION + [have_tests=yes] + ) + PKG_CHECK_MODULES(DBUSMENUTESTSVALGRIND, valgrind, have_valgrind=yes, have_valgrind=no) +-AM_CONDITIONAL([HAVE_VALGRIND], [test "x$have_valgrind" = "xyes"]) + ]) ++AM_CONDITIONAL([HAVE_VALGRIND], [test "x$have_valgrind" = "xyes"]) + + AC_SUBST(DBUSMENUTESTS_CFLAGS) + AC_SUBST(DBUSMENUTESTS_LIBS) +-- +2.46.2 diff --git a/packaging/flatpak/shared-modules/libayatana-appindicator/0001-Make-introspection-configurable.patch b/packaging/flatpak/shared-modules/libayatana-appindicator/0001-Make-introspection-configurable.patch new file mode 100644 index 0000000..186e34e --- /dev/null +++ b/packaging/flatpak/shared-modules/libayatana-appindicator/0001-Make-introspection-configurable.patch @@ -0,0 +1,43 @@ +From 8a09e6ad33c58c017c0c8fd756da036fc39428ea Mon Sep 17 00:00:00 2001 +From: Alexander Koskovich +Date: Sun, 29 Sep 2024 13:47:54 -0400 +Subject: [PATCH 1/1] Make introspection configurable + +--- + CMakeLists.txt | 1 + + src/CMakeLists.txt | 4 ++++ + 2 files changed, 5 insertions(+) + +diff --git a/CMakeLists.txt b/CMakeLists.txt +index 0e13fcd..f3e9ec0 100644 +--- a/CMakeLists.txt ++++ b/CMakeLists.txt +@@ -12,6 +12,7 @@ endif(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + option(ENABLE_TESTS "Enable all tests and checks" OFF) + option(ENABLE_COVERAGE "Enable coverage reports (includes enabling all tests and checks)" OFF) + option(ENABLE_WERROR "Treat all build warnings as errors" OFF) ++option(ENABLE_INTROSPECTION "Enable introspection" ON) + + if(ENABLE_COVERAGE) + set(ENABLE_TESTS ON) +diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt +index 5b3638d..aca9481 100644 +--- a/src/CMakeLists.txt ++++ b/src/CMakeLists.txt +@@ -108,6 +108,8 @@ install(TARGETS "ayatana-ido3-0.4" LIBRARY DESTINATION "${CMAKE_INSTALL_FULL_LIB + + # AyatanaIdo3-0.4.gir + ++if (ENABLE_INTROSPECTION) ++ + find_package(GObjectIntrospection REQUIRED QUIET) + + if (INTROSPECTION_FOUND) +@@ -183,3 +185,5 @@ if (INTROSPECTION_FOUND) + endif () + + endif () ++ ++endif () +-- +2.46.2 diff --git a/packaging/flatpak/shared-modules/libayatana-appindicator/libayatana-appindicator-gtk3.json b/packaging/flatpak/shared-modules/libayatana-appindicator/libayatana-appindicator-gtk3.json new file mode 100644 index 0000000..b3b5cf3 --- /dev/null +++ b/packaging/flatpak/shared-modules/libayatana-appindicator/libayatana-appindicator-gtk3.json @@ -0,0 +1,121 @@ +{ + "name": "libayatana-appindicator", + "buildsystem": "cmake-ninja", + "cleanup": [ + "/include", + "/lib/pkgconfig" + ], + "config-opts": [ + "-DENABLE_BINDINGS_MONO=NO", + "-DENABLE_BINDINGS_VALA=NO", + "-DENABLE_GTKDOC=NO" + ], + "modules": [ + "../intltool/intltool-0.51.json", + { + "name": "libdbusmenu", + "buildsystem": "autotools", + "build-options": { + "cflags": "-Wno-error" + }, + "cleanup": [ + "*.la", + "/include", + "/lib/pkgconfig", + "/libexec", + "/share/doc", + "/share/gtk-doc" + ], + "config-opts": [ + "--with-gtk=3", + "--disable-dumper", + "--disable-static", + "--disable-tests", + "--disable-gtk-doc", + "--enable-introspection=no", + "--disable-vala" + ], + "sources": [ + { + "type": "archive", + "url": "https://launchpad.net/libdbusmenu/16.04/16.04.0/+download/libdbusmenu-16.04.0.tar.gz", + "mirror-urls": [ + "https://sources.suse.com/SUSE:SLFO:Main:Build/libdbusmenu/a5b3e4717f09f90f9cd5568a5f933e8d/libdbusmenu-16.04.0.tar.gz", + "https://mirror.math.princeton.edu/pub/frugalware/frugalware-2.1/source/xlib/libdbusmenu/libdbusmenu-16.04.0.tar.gz", + "https://ftp.fr.openbsd.org/pub/OpenBSD/distfiles/libdbusmenu-16.04.0.tar.gz" + ], + "sha256": "b9cc4a2acd74509435892823607d966d424bd9ad5d0b00938f27240a1bfa878a" + }, + { + "type": "patch", + "paths": [ + "0001-Fix-HAVE_VALGRIND-AM_CONDITIONAL.patch" + ] + } + ] + }, + { + "name": "ayatana-ido", + "buildsystem": "cmake-ninja", + "cleanup": [ + "/include", + "/lib/pkgconfig" + ], + "config-opts": [ + "-DENABLE_INTROSPECTION=OFF" + ], + "sources": [ + { + "type": "git", + "url": "https://github.com/AyatanaIndicators/ayatana-ido.git", + "tag": "0.10.4", + "commit": "f968079b09e2310fefc3fc307359025f1c74b3eb", + "x-checker-data": { + "type": "git", + "tag-pattern": "^([\\d.]+)$" + } + }, + { + "type": "patch", + "paths": [ + "0001-Make-introspection-configurable.patch" + ] + } + ] + }, + { + "name": "libayatana-indicator", + "buildsystem": "cmake-ninja", + "cleanup": [ + "/include", + "/lib/pkgconfig", + "/libexec", + "/share" + ], + "sources": [ + { + "type": "git", + "url": "https://github.com/AyatanaIndicators/libayatana-indicator.git", + "tag": "0.9.4", + "commit": "611bb384b73fa6311777ba4c41381a06f5b99dad", + "x-checker-data": { + "type": "git", + "tag-pattern": "^([\\d.]+)$" + } + } + ] + } + ], + "sources": [ + { + "type": "git", + "url": "https://github.com/AyatanaIndicators/libayatana-appindicator.git", + "tag": "0.5.94", + "commit": "31e8bb083b307e1cc96af4874a94707727bd1e79", + "x-checker-data": { + "type": "git", + "tag-pattern": "^([\\d.]+)$" + } + } + ] +} diff --git a/src/vocalinux/single_instance.py b/src/vocalinux/single_instance.py index f802a3b..5b2a7e2 100644 --- a/src/vocalinux/single_instance.py +++ b/src/vocalinux/single_instance.py @@ -12,10 +12,12 @@ import sys from pathlib import Path from typing import Optional +from .utils.paths import data_dir + logger = logging.getLogger(__name__) -# Lock file location: ~/.local/share/vocalinux/ -LOCK_FILE_DIR = Path.home() / ".local" / "share" / "vocalinux" +# Lock file location: $XDG_DATA_HOME/vocalinux/ (defaults to ~/.local/share/vocalinux/) +LOCK_FILE_DIR = Path(data_dir()) LOCK_FILE_PATH = LOCK_FILE_DIR / "instance.lock" # Global lock file handle diff --git a/src/vocalinux/speech_recognition/recognition_manager.py b/src/vocalinux/speech_recognition/recognition_manager.py index b183d52..da12a93 100644 --- a/src/vocalinux/speech_recognition/recognition_manager.py +++ b/src/vocalinux/speech_recognition/recognition_manager.py @@ -20,6 +20,7 @@ from typing import Callable, Optional from ..common_types import RecognitionState from ..ui.audio_feedback import play_error_sound, play_start_sound, play_stop_sound +from ..utils.paths import models_dir from ..utils.vosk_model_info import VOSK_MODEL_INFO from ..utils.whispercpp_model_info import WHISPERCPP_MODEL_INFO, get_model_path, is_model_downloaded from .command_processor import CommandProcessor @@ -704,7 +705,7 @@ def _show_notification(title: str, message: str, icon: str = "dialog-warning"): # Define constants -MODELS_DIR = os.path.expanduser("~/.local/share/vocalinux/models") +MODELS_DIR = models_dir() def _get_system_model_paths() -> list: diff --git a/src/vocalinux/text_injection/ibus_engine.py b/src/vocalinux/text_injection/ibus_engine.py index f190a26..712c1ca 100644 --- a/src/vocalinux/text_injection/ibus_engine.py +++ b/src/vocalinux/text_injection/ibus_engine.py @@ -26,6 +26,8 @@ import time from pathlib import Path from typing import Callable, Optional +from ..utils.paths import xdg_data_home + logger = logging.getLogger(__name__) @@ -57,8 +59,8 @@ except (ImportError, ValueError) as e: GObject = None -# File paths for communication -VOCALINUX_IBUS_DIR = Path.home() / ".local" / "share" / "vocalinux-ibus" +# File paths for communication ($XDG_DATA_HOME, defaults to ~/.local/share) +VOCALINUX_IBUS_DIR = Path(xdg_data_home()) / "vocalinux-ibus" SOCKET_PATH = VOCALINUX_IBUS_DIR / "inject.sock" PID_FILE = VOCALINUX_IBUS_DIR / "engine.pid" diff --git a/src/vocalinux/text_injection/text_injector.py b/src/vocalinux/text_injection/text_injector.py index 3db16ee..c03501c 100644 --- a/src/vocalinux/text_injection/text_injector.py +++ b/src/vocalinux/text_injection/text_injector.py @@ -15,6 +15,7 @@ import time from enum import Enum from typing import Optional # noqa: F401 +from ..utils.paths import config_dir from .ibus_engine import ( IBusTextInjector, is_ibus_active_input_method, @@ -156,15 +157,31 @@ class TextInjector: The detected desktop environment """ session_type = os.environ.get("XDG_SESSION_TYPE", "").lower() + wayland_display = os.environ.get("WAYLAND_DISPLAY") + x11_display = os.environ.get("DISPLAY") + if session_type == "wayland": + # Flatpak often has no Wayland socket (injection uses uinput/x11). Prefer + # ydotool: xdotool only types into XWayland windows, not native clients. + if os.environ.get("FLATPAK_ID") and not wayland_display and x11_display: + if shutil.which("ydotool"): + logger.info( + "Flatpak on Wayland host: using ydotool (uinput) for text injection" + ) + return DesktopEnvironment.WAYLAND + logger.info( + "Flatpak on Wayland host: no ydotool; falling back to xdotool/XWayland " + "(X11 apps only)" + ) + return DesktopEnvironment.WAYLAND_XDOTOOL return DesktopEnvironment.WAYLAND elif session_type == "x11": return DesktopEnvironment.X11 else: # Try to detect based on other methods - if "WAYLAND_DISPLAY" in os.environ: + if wayland_display: return DesktopEnvironment.WAYLAND - elif "DISPLAY" in os.environ: + elif x11_display: return DesktopEnvironment.X11 else: logger.warning("Could not detect desktop environment, defaulting to X11") @@ -236,6 +253,41 @@ class TextInjector: # pgrep missing, timed out, or otherwise unavailable -> assume not running return False + def _ensure_ydotoold(self) -> bool: + """Start ydotoold if needed so ydotool can inject via uinput. + + ydotool 1.x talks to a daemon that owns /dev/uinput. Inside Flatpak we + start the daemon on demand when the app is granted device access. + """ + if self._is_ydotoold_running(): + return True + ydotoold = shutil.which("ydotoold") + if not ydotoold: + return False + if not os.path.exists("/dev/uinput"): + logger.warning( + "ydotoold needs /dev/uinput (Flatpak: grant --device=all). " + "Text injection into native Wayland apps will fail." + ) + return False + try: + subprocess.Popen( + [ydotoold], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + except OSError as e: + logger.warning(f"Could not start ydotoold: {e}") + return False + for _ in range(40): + time.sleep(0.05) + if self._is_ydotoold_running(): + logger.info("Started ydotoold for uinput text injection") + return True + logger.warning("ydotoold did not become ready in time") + return False + def _check_dependencies(self): """Check for the required tools for text injection.""" ibus_requested = False @@ -312,17 +364,16 @@ class TextInjector: ydotool_available = shutil.which("ydotool") is not None xdotool_available = shutil.which("xdotool") is not None - if ydotool_available and self._is_ydotoold_running(): - # ydotoold is running; ydotool can inject via the daemon. + # Prefer ydotool when the daemon is (or can be) ready. Flatpak ships + # ydotool for native Wayland typing; wtype needs a Wayland socket. + if ydotool_available and self._ensure_ydotoold(): self.wayland_tool = "ydotool" - logger.info(f"Using {self.wayland_tool} for Wayland text injection") + logger.info("Using ydotool for Wayland text injection") elif ydotool_available and not wtype_available: - # ydotool present but no daemon and no wtype: try ydotool anyway - # (direct uinput mode, requires access to /dev/uinput). self.wayland_tool = "ydotool" logger.warning( - "ydotoold daemon not running; using ydotool in direct mode " - "(may have latency/permission issues)" + "ydotoold not ready; using ydotool without daemon " + "(may fail or have latency/permission issues)" ) elif wtype_available: self.wayland_tool = "wtype" @@ -401,13 +452,19 @@ class TextInjector: def _get_clipboard_tools(self): tools = [] - if self._session_environment == DesktopEnvironment.WAYLAND and shutil.which("wl-copy"): + # Prefer wl-copy on Wayland (including Flatpak with --socket=wayland). + host_is_wayland = ( + self._session_environment == DesktopEnvironment.WAYLAND + or os.environ.get("XDG_SESSION_TYPE", "").lower() == "wayland" + or bool(os.environ.get("WAYLAND_DISPLAY")) + ) + if host_is_wayland and shutil.which("wl-copy"): tools.append("wl-copy") if shutil.which("xclip"): tools.append("xclip") if shutil.which("xsel"): tools.append("xsel") - if self._session_environment != DesktopEnvironment.WAYLAND and shutil.which("wl-copy"): + if not host_is_wayland and shutil.which("wl-copy"): tools.append("wl-copy") return tools @@ -639,7 +696,7 @@ class TextInjector: try: import json - config_path = os.path.expanduser("~/.config/vocalinux/config.json") + config_path = os.path.join(config_dir(), "config.json") if os.path.exists(config_path): with open(config_path, "r") as f: config = json.load(f) @@ -1083,30 +1140,26 @@ class TextInjector: # modify typed keys. self._wait_for_modifiers_released() - # ydotool emits *positional* evdev keycodes, so `ydotool type` is - # re-interpreted through the active keyboard layout, which it assumes is - # US QWERTY. On any other layout (AZERTY, QWERTZ, Dvorak, ...) the text - # comes out scrambled (e.g. "message" -> ",essqge" on AZERTY), and - # non-ASCII characters are dropped entirely. Always paste via the - # clipboard instead: Ctrl+V is layout-independent and Unicode-safe. + # Prefer clipboard + Ctrl+V for ydotool: one paste, layout-independent. + # Flatpak ships wl-copy (--socket=wayland) so native Wayland apps get + # bulk paste; character-by-character type is only a fallback. if self.wayland_tool == "ydotool": - logger.info("Using clipboard paste for ydotool (layout-independent, Unicode-safe)") + if not self._ensure_ydotoold(): + logger.warning("ydotoold not ready before injection") + logger.info("Using clipboard paste for ydotool (instant, layout-independent)") if self._inject_via_clipboard_paste(text): return logger.warning( "Clipboard paste failed, falling back to ydotool type " - "(text may be scrambled on non-US layouts)" + "(character-by-character; text may be scrambled on non-US layouts)" ) if self.wayland_tool == "wtype": cmd = ["wtype", text] else: # ydotool - # Pass explicit timing parameters to speed up injection. - # ydotool defaults to --key-delay 12 (or 20 in older versions). - # We use a faster default configurable via environment variable. - # IMPORTANT: key-delay must stay > 0 to avoid Shift-leak bug where - # capital letters corrupt following characters (e.g. "Can you" -> "CAN YOu"). - key_delay = os.environ.get("VOCALINUX_YDOTOOL_KEY_DELAY", "8") + # Keep key-delay > 0 to avoid Shift-leak ("Can you" -> "CAN YOu"). + # Low delay so fallback typing finishes quickly for long phrases. + key_delay = os.environ.get("VOCALINUX_YDOTOOL_KEY_DELAY", "2") cmd = ["ydotool", "type", "--key-delay", key_delay, text] try: diff --git a/src/vocalinux/ui/autostart_manager.py b/src/vocalinux/ui/autostart_manager.py index 261f90c..1979891 100644 --- a/src/vocalinux/ui/autostart_manager.py +++ b/src/vocalinux/ui/autostart_manager.py @@ -29,6 +29,11 @@ def get_autostart_file() -> Path: def get_exec_command() -> str: + # Host session manager cannot exec /app/bin; use the flatpak launcher instead. + flatpak_id = os.environ.get("FLATPAK_ID") + if flatpak_id: + return f"flatpak run {flatpak_id} --start-minimized" + installed_command = shutil.which("vocalinux") if installed_command: return f"{shlex.quote(installed_command)} --start-minimized" diff --git a/src/vocalinux/ui/config_manager.py b/src/vocalinux/ui/config_manager.py index 8ef5c92..e1f0c57 100644 --- a/src/vocalinux/ui/config_manager.py +++ b/src/vocalinux/ui/config_manager.py @@ -10,10 +10,12 @@ import logging import os from typing import Any, Optional +from ..utils.paths import config_dir + logger = logging.getLogger(__name__) # Define constants -CONFIG_DIR = os.path.expanduser("~/.config/vocalinux") +CONFIG_DIR = config_dir() CONFIG_FILE = os.path.join(CONFIG_DIR, "config.json") # Default configuration diff --git a/src/vocalinux/ui/keyboard_backends/__init__.py b/src/vocalinux/ui/keyboard_backends/__init__.py index 37791eb..287a0a6 100644 --- a/src/vocalinux/ui/keyboard_backends/__init__.py +++ b/src/vocalinux/ui/keyboard_backends/__init__.py @@ -58,15 +58,22 @@ class DesktopEnvironment: 'x11', 'wayland', or 'unknown' """ session_type = os.environ.get("XDG_SESSION_TYPE", "").lower() + wayland_display = os.environ.get("WAYLAND_DISPLAY") + x11_display = os.environ.get("DISPLAY") if session_type == "wayland": + # Flatpak often has X11 only (for injection). Session is still Wayland + # on the host; create_backend prefers evdev for global shortcuts. + if os.environ.get("FLATPAK_ID") and not wayland_display and x11_display: + logger.info("Flatpak has X11/XWayland access only; treating session as X11") + return DesktopEnvironment.X11 return DesktopEnvironment.WAYLAND elif session_type == "x11": return DesktopEnvironment.X11 # Fallback to environment variables - if "WAYLAND_DISPLAY" in os.environ: + if wayland_display: return DesktopEnvironment.WAYLAND - elif "DISPLAY" in os.environ: + elif x11_display: return DesktopEnvironment.X11 logger.warning("Could not detect desktop environment, defaulting to unknown") @@ -116,27 +123,34 @@ def create_backend( else: logger.warning(f"Unknown preferred backend: '{preferred_backend}'") - # Auto-select based on environment - if env == DesktopEnvironment.WAYLAND: - # On Wayland, prefer evdev (if available) as pynput doesn't work + # Prefer evdev whenever pynput cannot see keys from other apps: + # - native Wayland (no X11 grab path) + # - Flatpak: even with --socket=x11, XWayland only delivers keys to the + # focused X client, so double-tap Ctrl and other globals need /dev/input + need_evdev = env == DesktopEnvironment.WAYLAND or bool(os.environ.get("FLATPAK_ID")) + if need_evdev: + reason = "Flatpak" if os.environ.get("FLATPAK_ID") else "Wayland" if EVDEV_AVAILABLE: - logger.info("Using evdev backend for Wayland") + logger.info(f"Using evdev backend for global shortcuts ({reason})") backend = EvdevKeyboardBackend(shortcut=shortcut, mode=mode) # type: ignore if backend.is_available(): return backend hint = backend.get_permission_hint() if hint: - logger.warning(f"Evdev backend not usable on Wayland: {hint}") - logger.warning("Keyboard shortcuts will not work on Wayland without evdev access") + logger.warning(f"Evdev backend not usable on {reason}: {hint}") + logger.warning( + "Keyboard shortcuts will not work without access to keyboard input devices" + ) return None logger.warning( - "evdev backend not available on Wayland. " + "evdev backend not available (%s). " "Keyboard shortcuts will not work. Install python-evdev and " - "add your user to the 'input' group." + "add your user to the 'input' group (or grant Flatpak --device=input).", + reason, ) return None - # Default to pynput for X11 or unknown + # Default to pynput for native X11 (or unknown) if PYNPUT_AVAILABLE: logger.info("Using pynput backend") return PynputKeyboardBackend(shortcut=shortcut, mode=mode) # type: ignore diff --git a/src/vocalinux/ui/logging_manager.py b/src/vocalinux/ui/logging_manager.py index c774428..83e095f 100644 --- a/src/vocalinux/ui/logging_manager.py +++ b/src/vocalinux/ui/logging_manager.py @@ -11,6 +11,8 @@ from datetime import datetime from pathlib import Path from typing import Callable, Optional +from ..utils.paths import data_dir + logger = logging.getLogger(__name__) @@ -62,8 +64,8 @@ class LoggingManager: self.log_callbacks: list[Callable[[LogRecord], None]] = [] self.lock = threading.Lock() - # Create logs directory - self.logs_dir = Path.home() / ".local" / "share" / "vocalinux" / "logs" + # Create logs directory ($XDG_DATA_HOME/vocalinux/logs; Flatpak-safe) + self.logs_dir = Path(data_dir()) / "logs" self.logs_dir.mkdir(parents=True, exist_ok=True) # Set up custom log handler diff --git a/src/vocalinux/ui/settings_dialog.py b/src/vocalinux/ui/settings_dialog.py index ae86c5c..629b4fd 100644 --- a/src/vocalinux/ui/settings_dialog.py +++ b/src/vocalinux/ui/settings_dialog.py @@ -30,6 +30,7 @@ from gi.repository import Gdk, GLib, Gtk, Pango # noqa: E402 from ..common_types import RecognitionState # noqa: E402 from ..speech_recognition.silero_vad import is_silero_available # noqa: E402 +from ..utils.paths import models_dir # noqa: E402 from ..utils.vosk_model_info import SUPPORTED_LANGUAGES, VOSK_MODEL_INFO # noqa: E402 from ..utils.whispercpp_model_info import MODEL_SIZES as WHISPERCPP_MODEL_SIZES from ..utils.whispercpp_model_info import ( @@ -315,7 +316,7 @@ def get_available_engines(): # Models directory -MODELS_DIR = os.path.expanduser("~/.local/share/vocalinux/models") +MODELS_DIR = models_dir() SYSTEM_MODELS_DIRS = [ "/usr/local/share/vocalinux/models", "/usr/share/vocalinux/models", @@ -597,7 +598,7 @@ def _prevent_scroll_on_hover(widget: Gtk.Widget): def _get_whisper_cache_dir() -> str: """Get the Whisper model cache directory.""" - return os.path.expanduser("~/.local/share/vocalinux/models/whisper") + return os.path.join(MODELS_DIR, "whisper") def _is_whisper_model_downloaded(model_name: str) -> bool: diff --git a/src/vocalinux/ui/tray_indicator.py b/src/vocalinux/ui/tray_indicator.py index c0050a4..3feb8c8 100644 --- a/src/vocalinux/ui/tray_indicator.py +++ b/src/vocalinux/ui/tray_indicator.py @@ -37,18 +37,35 @@ from .settings_dialog import SettingsDialog logger = logging.getLogger(__name__) -# Define constants -APP_ID = "vocalinux" +# Flatpak StatusNotifier host resolves exported -* icons, not /app paths. +FLATPAK_ID = os.environ.get("FLATPAK_ID") +APP_ID = FLATPAK_ID or "vocalinux" # Initialize resource manager _resource_manager = ResourceManager() ICON_DIR = _resource_manager.icons_dir -# Icon file names +# Bundled icon file names (paths under ICON_DIR). DEFAULT_ICON = "vocalinux-microphone-off" ACTIVE_ICON = "vocalinux-microphone" PROCESSING_ICON = "vocalinux-microphone-process" + +def _themed_icon_names() -> dict: + """Icon theme names for the current runtime (host-exported names in Flatpak).""" + if FLATPAK_ID: + return { + "default": f"{FLATPAK_ID}-microphone-off", + "active": f"{FLATPAK_ID}-microphone", + "processing": f"{FLATPAK_ID}-microphone-process", + } + return { + "default": DEFAULT_ICON, + "active": ACTIVE_ICON, + "processing": PROCESSING_ICON, + } + + # /dev/input settle-detection tuning (used after resume) _INPUT_SETTLE_SECONDS = 2 _INPUT_MONITOR_CAP_SECONDS = 10 @@ -96,11 +113,7 @@ class TrayIndicator: "active": _resource_manager.get_icon_path(ACTIVE_ICON), "processing": _resource_manager.get_icon_path(PROCESSING_ICON), } - self.icon_names = { - "default": DEFAULT_ICON, - "active": ACTIVE_ICON, - "processing": PROCESSING_ICON, - } + self.icon_names = _themed_icon_names() # Register for speech recognition state changes self.speech_engine.register_state_callback(self._on_recognition_state_changed) @@ -190,14 +203,22 @@ class TrayIndicator: exists = os.path.exists(path) logger.info(f"Icon '{name}' ({path}): {'exists' if exists else 'missing'}") + initial_icon = _themed_icon_names()["default"] try: - self.indicator = AppIndicator3.Indicator.new_with_path( - APP_ID, - DEFAULT_ICON, - AppIndicator3.IndicatorCategory.APPLICATION_STATUS, - ICON_DIR, - ) - self.indicator.set_icon_theme_path(ICON_DIR) + if FLATPAK_ID: + self.indicator = AppIndicator3.Indicator.new( + APP_ID, + initial_icon, + AppIndicator3.IndicatorCategory.APPLICATION_STATUS, + ) + else: + self.indicator = AppIndicator3.Indicator.new_with_path( + APP_ID, + initial_icon, + AppIndicator3.IndicatorCategory.APPLICATION_STATUS, + ICON_DIR, + ) + self.indicator.set_icon_theme_path(ICON_DIR) self.indicator.set_status(AppIndicator3.IndicatorStatus.ACTIVE) except Exception as e: logger.error(f"Failed to create AppIndicator: {e}") diff --git a/src/vocalinux/utils/paths.py b/src/vocalinux/utils/paths.py new file mode 100644 index 0000000..64009fc --- /dev/null +++ b/src/vocalinux/utils/paths.py @@ -0,0 +1,30 @@ +"""XDG base-directory helpers (Flatpak-safe via XDG_CONFIG_HOME / XDG_DATA_HOME).""" + +import os + +APP_DIR_NAME = "vocalinux" + + +def xdg_config_home() -> str: + """Return ``$XDG_CONFIG_HOME`` or ``~/.config`` (empty values treated as unset).""" + return os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config") + + +def xdg_data_home() -> str: + """Return ``$XDG_DATA_HOME`` or ``~/.local/share`` (empty values treated as unset).""" + return os.environ.get("XDG_DATA_HOME") or os.path.expanduser("~/.local/share") + + +def config_dir() -> str: + """Return the Vocalinux configuration directory.""" + return os.path.join(xdg_config_home(), APP_DIR_NAME) + + +def data_dir() -> str: + """Return the Vocalinux data directory.""" + return os.path.join(xdg_data_home(), APP_DIR_NAME) + + +def models_dir() -> str: + """Return the directory where speech-recognition models are stored.""" + return os.path.join(data_dir(), "models") diff --git a/src/vocalinux/utils/whispercpp_model_info.py b/src/vocalinux/utils/whispercpp_model_info.py index 6617353..a38a1b9 100644 --- a/src/vocalinux/utils/whispercpp_model_info.py +++ b/src/vocalinux/utils/whispercpp_model_info.py @@ -11,6 +11,8 @@ import subprocess from functools import lru_cache from typing import Optional +from .paths import models_dir + logger = logging.getLogger(__name__) # Whisper.cpp model information @@ -311,14 +313,14 @@ def get_model_path(model_name: str) -> str: Returns: Path to the model file """ - models_dir = os.path.expanduser("~/.local/share/vocalinux/models/whispercpp") - os.makedirs(models_dir, exist_ok=True) + whispercpp_dir = os.path.join(models_dir(), "whispercpp") + os.makedirs(whispercpp_dir, exist_ok=True) model_info = WHISPERCPP_MODEL_INFO.get(model_name) if model_info and model_info.get("url"): - return os.path.join(models_dir, os.path.basename(model_info["url"])) + return os.path.join(whispercpp_dir, os.path.basename(model_info["url"])) - return os.path.join(models_dir, f"ggml-{model_name}.bin") + return os.path.join(whispercpp_dir, f"ggml-{model_name}.bin") def is_model_downloaded(model_name: str) -> bool: diff --git a/tests/test_autostart_manager.py b/tests/test_autostart_manager.py index f045b51..07630e5 100644 --- a/tests/test_autostart_manager.py +++ b/tests/test_autostart_manager.py @@ -29,6 +29,25 @@ class TestAutostartManager(unittest.TestCase): command = autostart_manager.get_exec_command() self.assertEqual(command, "/usr/bin/python3 -m vocalinux.main --start-minimized") + def test_get_exec_command_flatpak_uses_flatpak_run(self): + # Inside a Flatpak the host session manager must use the flatpak launcher, + # which is always on PATH, rather than /app/bin/vocalinux. + with patch.dict("os.environ", {"FLATPAK_ID": "com.vocalinux.Vocalinux"}, clear=False): + command = autostart_manager.get_exec_command() + self.assertEqual(command, "flatpak run com.vocalinux.Vocalinux --start-minimized") + + def test_enable_autostart_flatpak_writes_flatpak_run_exec(self): + with tempfile.TemporaryDirectory() as tmp_dir: + with patch.dict( + "os.environ", + {"XDG_CONFIG_HOME": tmp_dir, "FLATPAK_ID": "com.vocalinux.Vocalinux"}, + clear=False, + ): + self.assertTrue(autostart_manager.enable_autostart()) + desktop_file = Path(tmp_dir) / "autostart" / "vocalinux.desktop" + content = desktop_file.read_text(encoding="utf-8") + self.assertIn("Exec=flatpak run com.vocalinux.Vocalinux --start-minimized", content) + def test_enable_disable_autostart_creates_and_removes_entry(self): with tempfile.TemporaryDirectory() as tmp_dir: with ( diff --git a/tests/test_keyboard_init_ext.py b/tests/test_keyboard_init_ext.py index 1d180e9..292827c 100644 --- a/tests/test_keyboard_init_ext.py +++ b/tests/test_keyboard_init_ext.py @@ -48,6 +48,73 @@ class TestDesktopEnvironmentDetect: result = DesktopEnvironment.detect() assert result == DesktopEnvironment.X11 + def test_detect_flatpak_wayland_without_socket_uses_x11(self): + """Flatpak with only X11/XWayland is labeled X11 (injection path). + + Global shortcuts still prefer evdev via create_backend when FLATPAK_ID + is set — pynput over XWayland is not truly global. + """ + env = { + "FLATPAK_ID": "com.vocalinux.Vocalinux", + "XDG_SESSION_TYPE": "wayland", + "DISPLAY": ":0", + } + with patch.dict(os.environ, env, clear=True): + assert DesktopEnvironment.detect() == DesktopEnvironment.X11 + + def test_detect_flatpak_wayland_with_socket_stays_wayland(self): + """With a Wayland socket exposed, detection still reports Wayland.""" + env = { + "FLATPAK_ID": "com.vocalinux.Vocalinux", + "XDG_SESSION_TYPE": "wayland", + "WAYLAND_DISPLAY": "wayland-0", + } + with patch.dict(os.environ, env, clear=True): + assert DesktopEnvironment.detect() == DesktopEnvironment.WAYLAND + + def test_create_backend_flatpak_prefers_evdev(self): + """Flatpak must use evdev so hotkeys work when another app is focused.""" + from vocalinux.ui.keyboard_backends import create_backend + + fake = MagicMock() + fake.is_available.return_value = True + + env = { + "FLATPAK_ID": "com.vocalinux.Vocalinux", + "XDG_SESSION_TYPE": "wayland", + "DISPLAY": ":0", + } + with patch.dict(os.environ, env, clear=True): + with ( + patch.object(kb_module, "EVDEV_AVAILABLE", True), + patch.object(kb_module, "EvdevKeyboardBackend", return_value=fake) as mock_evdev, + patch.object(kb_module, "PYNPUT_AVAILABLE", True), + patch.object(kb_module, "PynputKeyboardBackend") as mock_pynput, + ): + backend = create_backend() + assert backend is fake + mock_evdev.assert_called_once() + mock_pynput.assert_not_called() + + def test_create_backend_native_x11_prefers_pynput(self): + """Outside Flatpak on X11, pynput remains the default.""" + from vocalinux.ui.keyboard_backends import create_backend + + env = {"XDG_SESSION_TYPE": "x11", "DISPLAY": ":0"} + with patch.dict(os.environ, env, clear=True): + with ( + patch.object(kb_module, "EVDEV_AVAILABLE", True), + patch.object(kb_module, "EvdevKeyboardBackend") as mock_evdev, + patch.object(kb_module, "PYNPUT_AVAILABLE", True), + patch.object( + kb_module, "PynputKeyboardBackend", return_value=MagicMock() + ) as mock_pynput, + ): + backend = create_backend() + assert backend is mock_pynput.return_value + mock_pynput.assert_called_once() + mock_evdev.assert_not_called() + def test_detect_xdg_session_type_case_insensitive(self): """Test that XDG_SESSION_TYPE detection is case-insensitive.""" with patch.dict(os.environ, {"XDG_SESSION_TYPE": "WAYLAND"}, clear=False): diff --git a/tests/test_logging_manager.py b/tests/test_logging_manager.py index 82ce5f5..7314c51 100644 --- a/tests/test_logging_manager.py +++ b/tests/test_logging_manager.py @@ -117,6 +117,24 @@ class TestLoggingManager: assert logging_manager.log_callbacks == [] assert logging_manager.handler is not None + def test_logs_dir_honours_xdg_data_home(self, tmp_path, monkeypatch): + """Logs must follow XDG_DATA_HOME so Flatpak sandbox paths work.""" + import vocalinux.ui.logging_manager as lm + + monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path)) + lm._logging_manager = None + root_logger = logging.getLogger() + for h in [h for h in root_logger.handlers if isinstance(h, lm.LoggingHandler)]: + root_logger.removeHandler(h) + + manager = lm.LoggingManager(max_records=10) + try: + assert manager.logs_dir == tmp_path / "vocalinux" / "logs" + assert manager.logs_dir.is_dir() + finally: + root_logger.removeHandler(manager.handler) + lm._logging_manager = None + def test_add_log_record(self, logging_manager): """Test adding a log record.""" from vocalinux.ui.logging_manager import LogRecord diff --git a/tests/test_paths.py b/tests/test_paths.py new file mode 100644 index 0000000..a5858e7 --- /dev/null +++ b/tests/test_paths.py @@ -0,0 +1,38 @@ +"""Tests for the XDG base-directory helpers in vocalinux.utils.paths.""" + +import os +from unittest.mock import patch + +from vocalinux.utils import paths + + +class TestXdgHelpers: + def test_defaults_when_env_unset(self): + with patch.dict(os.environ, {}, clear=True): + assert paths.xdg_config_home() == os.path.expanduser("~/.config") + assert paths.xdg_data_home() == os.path.expanduser("~/.local/share") + assert paths.config_dir() == os.path.expanduser("~/.config/vocalinux") + assert paths.data_dir() == os.path.expanduser("~/.local/share/vocalinux") + assert paths.models_dir() == os.path.expanduser("~/.local/share/vocalinux/models") + + def test_honours_xdg_environment(self): + env = { + "XDG_CONFIG_HOME": "/run/user/1000/cfg", + "XDG_DATA_HOME": "/run/user/1000/data", + } + with patch.dict(os.environ, env, clear=True): + assert paths.config_dir() == "/run/user/1000/cfg/vocalinux" + assert paths.data_dir() == "/run/user/1000/data/vocalinux" + assert paths.models_dir() == "/run/user/1000/data/vocalinux/models" + + def test_empty_env_falls_back_to_default(self): + # The XDG spec says empty values must be treated as unset. + with patch.dict(os.environ, {"XDG_CONFIG_HOME": "", "XDG_DATA_HOME": ""}, clear=True): + assert paths.xdg_config_home() == os.path.expanduser("~/.config") + assert paths.xdg_data_home() == os.path.expanduser("~/.local/share") + + def test_flatpak_style_data_home(self): + # Flatpak points XDG_DATA_HOME at ~/.var/app//data. + flatpak_data = "/home/user/.var/app/com.vocalinux.Vocalinux/data" + with patch.dict(os.environ, {"XDG_DATA_HOME": flatpak_data}, clear=True): + assert paths.models_dir() == os.path.join(flatpak_data, "vocalinux", "models") diff --git a/tests/test_text_injector.py b/tests/test_text_injector.py index b68297e..bf26fb7 100644 --- a/tests/test_text_injector.py +++ b/tests/test_text_injector.py @@ -205,9 +205,10 @@ class TestTextInjector(unittest.TestCase): # Inject text injector.inject_text("Hello world") - # Verify ydotool was called correctly with timing parameters + # Clipboard paste fails (no wl-copy/xclip/xsel in this mock); type fallback. + # Default key-delay is 2 (overridable via VOCALINUX_YDOTOOL_KEY_DELAY). self.mock_subprocess.assert_any_call( - ["ydotool", "type", "--key-delay", "8", "Hello world"], + ["ydotool", "type", "--key-delay", "2", "Hello world"], check=True, stderr=subprocess.PIPE, text=True, diff --git a/tests/test_text_injector_ext.py b/tests/test_text_injector_ext.py index b099544..eb04c07 100644 --- a/tests/test_text_injector_ext.py +++ b/tests/test_text_injector_ext.py @@ -100,6 +100,49 @@ class TestDetectEnvironment(unittest.TestCase): result, [DesktopEnvironment.WAYLAND, DesktopEnvironment.WAYLAND_IBUS] ) + def test_detect_flatpak_prefers_ydotool_when_available(self): + """Flatpak prefers ydotool so injection reaches native Wayland apps.""" + from vocalinux.text_injection.text_injector import DesktopEnvironment + + obj = _make_injector(DesktopEnvironment.X11) + env = { + "FLATPAK_ID": "com.vocalinux.Vocalinux", + "XDG_SESSION_TYPE": "wayland", + "DISPLAY": ":0", + } + with patch.dict(os.environ, env, clear=True): + with patch("vocalinux.text_injection.text_injector.shutil.which") as which: + which.side_effect = lambda name: "/app/bin/ydotool" if name == "ydotool" else None + self.assertEqual(obj._detect_environment(), DesktopEnvironment.WAYLAND) + + def test_detect_flatpak_without_ydotool_uses_xwayland(self): + """Without ydotool, Flatpak falls back to xdotool/XWayland.""" + from vocalinux.text_injection.text_injector import DesktopEnvironment + + obj = _make_injector(DesktopEnvironment.X11) + env = { + "FLATPAK_ID": "com.vocalinux.Vocalinux", + "XDG_SESSION_TYPE": "wayland", + "DISPLAY": ":0", + } + with patch.dict(os.environ, env, clear=True): + with patch("vocalinux.text_injection.text_injector.shutil.which", return_value=None): + self.assertEqual(obj._detect_environment(), DesktopEnvironment.WAYLAND_XDOTOOL) + + def test_detect_flatpak_with_wayland_socket_stays_wayland(self): + """If the Wayland socket is exposed, normal Wayland detection still applies.""" + from vocalinux.text_injection.text_injector import DesktopEnvironment + + obj = _make_injector(DesktopEnvironment.WAYLAND) + env = { + "FLATPAK_ID": "com.vocalinux.Vocalinux", + "XDG_SESSION_TYPE": "wayland", + "WAYLAND_DISPLAY": "wayland-0", + "DISPLAY": ":0", + } + with patch.dict(os.environ, env, clear=True): + self.assertEqual(obj._detect_environment(), DesktopEnvironment.WAYLAND) + def test_detect_x11(self): from vocalinux.text_injection.text_injector import DesktopEnvironment diff --git a/tests/test_tray_indicator.py b/tests/test_tray_indicator.py index 5afeb3a..baaab71 100644 --- a/tests/test_tray_indicator.py +++ b/tests/test_tray_indicator.py @@ -547,3 +547,36 @@ class TestTrayIndicator(unittest.TestCase): delattr(self.tray_indicator, "menu") self.tray_indicator._set_menu_item_enabled("Start Voice Typing", True) + + +class TestTrayIndicatorFlatpakIcons(unittest.TestCase): + """Tray icon names must adapt to the Flatpak runtime. + + Inside a Flatpak the StatusNotifier host runs outside the sandbox and resolves + icons from the exported ``-*`` theme, so the indicator must reference + those names rather than the bundled ``vocalinux-*`` names. + """ + + def setUp(self): + sys.modules["gi"] = mock_gi + sys.modules["gi.repository"] = mock_gi_repository + for mod in [k for k in list(sys.modules.keys()) if "tray_indicator" in k]: + del sys.modules[mod] + + def test_themed_icon_names_outside_flatpak(self): + import vocalinux.ui.tray_indicator as tray + + with patch.object(tray, "FLATPAK_ID", None): + names = tray._themed_icon_names() + self.assertEqual(names["default"], "vocalinux-microphone-off") + self.assertEqual(names["active"], "vocalinux-microphone") + self.assertEqual(names["processing"], "vocalinux-microphone-process") + + def test_themed_icon_names_inside_flatpak(self): + import vocalinux.ui.tray_indicator as tray + + with patch.object(tray, "FLATPAK_ID", "com.vocalinux.Vocalinux"): + names = tray._themed_icon_names() + self.assertEqual(names["default"], "com.vocalinux.Vocalinux-microphone-off") + self.assertEqual(names["active"], "com.vocalinux.Vocalinux-microphone") + self.assertEqual(names["processing"], "com.vocalinux.Vocalinux-microphone-process")