chore(release): prepare v0.14.1-beta (#525)
Patch release for Flatpak/AUR packaging and post-0.14.0 fixes: installer wrappers, ydotool dual API and Flatpak socket probe, model download timeouts, layout-aware hotkeys, and related docs/web bumps.
This commit is contained in:
@@ -32,42 +32,28 @@ It's a free, GPLv3-licensed desktop app that lets you dictate text into *any* ap
|
||||
|
||||
No internet required. No data leaves your machine. Just speak and type.
|
||||
|
||||
## 📚 What's New in v0.14.0-beta
|
||||
## 📚 What's New in v0.14.1-beta
|
||||
|
||||
> 🎉 **Release: Configurable keyboard shortcuts, FunASR/SenseVoice remote-API support, and another round of Wayland reliability fixes.**
|
||||
> **Release: Flatpak packaging, AUR package, layout-aware hotkeys, and installer/text-injection fixes.**
|
||||
|
||||
### 🚀 Highlights
|
||||
### Highlights
|
||||
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
| **⌨️ Configurable Shortcuts** | Bind any modifier combination to a key — e.g. `Alt+R`, `Ctrl+Shift+V`, or `Super+F10` |
|
||||
| **🌐 FunASR / SenseVoice Remote API** | Remote-API engine now supports FunASR and SenseVoice models via OpenAI-compatible endpoints |
|
||||
| **🖥️ GNOME Wayland IBus Reliability** | Text injection works again on GNOME Wayland with bare `xkb` layouts and engine restore fallbacks are fixed |
|
||||
| **🎙️ Audio Crash Fix** | Recording no longer crashes when the system audio device index changes between sessions |
|
||||
| **⚡ Hybrid-CPU Efficiency** | whisper.cpp no longer defaults to all cores on hybrid Intel/AMD processors |
|
||||
| **Flatpak packaging** | Distro-independent install with whisper.cpp, global hotkeys (evdev), and Wayland text injection (wl-copy + ydotool) |
|
||||
| **AUR package** | Arch users can install via `yay -S vocalinux` |
|
||||
| **Layout-aware hotkeys** | Combo keys work correctly on non-US keyboard layouts |
|
||||
| **Installer / text injection** | `sg` fix for Ubuntu 26.04/Debian 13; treat XIM `none` as unset |
|
||||
|
||||
### ✨ New Features
|
||||
See [docs/UPDATE.md](docs/UPDATE.md) and the [full changelog](https://github.com/jatinkrmalik/vocalinux/releases/tag/v0.14.1-beta).
|
||||
|
||||
- **Configurable modifier+key hotkeys** — The Settings dialog now lets you set custom shortcuts using any combination of Ctrl, Alt, Shift, and Super plus a letter/number key. The legacy defaults still work, and you can now bind combinations like `Alt+R` or `Ctrl+Shift+V` (#493)
|
||||
- **Remote API FunASR/SenseVoice support** — OpenAI-compatible remote endpoints can specify FunASR/SenseVoice model names (e.g. `sensevoice`) and return richer response shapes; SenseVoice metadata labels are stripped before text injection (#468)
|
||||
### Previous: v0.14.0-beta
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **GNOME Wayland/IBus**: Restore text injection when only a bare `xkb` engine is configured; the engine restore fallback now picks the correct IM engine instead of silently dropping text (#506, #500)
|
||||
- **KDE Wayland/IBus**: Restore the KDE Plasma Wayland IBus text-injection path that was regressed in recent compositor-detection changes (#502)
|
||||
- **Wayland injection**: Wait for held modifiers (Ctrl/Alt/Shift/Super) to release before injecting text, preventing accidental shortcut triggers and garbled output on modifier-heavy workflows (#494)
|
||||
- **Shortcuts UI**: Keep preset and custom shortcut selection exclusive — selecting a preset now clears the custom field, and setting a custom combo selects the "Custom Shortcut" preset (#509)
|
||||
- **whisper.cpp**: Stop defaulting to all CPU cores on hybrid processors (Intel Performance + Efficient cores), which caused UI lag and excess battery drain (#492)
|
||||
- **Audio**: Fix a crash on recording start when the selected audio device index no longer matches the current system enumeration (#499)
|
||||
- **Installer**: Include `xsel` as a fallback for the Wayland clipboard path when `xclip` is unavailable (#496)
|
||||
|
||||
### 🔧 Improvements
|
||||
|
||||
- **Code style** — Removed an outdated long comment about whisper.cpp default thread counts (#505)
|
||||
Configurable shortcuts, FunASR/SenseVoice remote API, Wayland IBus reliability, audio crash fix, hybrid-CPU efficiency. Details in [UPDATE.md](docs/UPDATE.md).
|
||||
|
||||
---
|
||||
|
||||
## ✨ Features
|
||||
## Features
|
||||
|
||||
- 🎤 **Toggle or Push-to-Talk** activation modes
|
||||
- ⚡ **Real-time transcription** with minimal latency
|
||||
@@ -440,7 +426,7 @@ Vocalinux is part of a family of privacy-first, offline voice dictation tools. S
|
||||
|
||||
| Platform | Project | Website | GitHub | Status |
|
||||
|----------|---------|---------|--------|--------|
|
||||
| 🐧 Linux | **VocaLinux** | [vocalinux.com](https://vocalinux.com) | [jatinkrmalik/vocalinux](https://github.com/jatinkrmalik/vocalinux) | ✅ Beta v0.14.0 |
|
||||
| 🐧 Linux | **VocaLinux** | [vocalinux.com](https://vocalinux.com) | [jatinkrmalik/vocalinux](https://github.com/jatinkrmalik/vocalinux) | ✅ Beta v0.14.1 |
|
||||
| 🍎 macOS | **VocaMac** | [vocamac.com](https://vocamac.com) | [jatinkrmalik/vocamac](https://github.com/jatinkrmalik/vocamac) | 🚀 Beta |
|
||||
| 🪟 Windows | **VocaWin** | [vocawin.com](https://vocawin.com) | [jatinkrmalik/vocawin](https://github.com/jatinkrmalik/vocawin) | 📋 Planned |
|
||||
|
||||
|
||||
@@ -345,6 +345,7 @@ git push origin v0.5.1-beta
|
||||
| 0.12.0-beta | 2026-06-07 | Beta | Remote API engine, Silero VAD, threading/IBus hardening, settings and installer fixes |
|
||||
| 0.13.0-beta | 2026-06-30 | Beta | Guided whisper.cpp model variants, Wayland text-injection reliability, hotplug keyboard support, dictation spacing fix, website docs refresh |
|
||||
| 0.14.0-beta | 2026-07-13 | Beta | Configurable modifier+key hotkeys, FunASR/SenseVoice remote-API support, GNOME/KDE Wayland IBus reliability fixes, audio crash fix, hybrid-CPU efficiency fix |
|
||||
| 0.14.1-beta | 2026-07-16 | Beta | Flatpak packaging, AUR package, layout-aware hotkeys, installer/text-injection fixes |
|
||||
|
||||
## Questions?
|
||||
|
||||
|
||||
+40
-8
@@ -2,17 +2,49 @@
|
||||
|
||||
This guide explains how to update Vocalinux to the latest version.
|
||||
|
||||
## What's New in v0.14.0-beta
|
||||
## What's New in v0.14.1-beta
|
||||
|
||||
### 🚀 Highlights
|
||||
### Highlights
|
||||
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
| **⌨️ Configurable Shortcuts** | Bind any modifier combination to a key — e.g. `Alt+R`, `Ctrl+Shift+V`, or `Super+F10` |
|
||||
| **🌐 FunASR / SenseVoice Remote API** | Remote-API engine now supports FunASR and SenseVoice models via OpenAI-compatible endpoints |
|
||||
| **🖥️ GNOME Wayland IBus Reliability** | Text injection works again on GNOME Wayland with bare `xkb` layouts and engine restore fallbacks are fixed |
|
||||
| **🎙️ Audio Crash Fix** | Recording no longer crashes when the system audio device index changes between sessions |
|
||||
| **⚡ Hybrid-CPU Efficiency** | whisper.cpp no longer defaults to all cores on hybrid Intel/AMD processors |
|
||||
| **Flatpak packaging** | Universal Flatpak (whisper.cpp) with sandbox-aware paths, global hotkeys, and Wayland paste injection |
|
||||
| **AUR package** | Official Arch packaging and CI publish path |
|
||||
| **Layout-aware hotkeys** | Combo keys respect non-US layouts |
|
||||
| **Installer / injection fixes** | `sg` on Ubuntu 26.04/Debian 13; XIM `none` treated as unset |
|
||||
|
||||
### New Features
|
||||
|
||||
- **Flatpak packaging** — GNOME Platform 50, whisper.cpp + Vulkan, ydotool/wl-copy injection, evdev global shortcuts; build via `packaging/flatpak/` (#484, closes #167)
|
||||
- **AUR release package** — PKGBUILD and CI publish for Arch Linux (#518)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **Hotkeys**: Layout-aware combo keys for non-US layouts (#514)
|
||||
- **Installer**: `sg` not found on Ubuntu 26.04 / Debian 13 (#524)
|
||||
- **Text injection**: Treat XIM `none` as unset (#512)
|
||||
- **Web**: Dependabot npm alerts in package-lock (#515)
|
||||
|
||||
### Docs
|
||||
|
||||
- Refreshed v0.14 UI screenshots and website gallery (#521)
|
||||
- README Star History and related polish
|
||||
|
||||
See the [full changelog](https://github.com/jatinkrmalik/vocalinux/releases/tag/v0.14.1-beta).
|
||||
|
||||
---
|
||||
|
||||
## What's New in v0.14.0-beta
|
||||
|
||||
### Highlights
|
||||
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
| **Configurable Shortcuts** | Bind any modifier combination to a key — e.g. `Alt+R`, `Ctrl+Shift+V`, or `Super+F10` |
|
||||
| **FunASR / SenseVoice Remote API** | Remote-API engine now supports FunASR and SenseVoice models via OpenAI-compatible endpoints |
|
||||
| **GNOME Wayland IBus Reliability** | Text injection works again on GNOME Wayland with bare `xkb` layouts and engine restore fallbacks are fixed |
|
||||
| **Audio Crash Fix** | Recording no longer crashes when the system audio device index changes between sessions |
|
||||
| **Hybrid-CPU Efficiency** | whisper.cpp no longer defaults to all cores on hybrid Intel/AMD processors |
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
@@ -273,7 +305,7 @@ The installer will:
|
||||
```bash
|
||||
cd vocalinux
|
||||
git fetch origin
|
||||
git checkout v0.14.0-beta
|
||||
git checkout v0.14.1-beta
|
||||
./install.sh
|
||||
```
|
||||
|
||||
|
||||
+13
-7
@@ -420,8 +420,12 @@ if [ "$IS_VOCALINUX_LOCAL" = true ]; then
|
||||
# Running from within the vocalinux repo
|
||||
INSTALL_DIR="$(pwd)"
|
||||
print_info "Running from local repository: $INSTALL_DIR"
|
||||
# Convert VENV_DIR to absolute path for wrapper scripts
|
||||
VENV_DIR="$INSTALL_DIR/$VENV_DIR"
|
||||
# Convert relative VENV_DIR to absolute for wrapper scripts.
|
||||
# Absolute --venv-dir= paths must not be re-prefixed with INSTALL_DIR.
|
||||
case "$VENV_DIR" in
|
||||
/*) ;;
|
||||
*) VENV_DIR="$INSTALL_DIR/$VENV_DIR" ;;
|
||||
esac
|
||||
else
|
||||
# Running remotely (e.g., via curl | bash)
|
||||
print_info "Installing Vocalinux version: ${INSTALL_TAG}"
|
||||
@@ -3075,12 +3079,14 @@ REMOTE_CONFIG
|
||||
# Create wrapper scripts in ~/.local/bin for easy access
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
|
||||
# Shared sg check logic for wrapper scripts
|
||||
# Uses sg to activate input group for Wayland keyboard shortcuts without logout
|
||||
local SG_CHECK='if grep -q "^input:.*\b\$(whoami)\b" /etc/group 2>/dev/null && ! groups | grep -q "\binput\b" && command -v sg &>/dev/null; then
|
||||
exec sg input -c "\$EXEC_CMD"
|
||||
# Shared sg check logic for wrapper scripts.
|
||||
# Uses sg to activate the input group for Wayland keyboard shortcuts without logout.
|
||||
# Single-quoted so install.sh does not expand; the wrapper expands $(whoami)/$EXEC_CMD
|
||||
# at runtime. Do not write \$ — that leaves a literal $EXEC_CMD for exec.
|
||||
local SG_CHECK='if grep -q "^input:.*\b$(whoami)\b" /etc/group 2>/dev/null && ! groups | grep -q "\binput\b" && command -v sg &>/dev/null; then
|
||||
exec sg input -c "$EXEC_CMD"
|
||||
else
|
||||
exec \$EXEC_CMD
|
||||
exec $EXEC_CMD
|
||||
fi'
|
||||
|
||||
# Create vocalinux wrapper script
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
|
||||
pkgname=vocalinux
|
||||
# AUR pkgver cannot contain hyphens (v0.14.0-beta -> 0.14.0beta).
|
||||
pkgver=0.14.0beta
|
||||
_tag=0.14.0-beta
|
||||
pkgver=0.14.1beta
|
||||
_tag=0.14.1-beta
|
||||
pkgrel=1
|
||||
pkgdesc="Free, offline voice dictation for Linux"
|
||||
arch=('any')
|
||||
|
||||
@@ -78,7 +78,7 @@ pipx run flatpak-pip-generator \
|
||||
sources:
|
||||
- type: git
|
||||
url: https://github.com/jatinkrmalik/vocalinux.git
|
||||
tag: v0.14.0-beta
|
||||
tag: v0.14.1-beta
|
||||
commit: <release-commit-sha>
|
||||
```
|
||||
|
||||
|
||||
@@ -105,6 +105,11 @@
|
||||
</content_rating>
|
||||
|
||||
<releases>
|
||||
<release version="0.14.1-beta" date="2026-07-16">
|
||||
<description>
|
||||
<p>First Flathub-oriented packaging release: Flatpak with whisper.cpp + Vulkan, wl-copy/ydotool Wayland paste, evdev global hotkeys, AUR package, layout-aware shortcuts, and installer fixes.</p>
|
||||
</description>
|
||||
</release>
|
||||
<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>
|
||||
|
||||
@@ -93,6 +93,8 @@ modules:
|
||||
sha256: 6eb8081207fb5581d1d82c4bcd9587205a31a3d47bea3ebeb7f41aa1143783eb
|
||||
|
||||
# uinput-based typing / paste chords for native Wayland clients.
|
||||
# Pinned to v1.0.4 (keycode:value key API). Distro 0.1.x uses "ctrl+v" instead;
|
||||
# vocalinux detects the installed CLI at runtime (see TextInjector).
|
||||
- name: ydotool
|
||||
buildsystem: cmake-ninja
|
||||
config-opts:
|
||||
@@ -105,6 +107,11 @@ modules:
|
||||
- type: archive
|
||||
url: https://github.com/ReimuNotMoe/ydotool/archive/refs/tags/v1.0.4.tar.gz
|
||||
sha256: ba075a43aa6ead51940e892ecffa4d0b8b40c241e4e2bc4bd9bd26b61fde23bd
|
||||
x-checker-data:
|
||||
type: anitya
|
||||
project-id: 138160
|
||||
stable-only: true
|
||||
url-template: https://github.com/ReimuNotMoe/ydotool/archive/refs/tags/v$version.tar.gz
|
||||
- type: shell
|
||||
commands:
|
||||
# scdoc is not in the SDK; man pages are not needed in the sandbox.
|
||||
|
||||
@@ -1684,6 +1684,92 @@ class SpeechRecognitionManager:
|
||||
logger.debug(f"whisper.cpp server API format attempt failed: {e}")
|
||||
return None
|
||||
|
||||
# (connect timeout, read timeout) for model HTTP downloads. HF often hangs
|
||||
# without a timeout when the CDN is degraded (e.g. 504 / empty body).
|
||||
_MODEL_DOWNLOAD_TIMEOUT = (15, 120)
|
||||
|
||||
def _stream_model_download(self, url: str, dest_path: str) -> None:
|
||||
"""Stream a model file from ``url`` to ``dest_path`` with progress.
|
||||
|
||||
Raises RuntimeError on cancel, HTTP errors, timeouts, or empty body.
|
||||
"""
|
||||
import requests
|
||||
|
||||
logger.info(f"Downloading from {url}")
|
||||
response = requests.get(
|
||||
url,
|
||||
stream=True,
|
||||
timeout=self._MODEL_DOWNLOAD_TIMEOUT,
|
||||
headers={"User-Agent": "vocalinux/0.14.1"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
content_type = (response.headers.get("content-type") or "").lower()
|
||||
if "text/html" in content_type:
|
||||
raise RuntimeError(
|
||||
f"Model URL returned HTML instead of a binary file "
|
||||
f"(status {response.status_code}, content-type {content_type}). "
|
||||
f"Hugging Face may be unavailable; try again later."
|
||||
)
|
||||
|
||||
total_size = int(response.headers.get("content-length", 0))
|
||||
downloaded_size = 0
|
||||
start_time = time.time()
|
||||
last_update_time = start_time
|
||||
chunk_size = 8192
|
||||
|
||||
with open(dest_path, "wb") as f:
|
||||
for data in response.iter_content(chunk_size=chunk_size):
|
||||
if self._download_cancelled:
|
||||
logger.info("Download cancelled by user")
|
||||
f.close()
|
||||
if os.path.exists(dest_path):
|
||||
os.remove(dest_path)
|
||||
raise RuntimeError("Download cancelled")
|
||||
|
||||
if not data:
|
||||
continue
|
||||
f.write(data)
|
||||
downloaded_size += len(data)
|
||||
|
||||
current_time = time.time()
|
||||
if self._download_progress_callback and (current_time - last_update_time) >= 0.1:
|
||||
elapsed = current_time - start_time
|
||||
speed_mbps = (downloaded_size / (1024 * 1024)) / elapsed if elapsed > 0 else 0
|
||||
|
||||
if total_size > 0:
|
||||
progress = downloaded_size / total_size
|
||||
remaining_mb = (total_size - downloaded_size) / (1024 * 1024)
|
||||
if speed_mbps > 0:
|
||||
eta_seconds = remaining_mb / speed_mbps
|
||||
eta_str = (
|
||||
f"{int(eta_seconds)}s"
|
||||
if eta_seconds < 60
|
||||
else f"{int(eta_seconds / 60)}m {int(eta_seconds % 60)}s"
|
||||
)
|
||||
else:
|
||||
eta_str = "--"
|
||||
status = (
|
||||
f"{downloaded_size / (1024 * 1024):.1f} / "
|
||||
f"{total_size / (1024 * 1024):.1f} MB • "
|
||||
f"{speed_mbps:.1f} MB/s • ETA: {eta_str}"
|
||||
)
|
||||
else:
|
||||
progress = 0
|
||||
status = f"{downloaded_size / (1024 * 1024):.1f} MB • {speed_mbps:.1f} MB/s"
|
||||
|
||||
self._download_progress_callback(progress, speed_mbps, status)
|
||||
last_update_time = current_time
|
||||
logger.info(f"Download progress: {progress * 100:.1f}% - {status}")
|
||||
|
||||
if downloaded_size == 0:
|
||||
if os.path.exists(dest_path):
|
||||
os.remove(dest_path)
|
||||
raise RuntimeError(
|
||||
f"Downloaded 0 bytes from {url}. "
|
||||
"Hugging Face may be down or blocked; try again later."
|
||||
)
|
||||
|
||||
def _download_whispercpp_model(self):
|
||||
"""Download a whisper.cpp model with progress tracking."""
|
||||
import requests
|
||||
@@ -1695,84 +1781,37 @@ class SpeechRecognitionManager:
|
||||
raise ValueError(f"Unknown whisper.cpp model size: {self.model_size}")
|
||||
|
||||
url = model_info["url"]
|
||||
# Prefer explicit download=true (some HF edges serve HTML without it).
|
||||
if "huggingface.co" in url and "download=" not in url:
|
||||
url = url + ("&" if "?" in url else "?") + "download=true"
|
||||
model_path = get_model_path(self.model_size)
|
||||
temp_file = model_path + ".tmp"
|
||||
|
||||
# Ensure directory exists
|
||||
os.makedirs(os.path.dirname(model_path), exist_ok=True)
|
||||
|
||||
logger.info(f"Downloading whisper.cpp {self.model_size} model to {model_path}")
|
||||
logger.info(f"Downloading from {url}")
|
||||
|
||||
try:
|
||||
response = requests.get(url, stream=True)
|
||||
response.raise_for_status()
|
||||
|
||||
total_size = int(response.headers.get("content-length", 0))
|
||||
downloaded_size = 0
|
||||
start_time = time.time()
|
||||
last_update_time = start_time
|
||||
chunk_size = 8192 # 8KB chunks
|
||||
|
||||
with open(temp_file, "wb") as f:
|
||||
for data in response.iter_content(chunk_size=chunk_size):
|
||||
if self._download_cancelled:
|
||||
logger.info("Download cancelled by user")
|
||||
f.close()
|
||||
if os.path.exists(temp_file):
|
||||
os.remove(temp_file)
|
||||
raise RuntimeError("Download cancelled")
|
||||
|
||||
f.write(data)
|
||||
downloaded_size += len(data)
|
||||
|
||||
# Update progress callback
|
||||
current_time = time.time()
|
||||
if (
|
||||
self._download_progress_callback
|
||||
and (current_time - last_update_time) >= 0.1
|
||||
):
|
||||
elapsed = current_time - start_time
|
||||
if elapsed > 0:
|
||||
speed_mbps = (downloaded_size / (1024 * 1024)) / elapsed
|
||||
else:
|
||||
speed_mbps = 0
|
||||
|
||||
if total_size > 0:
|
||||
progress = downloaded_size / total_size
|
||||
remaining_mb = (total_size - downloaded_size) / (1024 * 1024)
|
||||
if speed_mbps > 0:
|
||||
eta_seconds = remaining_mb / speed_mbps
|
||||
eta_str = (
|
||||
f"{int(eta_seconds)}s"
|
||||
if eta_seconds < 60
|
||||
else f"{int(eta_seconds / 60)}m {int(eta_seconds % 60)}s"
|
||||
)
|
||||
else:
|
||||
eta_str = "--"
|
||||
status = f"{downloaded_size / (1024 * 1024):.1f} / {total_size / (1024 * 1024):.1f} MB • {speed_mbps:.1f} MB/s • ETA: {eta_str}"
|
||||
else:
|
||||
progress = 0
|
||||
status = (
|
||||
f"{downloaded_size / (1024 * 1024):.1f} MB • {speed_mbps:.1f} MB/s"
|
||||
)
|
||||
|
||||
self._download_progress_callback(progress, speed_mbps, status)
|
||||
last_update_time = current_time
|
||||
|
||||
logger.info(f"Download progress: {progress * 100:.1f}% - {status}")
|
||||
|
||||
# Rename temp file to final
|
||||
self._stream_model_download(url, temp_file)
|
||||
os.rename(temp_file, model_path)
|
||||
logger.info("whisper.cpp model downloaded successfully")
|
||||
|
||||
if self._download_progress_callback:
|
||||
self._download_progress_callback(1.0, 0, "Complete!")
|
||||
|
||||
# Use RequestException when available; tests mock requests and set
|
||||
# RequestException=Exception. Do not catch Timeout separately — under a
|
||||
# MagicMock that is not a real exception type (TypeError in CI).
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"Failed to download whisper.cpp model from {url}: {e}")
|
||||
if os.path.exists(temp_file):
|
||||
os.remove(temp_file)
|
||||
msg = str(e).lower()
|
||||
if "timeout" in msg or type(e).__name__ == "Timeout":
|
||||
raise RuntimeError(
|
||||
"Model download timed out (Hugging Face may be slow or unavailable). "
|
||||
"Check your network and try again."
|
||||
) from e
|
||||
raise RuntimeError(f"Failed to download whisper.cpp model: {e}") from e
|
||||
except (OSError, RuntimeError, ValueError) as e:
|
||||
logger.error(f"An error occurred during whisper.cpp model download: {e}")
|
||||
@@ -1850,7 +1889,7 @@ class SpeechRecognitionManager:
|
||||
# Download the model
|
||||
logger.info(f"Downloading VOSK model from {url}")
|
||||
try:
|
||||
response = requests.get(url, stream=True)
|
||||
response = requests.get(url, stream=True, timeout=self._MODEL_DOWNLOAD_TIMEOUT)
|
||||
response.raise_for_status() # Raise an exception for bad status codes (4xx or 5xx)
|
||||
|
||||
total_size = int(response.headers.get("content-length", 0))
|
||||
|
||||
@@ -9,6 +9,7 @@ import logging
|
||||
import math
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
@@ -223,47 +224,86 @@ class TextInjector:
|
||||
).lower()
|
||||
return not any(name in desktop for name in self._IBUS_UNBRIDGED_COMPOSITORS)
|
||||
|
||||
def _is_ydotoold_running(self) -> bool:
|
||||
"""Return True if the ydotoold daemon appears to be running.
|
||||
|
||||
``ydotool type ""`` exits 0 even with no daemon (it prints a notice and
|
||||
silently does nothing), so the exit code alone is not a reliable probe.
|
||||
Check for the daemon's socket first, then fall back to scanning for the
|
||||
process itself.
|
||||
"""
|
||||
socket_candidates = []
|
||||
def _ydotool_socket_paths(self) -> list:
|
||||
"""Return candidate Unix socket paths used by ydotoold."""
|
||||
paths = []
|
||||
env_socket = os.environ.get("YDOTOOL_SOCKET")
|
||||
if env_socket:
|
||||
socket_candidates.append(env_socket)
|
||||
paths.append(env_socket)
|
||||
runtime_dir = os.environ.get("XDG_RUNTIME_DIR")
|
||||
if runtime_dir:
|
||||
socket_candidates.append(os.path.join(runtime_dir, ".ydotool_socket"))
|
||||
socket_candidates.append("/tmp/.ydotool_socket")
|
||||
if any(os.path.exists(path) for path in socket_candidates):
|
||||
return True
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["pgrep", "-x", "ydotoold"],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
timeout=2,
|
||||
)
|
||||
return result.returncode == 0
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
# pgrep missing, timed out, or otherwise unavailable -> assume not running
|
||||
return False
|
||||
paths.append(os.path.join(runtime_dir, ".ydotool_socket"))
|
||||
paths.append("/tmp/.ydotool_socket")
|
||||
return paths
|
||||
|
||||
def _is_ydotoold_running(self) -> bool:
|
||||
"""Return True if ydotoold is accepting connections.
|
||||
|
||||
A leftover socket file is not enough: after a crash or a Flatpak session
|
||||
exit the path can remain while nothing listens, and ydotool then fails
|
||||
with exit status 2. Probe with a real connect(); remove only sockets
|
||||
that nothing accepts.
|
||||
|
||||
ydotool 1.x uses a Unix **datagram** socket (not stream). Probing with
|
||||
SOCK_STREAM fails with EPROTOTYPE and must not be treated as stale.
|
||||
"""
|
||||
for path in self._ydotool_socket_paths():
|
||||
if not os.path.exists(path):
|
||||
continue
|
||||
# Try dgram first (ydotool 1.x), then stream (other clients).
|
||||
connected = False
|
||||
wrong_type = False
|
||||
for sock_type in (socket.SOCK_DGRAM, socket.SOCK_STREAM):
|
||||
sock = None
|
||||
try:
|
||||
sock = socket.socket(socket.AF_UNIX, sock_type)
|
||||
sock.settimeout(0.5)
|
||||
sock.connect(path)
|
||||
connected = True
|
||||
break
|
||||
except OSError as e:
|
||||
# Linux: EPROTOTYPE (91) / some kernels EISCONN variants when
|
||||
# socket type mismatches the listening end.
|
||||
if getattr(e, "errno", None) in (
|
||||
getattr(socket, "EPROTOTYPE", 91),
|
||||
91,
|
||||
):
|
||||
wrong_type = True
|
||||
continue
|
||||
finally:
|
||||
if sock is not None:
|
||||
try:
|
||||
sock.close()
|
||||
except OSError:
|
||||
pass
|
||||
if connected:
|
||||
return True
|
||||
if wrong_type:
|
||||
# Socket exists with a type we failed to match; do not unlink —
|
||||
# a live ydotoold may still be serving clients that use dgram.
|
||||
# Fall through to next path.
|
||||
continue
|
||||
try:
|
||||
os.unlink(path)
|
||||
logger.info("Removed stale ydotool socket: %s", path)
|
||||
except OSError:
|
||||
pass
|
||||
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.
|
||||
Host ydotool 0.1.x often works without a daemon; starting one is still
|
||||
safe when ydotoold is installed.
|
||||
"""
|
||||
if self._is_ydotoold_running():
|
||||
return True
|
||||
ydotoold = shutil.which("ydotoold")
|
||||
if not ydotoold:
|
||||
return False
|
||||
# Distro ydotool 0.1.x may not ship a daemon; treat as ready.
|
||||
return shutil.which("ydotool") is not None
|
||||
if not os.path.exists("/dev/uinput"):
|
||||
logger.warning(
|
||||
"ydotoold needs /dev/uinput (Flatpak: grant --device=all). "
|
||||
@@ -1011,14 +1051,15 @@ class TextInjector:
|
||||
logger.warning("Could not copy text to clipboard for paste injection")
|
||||
return False
|
||||
|
||||
# Simulate Ctrl+V via ydotool using evdev keycodes:
|
||||
# KEY_LEFTCTRL=29, KEY_V=47; value 1=press, 0=release.
|
||||
# wtype is intentionally not handled here: wtype uses the Wayland
|
||||
# virtual-keyboard protocol which supports Unicode natively, so it
|
||||
# never needs the clipboard-paste workaround.
|
||||
# Simulate Ctrl+V via ydotool. Syntax differs by major version:
|
||||
# - 0.1.x (distro packages): named sequences, e.g. ctrl+v
|
||||
# - 1.x (Flatpak build): keycode:value (29=LEFTCTRL, 47=V)
|
||||
# Passing 1.x codes to 0.1.x does not paste; it types garbage (e.g. "2442").
|
||||
try:
|
||||
cmd = self._ydotool_ctrl_v_command()
|
||||
logger.debug(f"Simulating paste with: {cmd}")
|
||||
subprocess.run(
|
||||
["ydotool", "key", "29:1", "47:1", "47:0", "29:0"],
|
||||
cmd,
|
||||
check=True,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
@@ -1030,6 +1071,59 @@ class TextInjector:
|
||||
logger.warning(f"Paste simulation failed: {e}")
|
||||
return False
|
||||
|
||||
# ydotool 1.x (Flatpak pins v1.0.4): KEY_LEFTCTRL=29, KEY_V=47 press/release.
|
||||
_YDOTOOL_V1_CTRL_V = ["ydotool", "key", "29:1", "47:1", "47:0", "29:0"]
|
||||
# ydotool 0.1.x (common distro packages): named key sequences.
|
||||
_YDOTOOL_LEGACY_CTRL_V = ["ydotool", "key", "ctrl+v"]
|
||||
|
||||
def _ydotool_ctrl_v_command(self) -> list:
|
||||
"""Return argv to simulate Ctrl+V for the installed ydotool.
|
||||
|
||||
ydotool 0.1.x expects ``key ctrl+v``. ydotool 1.x expects
|
||||
``key 29:1 47:1 47:0 29:0`` (evdev press/release).
|
||||
|
||||
Flatpak always ships pinned ydotool 1.0.4 under /app, so we use the
|
||||
keycode form there without probing. Host installs probe ``key --help``.
|
||||
"""
|
||||
cached = getattr(self, "_ydotool_ctrl_v_cmd", None)
|
||||
if cached is not None:
|
||||
return list(cached)
|
||||
|
||||
# Flatpak package pins ydotool v1.0.4 (see packaging/flatpak manifest).
|
||||
ydotool_path = shutil.which("ydotool")
|
||||
if not isinstance(ydotool_path, str):
|
||||
ydotool_path = ""
|
||||
if os.environ.get("FLATPAK_ID") or ydotool_path.startswith("/app/"):
|
||||
cmd = list(self._YDOTOOL_V1_CTRL_V)
|
||||
self._ydotool_ctrl_v_cmd = cmd
|
||||
return list(cmd)
|
||||
|
||||
help_text = ""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["ydotool", "key", "--help"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=2,
|
||||
)
|
||||
help_text = f"{result.stdout or ''}{result.stderr or ''}"
|
||||
except (OSError, subprocess.SubprocessError) as e:
|
||||
logger.debug(f"Could not probe ydotool key --help: {e}")
|
||||
|
||||
# 0.1.x help: "separated by plus (+)" / examples like alt+r, CTRL+alt+f3
|
||||
if "plus (+)" in help_text or "separated by plus" in help_text.lower():
|
||||
cmd = list(self._YDOTOOL_LEGACY_CTRL_V)
|
||||
elif ":1" in help_text or "keycode" in help_text.lower():
|
||||
cmd = list(self._YDOTOOL_V1_CTRL_V)
|
||||
else:
|
||||
# Unknown help text: prefer named sequence (safe on 0.1.x; fails
|
||||
# loudly on 1.x rather than typing digit garbage).
|
||||
logger.debug("Unrecognized ydotool key --help; defaulting to legacy ctrl+v syntax")
|
||||
cmd = list(self._YDOTOOL_LEGACY_CTRL_V)
|
||||
|
||||
self._ydotool_ctrl_v_cmd = cmd
|
||||
return list(cmd)
|
||||
|
||||
# evdev keycodes for modifier keys. If any of these is still physically held
|
||||
# when a Wayland injection fires, the injected keystrokes are modified: a
|
||||
# held Alt turns the Ctrl+V paste into Ctrl+Alt+V (nothing pastes), and a
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
Version information for Vocalinux.
|
||||
"""
|
||||
|
||||
__version__ = "0.14.0-beta"
|
||||
__version_info__ = (0, 14, 0, "beta")
|
||||
__version__ = "0.14.1-beta"
|
||||
__version_info__ = (0, 14, 1, "beta")
|
||||
__author__ = "Jatin K Malik"
|
||||
__email__ = "jatinkrmalik@gmail.com"
|
||||
__license__ = "GPL-3.0"
|
||||
|
||||
@@ -25,9 +25,7 @@ if "gi" not in sys.modules:
|
||||
if "gi.repository" not in sys.modules:
|
||||
sys.modules["gi.repository"] = MagicMock()
|
||||
|
||||
from vocalinux.speech_recognition.recognition_manager import (
|
||||
SpeechRecognitionManager,
|
||||
)
|
||||
from vocalinux.speech_recognition.recognition_manager import SpeechRecognitionManager
|
||||
|
||||
|
||||
def _make_manager(engine="whisper_cpp", **kw):
|
||||
@@ -161,6 +159,131 @@ class TestDownloadWhispercppModel:
|
||||
with pytest.raises(RuntimeError, match="Failed to download"):
|
||||
manager._download_whispercpp_model()
|
||||
|
||||
def test_download_whispercpp_appends_download_true(self, tmp_path):
|
||||
"""Hugging Face URLs get ?download=true for reliable binary responses."""
|
||||
manager = _make_manager(engine="whisper_cpp")
|
||||
model_file = str(tmp_path / "ggml-small.bin")
|
||||
|
||||
mock_requests = MagicMock()
|
||||
mock_response = MagicMock()
|
||||
mock_response.headers = {"content-length": "4", "content-type": "application/octet-stream"}
|
||||
mock_response.iter_content.return_value = [b"data"]
|
||||
mock_requests.get.return_value = mock_response
|
||||
mock_requests.exceptions.RequestException = Exception
|
||||
|
||||
with patch.dict("sys.modules", {"requests": mock_requests}):
|
||||
with patch(
|
||||
"vocalinux.speech_recognition.recognition_manager.get_model_path",
|
||||
return_value=model_file,
|
||||
):
|
||||
manager._download_whispercpp_model()
|
||||
|
||||
called_url = mock_requests.get.call_args[0][0]
|
||||
assert "huggingface.co" in called_url
|
||||
assert "download=true" in called_url
|
||||
assert mock_requests.get.call_args[1].get("timeout") == manager._MODEL_DOWNLOAD_TIMEOUT
|
||||
|
||||
def test_download_whispercpp_timeout_message(self, tmp_path):
|
||||
"""Timeout-like errors surface a dedicated user-facing message."""
|
||||
manager = _make_manager(engine="whisper_cpp")
|
||||
model_file = str(tmp_path / "ggml-small.bin")
|
||||
|
||||
mock_requests = MagicMock()
|
||||
mock_requests.get.side_effect = Exception("Read timeout")
|
||||
mock_requests.exceptions.RequestException = Exception
|
||||
|
||||
with patch.dict("sys.modules", {"requests": mock_requests}):
|
||||
with patch(
|
||||
"vocalinux.speech_recognition.recognition_manager.get_model_path",
|
||||
return_value=model_file,
|
||||
):
|
||||
with pytest.raises(RuntimeError, match="timed out"):
|
||||
manager._download_whispercpp_model()
|
||||
|
||||
def test_stream_model_download_rejects_html(self, tmp_path):
|
||||
"""HTML error pages must not be written as model binaries."""
|
||||
manager = _make_manager(engine="whisper_cpp")
|
||||
dest = str(tmp_path / "bad.bin")
|
||||
|
||||
mock_requests = MagicMock()
|
||||
mock_response = MagicMock()
|
||||
mock_response.headers = {"content-type": "text/html; charset=utf-8"}
|
||||
mock_response.status_code = 200
|
||||
mock_requests.get.return_value = mock_response
|
||||
|
||||
with patch.dict("sys.modules", {"requests": mock_requests}):
|
||||
with pytest.raises(RuntimeError, match="HTML"):
|
||||
manager._stream_model_download("https://example.com/model.bin", dest)
|
||||
|
||||
assert not os.path.exists(dest)
|
||||
|
||||
def test_stream_model_download_empty_body(self, tmp_path):
|
||||
"""Zero-byte downloads are treated as failure and cleaned up."""
|
||||
manager = _make_manager(engine="whisper_cpp")
|
||||
dest = str(tmp_path / "empty.bin")
|
||||
|
||||
mock_requests = MagicMock()
|
||||
mock_response = MagicMock()
|
||||
mock_response.headers = {"content-length": "0", "content-type": "application/octet-stream"}
|
||||
mock_response.iter_content.return_value = [b"", b""]
|
||||
mock_requests.get.return_value = mock_response
|
||||
|
||||
with patch.dict("sys.modules", {"requests": mock_requests}):
|
||||
with pytest.raises(RuntimeError, match="0 bytes"):
|
||||
manager._stream_model_download("https://example.com/model.bin", dest)
|
||||
|
||||
assert not os.path.exists(dest)
|
||||
|
||||
def test_stream_model_download_cancelled(self, tmp_path):
|
||||
"""User cancel mid-stream removes the partial file."""
|
||||
manager = _make_manager(engine="whisper_cpp")
|
||||
manager._download_cancelled = True
|
||||
dest = str(tmp_path / "partial.bin")
|
||||
|
||||
mock_requests = MagicMock()
|
||||
mock_response = MagicMock()
|
||||
mock_response.headers = {
|
||||
"content-length": "100",
|
||||
"content-type": "application/octet-stream",
|
||||
}
|
||||
mock_response.iter_content.return_value = [b"chunk"]
|
||||
mock_requests.get.return_value = mock_response
|
||||
|
||||
with patch.dict("sys.modules", {"requests": mock_requests}):
|
||||
with pytest.raises(RuntimeError, match="cancelled"):
|
||||
manager._stream_model_download("https://example.com/model.bin", dest)
|
||||
|
||||
assert not os.path.exists(dest)
|
||||
|
||||
def test_stream_model_download_eta_minutes_and_empty_chunks(self, tmp_path):
|
||||
"""Progress path covers multi-minute ETA and skips empty chunks."""
|
||||
manager = _make_manager(engine="whisper_cpp")
|
||||
progress_calls = []
|
||||
manager._download_progress_callback = lambda progress, speed, status: progress_calls.append(
|
||||
status
|
||||
)
|
||||
dest = str(tmp_path / "big.bin")
|
||||
|
||||
mock_requests = MagicMock()
|
||||
mock_response = MagicMock()
|
||||
# Large total so remaining/speed yields ETA >= 60s
|
||||
mock_response.headers = {
|
||||
"content-length": str(100 * 1024 * 1024),
|
||||
"content-type": "application/octet-stream",
|
||||
}
|
||||
mock_response.iter_content.return_value = [b"", b"x" * 1024]
|
||||
mock_requests.get.return_value = mock_response
|
||||
|
||||
# start=0, then 0.2 for progress update (elapsed > 0, small speed)
|
||||
times = [0.0, 0.2, 0.2]
|
||||
with patch.dict("sys.modules", {"requests": mock_requests}):
|
||||
with patch("time.time", side_effect=lambda: times.pop(0) if times else 1.0):
|
||||
manager._stream_model_download("https://example.com/model.bin", dest)
|
||||
|
||||
assert os.path.exists(dest)
|
||||
assert os.path.getsize(dest) == 1024
|
||||
assert any("m " in s or "ETA" in s for s in progress_calls)
|
||||
|
||||
|
||||
class TestDownloadVoskModel:
|
||||
"""Test _download_vosk_model() with runtime import mocking."""
|
||||
|
||||
+167
-14
@@ -688,6 +688,78 @@ class TestTextInjector(unittest.TestCase):
|
||||
"wtype must NOT use clipboard-paste workaround",
|
||||
)
|
||||
|
||||
@patch("vocalinux.text_injection.text_injector.subprocess.run")
|
||||
@patch("vocalinux.text_injection.text_injector.shutil.which")
|
||||
def test_ydotool_ctrl_v_command_legacy_uses_named_sequence(self, mock_which, mock_run):
|
||||
"""Distro ydotool 0.1.x expects ctrl+v, not keycode:value."""
|
||||
mock_which.return_value = "/usr/bin/ydotool"
|
||||
mock_run.return_value = MagicMock(
|
||||
returncode=0,
|
||||
stdout="",
|
||||
stderr=(
|
||||
"Each key sequence can be any number of modifiers and keys, "
|
||||
"separated by plus (+)\n"
|
||||
),
|
||||
)
|
||||
injector = TextInjector.__new__(TextInjector)
|
||||
with patch.dict("os.environ", {}, clear=False):
|
||||
# Ensure host path (not Flatpak)
|
||||
os.environ.pop("FLATPAK_ID", None)
|
||||
cmd = injector._ydotool_ctrl_v_command()
|
||||
self.assertEqual(cmd, ["ydotool", "key", "ctrl+v"])
|
||||
self.assertEqual(injector._ydotool_ctrl_v_command(), ["ydotool", "key", "ctrl+v"])
|
||||
|
||||
@patch("vocalinux.text_injection.text_injector.shutil.which")
|
||||
def test_ydotool_ctrl_v_command_flatpak_uses_keycodes(self, mock_which):
|
||||
"""Flatpak pins ydotool 1.0.4; always use keycode:value form."""
|
||||
mock_which.return_value = "/app/bin/ydotool"
|
||||
injector = TextInjector.__new__(TextInjector)
|
||||
with patch.dict("os.environ", {"FLATPAK_ID": "com.vocalinux.Vocalinux"}):
|
||||
cmd = injector._ydotool_ctrl_v_command()
|
||||
self.assertEqual(cmd, ["ydotool", "key", "29:1", "47:1", "47:0", "29:0"])
|
||||
|
||||
@patch("vocalinux.text_injection.text_injector.subprocess.run")
|
||||
@patch("vocalinux.text_injection.text_injector.shutil.which")
|
||||
def test_ydotool_ctrl_v_command_v1_help_uses_keycodes(self, mock_which, mock_run):
|
||||
"""Host ydotool 1.x (no plus-sequence help) uses press/release keycodes."""
|
||||
mock_which.return_value = "/usr/local/bin/ydotool"
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout="Usage: key N:1 N:0 ...", stderr="")
|
||||
injector = TextInjector.__new__(TextInjector)
|
||||
os.environ.pop("FLATPAK_ID", None)
|
||||
cmd = injector._ydotool_ctrl_v_command()
|
||||
self.assertEqual(cmd, ["ydotool", "key", "29:1", "47:1", "47:0", "29:0"])
|
||||
|
||||
@patch("vocalinux.text_injection.text_injector.subprocess.run")
|
||||
@patch("vocalinux.text_injection.text_injector.shutil.which")
|
||||
def test_ydotool_ctrl_v_command_unknown_help_defaults_legacy(self, mock_which, mock_run):
|
||||
"""Unrecognized help text prefers named ctrl+v (safe on 0.1.x)."""
|
||||
mock_which.return_value = "/usr/bin/ydotool"
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout="mystery help", stderr="")
|
||||
injector = TextInjector.__new__(TextInjector)
|
||||
os.environ.pop("FLATPAK_ID", None)
|
||||
cmd = injector._ydotool_ctrl_v_command()
|
||||
self.assertEqual(cmd, ["ydotool", "key", "ctrl+v"])
|
||||
|
||||
@patch("vocalinux.text_injection.text_injector.subprocess.run")
|
||||
@patch("vocalinux.text_injection.text_injector.shutil.which")
|
||||
def test_ydotool_ctrl_v_command_probe_error_defaults_legacy(self, mock_which, mock_run):
|
||||
"""If key --help fails, default to legacy named sequence."""
|
||||
mock_which.return_value = "/usr/bin/ydotool"
|
||||
mock_run.side_effect = OSError("no ydotool")
|
||||
injector = TextInjector.__new__(TextInjector)
|
||||
os.environ.pop("FLATPAK_ID", None)
|
||||
cmd = injector._ydotool_ctrl_v_command()
|
||||
self.assertEqual(cmd, ["ydotool", "key", "ctrl+v"])
|
||||
|
||||
@patch("vocalinux.text_injection.text_injector.shutil.which")
|
||||
def test_ydotool_ctrl_v_command_app_prefix_uses_keycodes(self, mock_which):
|
||||
"""/app/bin/ydotool (Flatpak path) always uses 1.x keycodes without FLATPAK_ID."""
|
||||
mock_which.return_value = "/app/bin/ydotool"
|
||||
injector = TextInjector.__new__(TextInjector)
|
||||
os.environ.pop("FLATPAK_ID", None)
|
||||
cmd = injector._ydotool_ctrl_v_command()
|
||||
self.assertEqual(cmd, ["ydotool", "key", "29:1", "47:1", "47:0", "29:0"])
|
||||
|
||||
@patch("vocalinux.text_injection.text_injector.shutil.which")
|
||||
@patch("vocalinux.text_injection.text_injector.subprocess.run")
|
||||
def test_clipboard_paste_returns_false_on_paste_failure(self, mock_run, mock_which):
|
||||
@@ -1715,24 +1787,103 @@ class TestCompositorIBusBridging(unittest.TestCase):
|
||||
with patch.dict("os.environ", {"XDG_CURRENT_DESKTOP": "COSMIC"}):
|
||||
self.assertTrue(injector._wayland_compositor_bridges_ibus())
|
||||
|
||||
@patch("vocalinux.text_injection.text_injector.socket.socket")
|
||||
@patch("os.path.exists", return_value=True)
|
||||
def test_is_ydotoold_running_true_when_socket_exists(self, _mock_exists):
|
||||
def test_is_ydotoold_running_true_when_socket_connects(self, _mock_exists, mock_socket_cls):
|
||||
"""Live ydotoold socket must accept a connect() probe."""
|
||||
mock_sock = MagicMock()
|
||||
mock_socket_cls.return_value = mock_sock
|
||||
injector = self._bare_injector()
|
||||
self.assertTrue(injector._is_ydotoold_running())
|
||||
mock_sock.connect.assert_called()
|
||||
|
||||
@patch("vocalinux.text_injection.text_injector.subprocess.run")
|
||||
@patch("os.path.exists", return_value=False)
|
||||
def test_is_ydotoold_running_true_when_process_found(self, _mock_exists, mock_run):
|
||||
@patch("os.unlink")
|
||||
@patch("vocalinux.text_injection.text_injector.socket.socket")
|
||||
@patch("os.path.exists", return_value=True)
|
||||
def test_is_ydotoold_running_false_and_unlinks_stale_socket(
|
||||
self, _mock_exists, mock_socket_cls, mock_unlink
|
||||
):
|
||||
"""Stale socket files must not count as a running daemon."""
|
||||
mock_sock = MagicMock()
|
||||
mock_sock.connect.side_effect = ConnectionRefusedError("stale")
|
||||
mock_socket_cls.return_value = mock_sock
|
||||
injector = self._bare_injector()
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
self.assertTrue(injector._is_ydotoold_running())
|
||||
|
||||
@patch("vocalinux.text_injection.text_injector.subprocess.run")
|
||||
@patch("os.path.exists", return_value=False)
|
||||
def test_is_ydotoold_running_false_when_absent(self, _mock_exists, mock_run):
|
||||
injector = self._bare_injector()
|
||||
mock_run.return_value = MagicMock(returncode=1)
|
||||
self.assertFalse(injector._is_ydotoold_running())
|
||||
self.assertTrue(mock_unlink.called)
|
||||
|
||||
@patch("os.unlink")
|
||||
@patch("vocalinux.text_injection.text_injector.socket.socket")
|
||||
@patch("os.path.exists", return_value=True)
|
||||
def test_is_ydotoold_running_prefers_dgram_connect(
|
||||
self, _mock_exists, mock_socket_cls, mock_unlink
|
||||
):
|
||||
"""ydotool 1.x listens on SOCK_DGRAM; probe connects with dgram first."""
|
||||
import socket as socket_mod
|
||||
|
||||
dgram_sock = MagicMock()
|
||||
stream_sock = MagicMock()
|
||||
err = OSError(91, "Protocol wrong type for socket")
|
||||
err.errno = 91
|
||||
stream_sock.connect.side_effect = err
|
||||
|
||||
def socket_factory(family, sock_type, *args, **kwargs):
|
||||
if sock_type == socket_mod.SOCK_DGRAM:
|
||||
return dgram_sock
|
||||
return stream_sock
|
||||
|
||||
mock_socket_cls.side_effect = socket_factory
|
||||
injector = self._bare_injector()
|
||||
self.assertTrue(injector._is_ydotoold_running())
|
||||
dgram_sock.connect.assert_called()
|
||||
stream_sock.connect.assert_not_called()
|
||||
mock_unlink.assert_not_called()
|
||||
|
||||
@patch("os.unlink")
|
||||
@patch("vocalinux.text_injection.text_injector.socket.socket")
|
||||
@patch("os.path.exists", return_value=True)
|
||||
def test_is_ydotoold_running_eprototype_only_does_not_unlink(
|
||||
self, _mock_exists, mock_socket_cls, mock_unlink
|
||||
):
|
||||
"""If only EPROTOTYPE is seen, do not delete a possibly live socket."""
|
||||
err = OSError(91, "Protocol wrong type for socket")
|
||||
err.errno = 91
|
||||
mock_sock = MagicMock()
|
||||
mock_sock.connect.side_effect = err
|
||||
mock_socket_cls.return_value = mock_sock
|
||||
injector = self._bare_injector()
|
||||
self.assertFalse(injector._is_ydotoold_running())
|
||||
mock_unlink.assert_not_called()
|
||||
|
||||
@patch("os.path.exists", return_value=False)
|
||||
def test_is_ydotoold_running_false_when_absent(self, _mock_exists):
|
||||
injector = self._bare_injector()
|
||||
self.assertFalse(injector._is_ydotoold_running())
|
||||
|
||||
@patch("vocalinux.text_injection.text_injector.shutil.which")
|
||||
def test_ensure_ydotoold_ready_when_only_ydotool_cli(self, mock_which):
|
||||
"""Host ydotool 0.1.x without ydotoold is still considered ready."""
|
||||
mock_which.side_effect = lambda cmd: "/usr/bin/ydotool" if cmd == "ydotool" else None
|
||||
injector = self._bare_injector()
|
||||
with patch.object(injector, "_is_ydotoold_running", return_value=False):
|
||||
self.assertTrue(injector._ensure_ydotoold())
|
||||
|
||||
@patch("os.path.exists", return_value=False)
|
||||
@patch("vocalinux.text_injection.text_injector.shutil.which", return_value="/app/bin/ydotoold")
|
||||
def test_ensure_ydotoold_false_without_uinput(self, _mock_which, _mock_exists):
|
||||
injector = self._bare_injector()
|
||||
with patch.object(injector, "_is_ydotoold_running", return_value=False):
|
||||
self.assertFalse(injector._ensure_ydotoold())
|
||||
|
||||
@patch("vocalinux.text_injection.text_injector.subprocess.Popen")
|
||||
@patch("os.path.exists", return_value=True)
|
||||
@patch("vocalinux.text_injection.text_injector.shutil.which", return_value="/app/bin/ydotoold")
|
||||
def test_ensure_ydotoold_starts_daemon(self, _mock_which, _mock_exists, mock_popen):
|
||||
injector = self._bare_injector()
|
||||
# First probe: not running; after start: running
|
||||
with patch.object(injector, "_is_ydotoold_running", side_effect=[False, False, True]):
|
||||
with patch("time.sleep"):
|
||||
self.assertTrue(injector._ensure_ydotoold())
|
||||
mock_popen.assert_called_once()
|
||||
|
||||
@patch("vocalinux.text_injection.text_injector.is_ibus_daemon_running", return_value=True)
|
||||
@patch("vocalinux.text_injection.text_injector.is_ibus_active_input_method", return_value=True)
|
||||
@@ -1770,11 +1921,13 @@ class TestCompositorIBusBridging(unittest.TestCase):
|
||||
mock_ibus_class.assert_not_called()
|
||||
|
||||
def test_is_ydotoold_running_true_when_socket_env_set(self):
|
||||
"""An explicit YDOTOOL_SOCKET pointing at an existing socket counts as running."""
|
||||
"""An explicit YDOTOOL_SOCKET that accepts connect() counts as running."""
|
||||
injector = self._bare_injector()
|
||||
with patch.dict("os.environ", {"YDOTOOL_SOCKET": "/run/user/1000/.ydotool_socket"}):
|
||||
with patch("os.path.exists", return_value=True):
|
||||
self.assertTrue(injector._is_ydotoold_running())
|
||||
with patch("vocalinux.text_injection.text_injector.socket.socket") as mock_cls:
|
||||
mock_cls.return_value = MagicMock()
|
||||
self.assertTrue(injector._is_ydotoold_running())
|
||||
|
||||
@patch("vocalinux.text_injection.text_injector.is_ibus_available", return_value=False)
|
||||
@patch("vocalinux.text_injection.text_injector.shutil.which")
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "vocalinux-website",
|
||||
"version": "0.14.0",
|
||||
"version": "0.14.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "vocalinux-website",
|
||||
"version": "0.14.0",
|
||||
"version": "0.14.1",
|
||||
"dependencies": {
|
||||
"@types/node": "^20.14.10",
|
||||
"@types/react": "^18.3.3",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "vocalinux-website",
|
||||
"version": "0.14.0",
|
||||
"version": "0.14.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -14,6 +14,19 @@ import { SeoSubpageShell } from "@/components/seo-subpage-shell";
|
||||
import { absoluteUrl, buildPageMetadata } from "@/lib/seo";
|
||||
|
||||
const releases = [
|
||||
{
|
||||
version: "v0.14.1-beta",
|
||||
date: "2026-07-16",
|
||||
type: "beta",
|
||||
highlights: [
|
||||
"Flatpak packaging for universal distribution: whisper.cpp engine, XDG sandbox paths, global hotkeys via evdev, Wayland text injection via wl-copy + ydotool (PR #484, closes #167)",
|
||||
"AUR package and CI publish path for Arch Linux (PR #518)",
|
||||
"Layout-aware combo keys so custom shortcuts work on non-US keyboard layouts (PR #514)",
|
||||
"Installer fix for sg not found on Ubuntu 26.04 / Debian 13 (PR #524)",
|
||||
"Text injection treats XIM none as unset (PR #512)",
|
||||
"Website screenshot gallery refresh and Dependabot npm alert fixes (PR #521, #515)",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "v0.14.0-beta",
|
||||
date: "2026-07-13",
|
||||
|
||||
@@ -68,7 +68,7 @@ const homeJsonLd = [
|
||||
},
|
||||
description:
|
||||
"Offline voice dictation and speech-to-text for Linux with whisper.cpp and VOSK.",
|
||||
softwareVersion: "0.14.0-beta",
|
||||
softwareVersion: "0.14.1-beta",
|
||||
author: {
|
||||
"@type": "Person",
|
||||
name: "Jatin K Malik",
|
||||
@@ -341,7 +341,7 @@ export default function HomePage() {
|
||||
/>
|
||||
<span className="text-lg font-bold sm:text-xl">Vocalinux</span>
|
||||
<span className="from-primary/20 border-primary/30 shadow-primary/20 hidden rounded-full border bg-gradient-to-r to-green-500/20 px-2.5 py-1 text-xs font-semibold text-primary shadow-sm sm:inline-block">
|
||||
v0.14.0 Beta
|
||||
v0.14.1 Beta
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user