feat(flatpak): add Flatpak packaging for universal distribution (#484)

* feat(flatpak): add Flatpak packaging for universal distribution

Adds a Flathub-ready Flatpak for the whisper.cpp engine, plus the minimal
runtime adaptations Flatpak actually needs — deliberately scoped down from the
earlier attempts (#416, #428), which bundled unrelated whisper.cpp GPU, audio,
and clipboard changes.

Packaging (packaging/flatpak/):
- app manifest, AppStream metainfo, and desktop entry (com.vocalinux.Vocalinux)
- generated offline Python dependency modules (flatpak-pip-generator)
- bundled shared modules: PortAudio, libayatana-appindicator, xdotool, xsel

Runtime changes (Flatpak-only, behaviour unchanged outside a sandbox):
- utils/paths.py: honour XDG_CONFIG_HOME/XDG_DATA_HOME so config, models and
  runtime data live under ~/.var/app/<app-id> in the sandbox
- autostart uses `flatpak run <app-id>` (the host launcher is always on PATH)
- text injection and keyboard backend detect a Wayland host reached via XWayland
  (no Wayland socket granted) and route through xdotool / pynput-on-X11
- tray indicator references the exported `<app-id>-*` host icon theme names

Also: a CI workflow building x86_64 + aarch64 bundles, and README / packaging
README / distro-compatibility doc updates.

Verified with a full flatpak-builder build + bundle install on aarch64:
pywhispercpp loads its native libraries without LD_LIBRARY_PATH, XDG paths
redirect correctly, bundled tools are present, and the entrypoint runs.
Manifest and builddir lint are clean and AppStream metadata validates.

Closes #167

* chore(flatpak): sync metainfo version to 0.14.0-beta

Also ignore flatpak-build-dir/ so local builder output stays untracked.

* refactor(flatpak): trim docs and comments after ponytail review

Drop redundant packaging README command blocks, shrink path/tray/autostart
comments, and stop ignoring a non-canonical build-dir name.

* fix(flatpak): close readiness gaps for v0.14.0-beta

Route log files through XDG data dirs so Flatpak keeps them under
~/.var/app, refresh AppStream screenshots to vocalinux.com, and use
the SPDX GPL-3.0-only license id.

* fix(flatpak): enable global hotkeys via evdev and --device=input

pynput over XWayland only receives keys for the focused X client. Prefer
evdev inside Flatpak and grant /dev/input so shortcuts work app-wide.

* fix(flatpak): inject text with ydotool/uinput on Wayland

xdotool only reaches XWayland clients, so typing appeared to succeed but
never hit native apps. Ship ydotool, grant --device=all for uinput, start
ydotoold on demand, and type via uinput inside the Flatpak.

* fix(flatpak): paste via wl-copy + Ctrl+V instead of typing

ydotool type injects character-by-character. Ship wl-clipboard, grant
--socket=wayland, and prefer clipboard paste for instant insertion.

* test(text-injection): expect ydotool default key-delay of 2

Matches the shipped VOCALINUX_YDOTOOL_KEY_DELAY default used when
clipboard paste falls back to character typing.
This commit is contained in:
Jatin K Malik
2026-07-16 00:03:30 -07:00
committed by GitHub
parent 48a4dd6374
commit 3deb523efd
34 changed files with 1361 additions and 69 deletions
+59
View File
@@ -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
+6
View File
@@ -83,6 +83,12 @@ out/
dist/
build/
# Flatpak build artifacts
.flatpak-builder/
build-dir/
/repo/
*.flatpak
# Environment variables
.env
.env.local
+18
View File
@@ -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
+1 -1
View File
@@ -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
+99
View File
@@ -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: <release-commit-sha>
```
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/`.
@@ -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
@@ -0,0 +1,129 @@
<?xml version="1.0" encoding="UTF-8"?>
<component type="desktop-application">
<id>com.vocalinux.Vocalinux</id>
<name>Vocalinux</name>
<summary>Free, offline voice dictation for Linux</summary>
<developer id="com.vocalinux">
<name>Jatin K Malik</name>
</developer>
<metadata_license>CC0-1.0</metadata_license>
<project_license>GPL-3.0-only</project_license>
<description>
<p>
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.
</p>
<p>Features:</p>
<ul>
<li>Toggle or push-to-talk activation modes</li>
<li>Real-time transcription with minimal latency</li>
<li>Universal compatibility across all Linux applications</li>
<li>100% offline operation for privacy and reliability</li>
<li>whisper.cpp by default — high-performance C++ speech recognition</li>
<li>Universal GPU support via Vulkan (AMD, Intel, NVIDIA)</li>
<li>System tray integration with visual status indicators</li>
<li>Start on login support via XDG autostart</li>
<li>Pleasant audio feedback with smooth gliding tones</li>
<li>Graphical settings dialog for easy configuration</li>
</ul>
<p>
This Flatpak ships the whisper.cpp engine. On a Wayland session, text is
injected through XWayland.
</p>
</description>
<launchable type="desktop-id">com.vocalinux.Vocalinux.desktop</launchable>
<categories>
<category>Utility</category>
<category>Accessibility</category>
<category>AudioVideo</category>
</categories>
<keywords>
<keyword>voice</keyword>
<keyword>typing</keyword>
<keyword>dictation</keyword>
<keyword>speech</keyword>
<keyword>recognition</keyword>
<keyword>transcription</keyword>
<keyword>accessibility</keyword>
</keywords>
<screenshots>
<screenshot type="default">
<caption>Real-time voice-to-text transcription</caption>
<image>https://vocalinux.com/screenshots/00-transcription.png</image>
</screenshot>
<screenshot>
<caption>Speech engine settings</caption>
<image>https://vocalinux.com/screenshots/settings-speech-engine.png</image>
</screenshot>
<screenshot>
<caption>System tray indicator</caption>
<image>https://vocalinux.com/screenshots/02-system-tray.png</image>
</screenshot>
</screenshots>
<url type="homepage">https://vocalinux.com</url>
<url type="bugtracker">https://github.com/jatinkrmalik/vocalinux/issues</url>
<url type="vcs-browser">https://github.com/jatinkrmalik/vocalinux</url>
<content_rating type="oars-1.1">
<content_attribute id="violence-cartoon">none</content_attribute>
<content_attribute id="violence-fantasy">none</content_attribute>
<content_attribute id="violence-realistic">none</content_attribute>
<content_attribute id="violence-bloodshed">none</content_attribute>
<content_attribute id="violence-sexual">none</content_attribute>
<content_attribute id="violence-desecration">none</content_attribute>
<content_attribute id="violence-slavery">none</content_attribute>
<content_attribute id="violence-worship">none</content_attribute>
<content_attribute id="drugs-alcohol">none</content_attribute>
<content_attribute id="drugs-narcotics">none</content_attribute>
<content_attribute id="drugs-tobacco">none</content_attribute>
<content_attribute id="sex-nudity">none</content_attribute>
<content_attribute id="sex-themes">none</content_attribute>
<content_attribute id="sex-homosexuality">none</content_attribute>
<content_attribute id="sex-prostitution">none</content_attribute>
<content_attribute id="sex-adultery">none</content_attribute>
<content_attribute id="sex-appearance">none</content_attribute>
<content_attribute id="language-profanity">none</content_attribute>
<content_attribute id="language-humor">none</content_attribute>
<content_attribute id="language-discrimination">none</content_attribute>
<content_attribute id="social-chat">none</content_attribute>
<content_attribute id="social-info">none</content_attribute>
<content_attribute id="social-audio">none</content_attribute>
<content_attribute id="social-location">none</content_attribute>
<content_attribute id="social-contacts">none</content_attribute>
<content_attribute id="money-purchasing">none</content_attribute>
<content_attribute id="money-gambling">none</content_attribute>
</content_rating>
<releases>
<release version="0.14.0-beta" date="2026-07-14">
<description>
<p>Vocalinux 0.14.0-beta as Flatpak: whisper.cpp with Vulkan, XDG config/models/logs, XWayland text injection, and system tray integration.</p>
</description>
</release>
<release version="0.12.0-beta" date="2026-06-07">
<description>
<p>First Flatpak release: whisper.cpp engine with Vulkan GPU support, XDG-compliant data paths, and XWayland text injection.</p>
</description>
</release>
<release version="0.11.0-beta" date="2026-05-30">
<description>
<p>Guided whisper.cpp model variants, hotplug keyboard rescanning, and IBus setup refinements.</p>
</description>
</release>
<release version="0.10.2-beta" date="2026-04-08">
<description>
<p>Non-ASCII text injection, IBus Wayland detection fixes, Pop!_OS dependency support, and code quality improvements.</p>
</description>
</release>
</releases>
</component>
@@ -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: ../..
@@ -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
+180
View File
@@ -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
@@ -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"
}
]
}
@@ -0,0 +1,59 @@
Description: Escape "{", to prevent complaints from perl 5.22 and 5.26
Author: Roderich Schupp <roderich.schupp@gmail.com>
Author: gregor herrmann <gregoa@debian.org>
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?
@@ -0,0 +1,50 @@
From 729546c51806a1b3ea6cb6efb7a115b1baa811f1 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Stefan=20Br=C3=BCns?= <stefan.bruens@rwth-aachen.de>
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
@@ -0,0 +1,43 @@
From 8a09e6ad33c58c017c0c8fd756da036fc39428ea Mon Sep 17 00:00:00 2001
From: Alexander Koskovich <akoskovich@pm.me>
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
@@ -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.]+)$"
}
}
]
}
+4 -2
View File
@@ -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
@@ -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:
+4 -2
View File
@@ -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"
+79 -26
View File
@@ -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:
+5
View File
@@ -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"
+3 -1
View File
@@ -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
+25 -11
View File
@@ -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
+4 -2
View File
@@ -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
+3 -2
View File
@@ -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:
+36 -15
View File
@@ -37,18 +37,35 @@ from .settings_dialog import SettingsDialog
logger = logging.getLogger(__name__)
# Define constants
APP_ID = "vocalinux"
# Flatpak StatusNotifier host resolves exported <app-id>-* 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}")
+30
View File
@@ -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")
+6 -4
View File
@@ -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:
+19
View File
@@ -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 (
+67
View File
@@ -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):
+18
View File
@@ -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
+38
View File
@@ -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/<app-id>/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")
+3 -2
View File
@@ -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,
+43
View File
@@ -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
+33
View File
@@ -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 ``<app-id>-*`` 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")