2 Commits
Author SHA1 Message Date
xavierk d1a80e6ead chore(release): prepare v0.19.1 (#55)
CI / security (push) Successful in 1m21s
CI / lint (push) Successful in 22s
CI / ai-review (push) Skipped
2026-11-06 21:18:15 +00:00
xavierk 8b842e5712 fix(legacy): flag leftover upstream Vocalinux launchers (#54)
CI / security (push) Successful in 1m12s
CI / lint (push) Successful in 23s
CI / ai-review (push) Skipped
After the tray is up, Voculinux looks for a user-level vocalinux.desktop or ~/.local/bin/vocalinux(-gui) whose target is gone. It logs them and shows one notification naming the files to remove. It never changes the user's files, and a working upstream install is left alone. The README gains a migration note.

Fixes #52
2026-11-06 21:03:29 +00:00
13 changed files with 487 additions and 18 deletions
+7 -1
View File
@@ -12,7 +12,13 @@ Release history for Vocalinux.
## Current stable
**[v0.19.0](https://git.bongbetic.com/xavierk/voculinux/releases/tag/v0.19.0)** (2026-11-07)
**[v0.19.1](https://git.bongbetic.com/xavierk/voculinux/releases/tag/v0.19.1)** (2026-11-07)
A patch on 0.19.0. A leftover upstream launcher (`vocalinux.desktop`, `vocalinux`, `vocalinux-gui`) is now reported at startup, because it hid the Voculinux entry in the launcher (#52, #54). The release pipeline docs now say `bash` for `publish-release.sh`.
### v0.19.0 (2026-11-07)
[Release page](https://git.bongbetic.com/xavierk/voculinux/releases/tag/v0.19.0)
The first Voculinux release: a hard fork of 0.18.5 for Void Linux (glibc, x86_64), published as a signed xbps repository from the fork's Gitea host. Changes since v0.18.5, from the commit subjects and PR titles on `main`:
+13 -2
View File
@@ -11,7 +11,7 @@
[![License: AGPL-3.0](https://img.shields.io/badge/License-AGPL--3.0-blue.svg)](https://www.gnu.org/licenses/agpl-3.0)
[![Privacy: on-device](https://img.shields.io/badge/privacy-on--device-success)](#privacy-and-security)
[![X11 & Wayland](https://img.shields.io/badge/display-X11%20%7C%20Wayland-lightgrey)](#features)
[![Release](https://img.shields.io/badge/release-v0.19.0-478061)](https://git.bongbetic.com/xavierk/voculinux/releases/tag/v0.19.0)
[![Release](https://img.shields.io/badge/release-v0.19.1-478061)](https://git.bongbetic.com/xavierk/voculinux/releases/tag/v0.19.1)
[Website](https://voculinux.com) · [Install](#install) · [Docs](#documentation) · [Releases](https://git.bongbetic.com/xavierk/voculinux/releases) · [Issues](https://git.bongbetic.com/xavierk/voculinux/issues)
@@ -21,7 +21,7 @@ Voculinux turns speech into typed text in whatever app has focus. It is a free,
No account is required. Models download once. After that, speech-to-text stays on your machine.
**Current release:** [v0.19.0](https://git.bongbetic.com/xavierk/voculinux/releases/tag/v0.19.0). The first Voculinux release: a Void Linux package from the [signed xbps repository](#void-linux-signed-xbps-repo), niri and Dank Material Shell support, a model default chosen by free GPU memory, and a fix that keeps system-audio capture from ever opening the microphone. Details: [docs/UPDATE.md](docs/UPDATE.md).
**Current release:** [v0.19.1](https://git.bongbetic.com/xavierk/voculinux/releases/tag/v0.19.1), a patch on v0.19.0 that warns when a leftover launcher from the upstream project shadows Voculinux. v0.19.0 was the first Voculinux release: a Void Linux package from the [signed xbps repository](#void-linux-signed-xbps-repo), niri and Dank Material Shell support, a model default chosen by free GPU memory, and a fix that keeps system-audio capture from ever opening the microphone. Details: [docs/UPDATE.md](docs/UPDATE.md).
## Features
@@ -169,6 +169,17 @@ voculinux # from the xbps package, on your PATH
You can also launch Voculinux from your application menu.
### Migrating from a previous Vocalinux install
A previous Vocalinux install can leave launcher files behind. Those that point at a Python environment which no longer exists start nothing, yet they appear next to Voculinux as an old Vocalinux entry, and choosing one does nothing. Voculinux logs and shows one notice that names those files. It never deletes or changes them, so remove them yourself:
```bash
rm -f ~/.local/share/applications/vocalinux.desktop # old Vocalinux menu entry
rm -f ~/.local/bin/vocalinux ~/.local/bin/vocalinux-gui # old Vocalinux wrapper scripts
```
Keep any launcher that still starts a working install. Voculinux only reports launchers that cannot start.
### From a source checkout (developers)
```bash
+3 -2
View File
@@ -574,10 +574,10 @@ from a clean checkout of the tag, in this order. Replace `vX.Y.Z` with the relea
that is not the committed one, both before any container starts. It writes `sdist/`,
`xbps/`, `SDIST_SHA256` and `BUILT_FROM` (the tag and the commit it was built from), and
prints `checksum=<sha256>`. Remove the key file afterwards.
5. **Dry-run the publish:** `packaging/void/publish-release.sh vX.Y.Z --out-dir /path/to/out --dry-run`.
5. **Dry-run the publish:** `bash packaging/void/publish-release.sh vX.Y.Z --out-dir /path/to/out --dry-run`.
It runs every check (the tag is on `origin` at the built commit, the digest, the key)
and prints the API calls it would make. It sends no request and reads no token.
6. **Publish:** `packaging/void/publish-release.sh vX.Y.Z --out-dir /path/to/out`. It attaches the
6. **Publish:** `bash packaging/void/publish-release.sh vX.Y.Z --out-dir /path/to/out`. It attaches the
sdist, packages, signatures, `FINGERPRINT` and the client `.plist` to the tag's release, then
replaces the assets of the rolling `latest` release that the client registration URL serves.
The repodata is uploaded last. The token comes from `GITEA_TOKEN`, or from the `tea` login
@@ -642,6 +642,7 @@ git push origin v0.5.1-beta
| Version | Date | Phase | Notes |
|---------|------|-------|-------|
| 0.19.1 | 2026-11-07 | Stable | Patch: a leftover upstream Vocalinux launcher is reported at startup (#52) |
| 0.19.0 | 2026-11-07 | Stable | First Voculinux release: a hard fork for Void Linux (glibc, x86_64) with a signed xbps repository, niri and Dank Material Shell support, a per-backend memory-aware model default, and PipeWire system audio that never falls back to the microphone |
| 0.2.0-alpha | 2024 | Alpha | Initial alpha |
| 0.3.0-alpha | 2024 | Alpha | - |
+10 -1
View File
@@ -25,7 +25,7 @@ installer as below.
```bash
cd voculinux
git fetch origin
git checkout v0.19.0
git checkout v0.19.1
./install.sh
```
@@ -90,6 +90,15 @@ Missing system packages: see [INSTALL.md](INSTALL.md) or [DISTRO_COMPATIBILITY.m
---
## What's New in v0.19.1
0.19.1 is a patch on 0.19.0 for Void Linux. If a launcher from the upstream project
is still installed, Voculinux now says so
on startup instead of leaving a stale upstream entry in the launcher. It does not remove
the files; the startup message names them. Update with `sudo xbps-install -Su`.
---
## What's New in v0.19.0
0.19.0 is the first Voculinux release: a hard fork of the 0.18.5 line for Void Linux
+3 -3
View File
@@ -198,7 +198,7 @@ just void-smoke # the gate: build, sign with a throwaway k
just void-release v0.19.0 # build the release set, with XBPS_SIGNING_KEY_FILE set
packaging/void/build-release.sh names v0.19.0-beta # offline: the names a tag maps to
packaging/void/build-release.sh check-tag v0.19.0-beta # offline: the tag gate
packaging/void/publish-release.sh v0.19.0 --out-dir DIR --dry-run # every check, the planned calls, no request
bash packaging/void/publish-release.sh v0.19.0 --out-dir DIR --dry-run # every check, the planned calls, no request
```
Podman only, never Docker. The base image digest and the void-packages commit
@@ -315,11 +315,11 @@ cannot build this release. The sequence is:
then, in that worktree,
`packaging/void/build-release.sh release vX.Y.Z --key-file /dev/shm/xbps-sign-key.pem --out-dir /path/to/out`.
Remove the key file when the run ends.
4. **Dry-run the publish:** `packaging/void/publish-release.sh vX.Y.Z --out-dir /path/to/out --dry-run`.
4. **Dry-run the publish:** `bash packaging/void/publish-release.sh vX.Y.Z --out-dir /path/to/out --dry-run`.
It checks that origin's `vX.Y.Z` peels to the commit in `BUILT_FROM`, that the sdist
matches `SDIST_SHA256`, and that the client key is the committed one, then prints the
calls it would make. It sends no request and reads no token.
5. **Publish:** `packaging/void/publish-release.sh vX.Y.Z --out-dir /path/to/out`. The tag's
5. **Publish:** `bash packaging/void/publish-release.sh vX.Y.Z --out-dir /path/to/out`. The tag's
release gets the sdist, packages, signatures, `FINGERPRINT` and the client `.plist`, and
the rolling `latest` release that the client registration URL serves is replaced, with the
repodata last. The token comes from `GITEA_TOKEN`, or from the `tea` login for
+2 -2
View File
@@ -1,11 +1,11 @@
# Template file for 'voculinux'
pkgname=voculinux
version=0.19.0
version=0.19.1
revision=1
# The release tag this template names: v + __version__, with the pre-release
# hyphen kept (the tag is v0.19.0-beta while version is 0.19.0beta). The release
# gate (packaging/void/build-release.sh check-tag) refuses any mismatch.
_tag=v0.19.0
_tag=v0.19.1
# The release sdist ships no setup.py (setuptools pyproject-only), so the
# build goes through the PEP 517 wheel (cf. Solaar, PhotoCollage).
build_style=python3-pep517
+177 -1
View File
@@ -14,13 +14,22 @@ the payload files above) is rewritten in the copied configuration to that file's
new location, whether or not this run copied it. Edits then never land in the
legacy file. Any other path, including a user-chosen file elsewhere or another
file inside the legacy directory, is kept as stored.
Ticket #52: a previous install can also leave user-level launcher files behind
(a desktop entry and two wrapper scripts). When their target is gone they start
nothing yet still show up as a second app. :func:`report_stale_upstream_launchers`
logs and shows one notice naming them, then records a marker. It never removes
or changes a file. It runs from the GTK idle loop after the tray is up and does
only stat calls and small reads.
"""
import json
import logging
import os
import re
import shlex
import shutil
from typing import Optional
from typing import Callable, Optional
from .app_identity import APP_DIR_NAME, LEGACY_APP_DIR_NAME
@@ -31,6 +40,17 @@ TERMS_FILENAME: str = "dictionary.txt"
CORRECTIONS_FILENAME: str = "custom-dictionary-corrections.json"
RESTORE_TOKEN_FILENAME: str = "remote_desktop_restore_token"
#: Leftover upstream launcher files, reported and never removed (ticket #52).
LEGACY_DESKTOP_FILENAME: str = f"{LEGACY_APP_DIR_NAME}.desktop"
LEGACY_LAUNCHER_NAMES: tuple[str, ...] = (LEGACY_APP_DIR_NAME, f"{LEGACY_APP_DIR_NAME}-gui")
#: Written in the new data directory once the stale-launcher notice has been shown.
STALE_LAUNCHER_MARKER: str = "stale-upstream-launcher-notified"
#: Upper bound on what is read from a launcher. Wrappers are a few KiB.
_MAX_SCRIPT_BYTES: int = 64 * 1024
_ENV_ASSIGNMENT = re.compile(r"[A-Za-z_][A-Za-z0-9_]*=")
_ABSOLUTE_PATH = re.compile(r"/[A-Za-z0-9._@+%~-]+(?:/[A-Za-z0-9._@+%~-]+)+")
_INTERPRETER_NAME = re.compile(r"python[\d.]*")
#: Files copied from the legacy configuration directory. Models are
#: deliberately absent: they re-resolve through the pinned-checksum download
#: path instead of being migrated.
@@ -170,3 +190,159 @@ def import_legacy_once() -> list[str]:
if imported:
logger.info("Legacy Vocalinux import complete: %d file(s)", len(imported))
return imported
def _read_head(path: str) -> Optional[str]:
"""Return the first ``_MAX_SCRIPT_BYTES`` of a text file, or None when unreadable."""
try:
with open(path, encoding="utf-8", errors="replace") as handle:
return handle.read(_MAX_SCRIPT_BYTES)
except OSError:
return None
def _is_interpreter_or_entry(path: str) -> bool:
"""True for a path to an interpreter or a legacy entry script inside a bin directory."""
name = os.path.basename(path)
if os.path.basename(os.path.dirname(path)) != "bin":
return False
return _INTERPRETER_NAME.fullmatch(name) is not None or name in LEGACY_LAUNCHER_NAMES
def _names_missing_interpreter(path: str) -> bool:
"""True when ``path`` is a script whose shebang or interpreter or venv entry is gone.
Only a regular file that starts with ``#!`` counts as a script, so a compiled
binary that happens to contain a dead path is never flagged.
"""
if not os.path.isfile(path):
return False
text = _read_head(path)
if text is None or not text.startswith("#!"):
return False
shebang = text.split("\n", 1)[0][2:].split()
if shebang and shebang[0].startswith("/") and not os.path.exists(shebang[0]):
return True
return any(
_is_interpreter_or_entry(token) and not os.path.exists(token)
for token in _ABSOLUTE_PATH.findall(text)
)
def _launcher_is_stale(path: str) -> bool:
"""True when a launcher file is missing (a dangling link) or names a missing interpreter."""
if not os.path.exists(path):
return True
return _names_missing_interpreter(path)
def _desktop_exec(path: str) -> Optional[str]:
"""Return the ``Exec=`` value of the ``[Desktop Entry]`` group, or None."""
text = _read_head(path)
if text is None:
return None
in_entry = False
for raw in text.splitlines():
line = raw.strip()
if line.startswith("["):
in_entry = line == "[Desktop Entry]"
elif in_entry and line.startswith("Exec="):
return line[len("Exec=") :]
return None
def _exec_target(exec_value: str) -> Optional[str]:
"""Return the program an Exec value starts, after any ``env`` and ``VAR=`` prefix."""
try:
words = shlex.split(exec_value)
except ValueError:
return None
if words and os.path.basename(words[0]) == "env":
words = words[1:]
while words and _ENV_ASSIGNMENT.match(words[0]):
words = words[1:]
return words[0] if words else None
def _desktop_entry_is_stale(path: str) -> bool:
"""True when a desktop entry starts a target that is missing or stale."""
if not os.path.exists(path):
return True
exec_value = _desktop_exec(path)
target = _exec_target(exec_value) if exec_value is not None else None
if target is None:
return False
if "/" in target or target.startswith("~"):
return _launcher_is_stale(os.path.expanduser(target))
# A bare name is looked up on PATH with stat calls only; no subprocess.
resolved = shutil.which(target)
return resolved is None or _launcher_is_stale(resolved)
def find_stale_upstream_launchers() -> list[str]:
"""Return the leftover upstream launcher files that no longer start Voculinux.
Checks three paths: the user-level desktop entry and the two wrappers in
``~/.local/bin``. Only positive evidence counts: a missing target, a dangling
link, or a script that names a missing interpreter or venv entry. A working
install is never reported.
"""
stale: list[str] = []
desktop = os.path.join(
_app_dir("XDG_DATA_HOME", "~/.local/share", "applications"), LEGACY_DESKTOP_FILENAME
)
if os.path.lexists(desktop) and _desktop_entry_is_stale(desktop):
stale.append(desktop)
bin_dir = os.path.expanduser("~/.local/bin")
for name in LEGACY_LAUNCHER_NAMES:
launcher = os.path.join(bin_dir, name)
if os.path.lexists(launcher) and _launcher_is_stale(launcher):
stale.append(launcher)
return stale
def _record_notice(marker: str) -> None:
"""Write the marker that says the stale-launcher notice has been shown."""
try:
os.makedirs(os.path.dirname(marker), exist_ok=True)
with open(marker, "w", encoding="utf-8") as handle:
handle.write("shown\n")
except OSError as error:
logger.warning("Could not record the stale launcher notice at %s: %s", marker, error)
def report_stale_upstream_launchers(
notifier: Optional[Callable[..., object]] = None,
) -> list[str]:
"""Log and show, once, leftover upstream launchers that no longer start Voculinux.
Nothing is scanned once the marker exists. A clean run records nothing, so a
stale launcher that appears later is still reported. The marker is written
after the notice. No file is removed or modified.
``notifier`` takes the signature of :func:`vocalinux.ui.notifications.notify`
and defaults to it. Returns the stale paths reported this run.
"""
marker = os.path.join(new_data_dir(), STALE_LAUNCHER_MARKER)
if os.path.exists(marker):
return []
stale = find_stale_upstream_launchers()
if not stale:
return []
for path in stale:
logger.warning(
"Old Vocalinux launcher %s starts nothing: its install is gone; remove it yourself",
path,
)
if notifier is None:
from .ui import notifications
notifier = notifications.notify
notifier(
"Old Vocalinux launcher found",
"These files from a previous Vocalinux install point at an install that is gone "
"and start nothing. Remove them; Voculinux leaves them in place.\n" + "\n".join(stale),
icon="dialog-warning",
)
_record_notice(marker)
return stale
+16
View File
@@ -344,6 +344,20 @@ def check_appindicator_support():
return True
def _report_stale_upstream_launchers() -> bool:
"""GLib idle callback: show the one-time stale-launcher notice (#52), then stop.
Returns False so GLib drops the callback after one run.
"""
try:
from .legacy_import import report_stale_upstream_launchers
report_stale_upstream_launchers()
except Exception as error:
logger.debug("Stale launcher check skipped: %s", error)
return False
def main():
"""Main entry point for the application."""
# Parse arguments first so flags like --version work even when
@@ -1388,6 +1402,8 @@ def main():
# callback has put the icon up. The load runs on its own thread, so the
# tray is usable while it loads (#50).
GLib.idle_add(speech_engine.load_model_in_background)
# Leftover upstream launchers (#52): checked after the tray is up, once.
GLib.idle_add(_report_stale_upstream_launchers)
# Start the GTK main loop
indicator.run()
+2 -2
View File
@@ -2,8 +2,8 @@
Version information for Voculinux.
"""
__version__ = "0.19.0"
__version_info__ = (0, 19, 0)
__version__ = "0.19.1"
__version_info__ = (0, 19, 1)
__author__ = "Jatin K Malik"
__email__ = "jatinkrmalik@gmail.com"
__license__ = "AGPL-3.0-only"
+250
View File
@@ -277,3 +277,253 @@ def test_existing_new_dictionary_takes_the_edit_and_legacy_stays_put(monkeypatch
assert legacy_terms.read_bytes() == legacy_bytes
assert legacy_terms.stat().st_mtime_ns == legacy_mtime
assert (new_config_dir / "dictionary.txt").read_text(encoding="utf-8") == "Mine\nKubernetes\n"
# --- #52: leftover upstream launchers -----------------------------------------
#
# A previous install can leave a user-level desktop entry and two wrapper
# scripts behind. When their venv is gone they start nothing, yet they show up
# as a second app in launchers. Every path here lives under a fake HOME, XDG
# roots and PATH, so the real home is never read.
_LEGACY_DESKTOP = "vocalinux.desktop" # legacy launcher file name
_LEGACY_LAUNCH = "vocalinux" # legacy CLI wrapper name
_LEGACY_GUI = "vocalinux-gui" # legacy GUI wrapper name
_LEGACY_TITLE = "Vocalinux" # legacy launcher display name
_GI_PREFIX = "env GI_TYPELIB_PATH=/usr/lib/girepository-1.0"
def _isolate_launchers(monkeypatch, tmp_path: Path) -> tuple[Path, Path, Path]:
"""Point HOME, XDG and PATH at scratch dirs; return (home, data_root, path_bin)."""
home = tmp_path / "home"
home.mkdir()
data_root = tmp_path / "data"
path_bin = tmp_path / "path-bin"
path_bin.mkdir()
monkeypatch.setenv("HOME", str(home))
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config"))
monkeypatch.setenv("XDG_DATA_HOME", str(data_root))
monkeypatch.setenv("PATH", str(path_bin))
return home, data_root, path_bin
def _bin_dir(home: Path) -> Path:
return home / ".local" / "bin"
def _write_desktop(data_root: Path, exec_value: str, extra: str = "") -> Path:
"""Write a desktop entry under the fake XDG data home."""
entry = data_root / "applications" / _LEGACY_DESKTOP
entry.parent.mkdir(parents=True, exist_ok=True)
entry.write_text(
f"[Desktop Entry]\nType=Application\nName={_LEGACY_TITLE}\nExec={exec_value}\n{extra}",
encoding="utf-8",
)
return entry
def _make_venv(root: Path, entry: str) -> Path:
"""Create a live venv with an interpreter and the entry script."""
venv = root / "venv"
(venv / "bin").mkdir(parents=True)
for name in ("python", entry):
script = venv / "bin" / name
script.write_text("#!/bin/sh\n", encoding="utf-8")
script.chmod(0o755)
return venv
def _write_wrapper(path: Path, venv: Path, entry: str) -> Path:
"""Write a wrapper shaped like the installer's, bound to ``venv``."""
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
"#!/bin/bash\n"
"export GI_TYPELIB_PATH=/usr/lib/girepository-1.0\n"
f'PY_SITE_PATHS=$("{venv / "bin" / "python"}" - <<\'PY\' 2>/dev/null\n'
"PY\n"
f'EXEC_CMD="{venv / "bin" / entry} \\$*"\n'
'exec sg input -c "$EXEC_CMD"\n',
encoding="utf-8",
)
path.chmod(0o755)
return path
def _recorder(calls: list):
"""Return a notifier stand-in that records (title, message, icon)."""
def _notify(title: str, message: str, icon: str = "") -> None:
calls.append((title, message, icon))
return _notify
def test_no_upstream_launchers_means_no_notice(monkeypatch, tmp_path) -> None:
"""A clean machine has nothing to report and writes no marker."""
_, data_root, _ = _isolate_launchers(monkeypatch, tmp_path)
calls: list = []
assert legacy_import.report_stale_upstream_launchers(notifier=_recorder(calls)) == []
assert calls == []
assert not (data_root / "voculinux" / legacy_import.STALE_LAUNCHER_MARKER).exists()
def test_missing_exec_target_is_flagged_named_and_shown_once(monkeypatch, tmp_path, caplog) -> None:
"""A desktop entry whose target is gone is logged, notified once, and left untouched."""
home, data_root, _ = _isolate_launchers(monkeypatch, tmp_path)
entry = _write_desktop(data_root, str(_bin_dir(home) / _LEGACY_GUI)) # never created
before = entry.read_bytes()
calls: list = []
with caplog.at_level("WARNING", logger="vocalinux.legacy_import"):
stale = legacy_import.report_stale_upstream_launchers(notifier=_recorder(calls))
assert stale == [str(entry)]
assert any(str(entry) in r.getMessage() for r in caplog.records if r.levelname == "WARNING")
assert len(calls) == 1
title, message, _icon = calls[0]
assert title == "Old Vocalinux launcher found"
assert str(entry) in message
assert entry.read_bytes() == before
again: list = []
assert legacy_import.report_stale_upstream_launchers(notifier=_recorder(again)) == []
assert again == []
def test_env_prefixed_exec_into_wrapper_with_missing_venv_is_flagged(monkeypatch, tmp_path) -> None:
"""The reported case: a GI_TYPELIB_PATH env prefix, then a wrapper whose venv is gone."""
home, data_root, _ = _isolate_launchers(monkeypatch, tmp_path)
gone_venv = tmp_path / "gone" / "venv"
gui = _write_wrapper(_bin_dir(home) / _LEGACY_GUI, gone_venv, _LEGACY_GUI)
entry = _write_desktop(data_root, f"{_GI_PREFIX} {gui}")
assert legacy_import.find_stale_upstream_launchers() == [str(entry), str(gui)]
def test_both_wrappers_are_flagged_when_their_venv_is_gone(monkeypatch, tmp_path) -> None:
"""Without a desktop entry, each stale wrapper is still named."""
home, _, _ = _isolate_launchers(monkeypatch, tmp_path)
gone_venv = tmp_path / "gone" / "venv"
launch = _write_wrapper(_bin_dir(home) / _LEGACY_LAUNCH, gone_venv, _LEGACY_LAUNCH)
gui = _write_wrapper(_bin_dir(home) / _LEGACY_GUI, gone_venv, _LEGACY_GUI)
assert legacy_import.find_stale_upstream_launchers() == [str(launch), str(gui)]
def test_working_upstream_install_is_not_flagged(monkeypatch, tmp_path) -> None:
"""A launcher whose target and venv exist is the user's choice, never reported."""
home, data_root, _ = _isolate_launchers(monkeypatch, tmp_path)
venv = _make_venv(tmp_path / "live", _LEGACY_GUI)
gui = _write_wrapper(_bin_dir(home) / _LEGACY_GUI, venv, _LEGACY_GUI)
_write_desktop(data_root, f"{_GI_PREFIX} {gui}")
calls: list = []
assert legacy_import.report_stale_upstream_launchers(notifier=_recorder(calls)) == []
assert calls == []
assert not (data_root / "voculinux" / legacy_import.STALE_LAUNCHER_MARKER).exists()
def test_dangling_wrapper_symlink_is_flagged(monkeypatch, tmp_path) -> None:
"""A symlink whose target was removed starts nothing, so it is stale."""
home, _, _ = _isolate_launchers(monkeypatch, tmp_path)
link = _bin_dir(home) / _LEGACY_LAUNCH
link.parent.mkdir(parents=True)
link.symlink_to(tmp_path / "removed" / "bin" / _LEGACY_LAUNCH)
assert legacy_import.find_stale_upstream_launchers() == [str(link)]
def test_compiled_binary_is_not_read_as_a_wrapper(monkeypatch, tmp_path) -> None:
"""Only a script with a shebang is a wrapper; a binary that names a dead path is left alone."""
home, _, _ = _isolate_launchers(monkeypatch, tmp_path)
binary = _bin_dir(home) / _LEGACY_LAUNCH
binary.parent.mkdir(parents=True)
binary.write_bytes(b"\x7fELF\x02\x01\x01" + str(tmp_path / "gone" / "bin" / "python").encode())
assert legacy_import.find_stale_upstream_launchers() == []
def test_bare_exec_name_is_resolved_through_path(monkeypatch, tmp_path) -> None:
"""A bare Exec name is stale when PATH cannot find it, and fine when a working copy is there."""
_, data_root, path_bin = _isolate_launchers(monkeypatch, tmp_path)
entry = _write_desktop(data_root, _LEGACY_LAUNCH)
assert legacy_import.find_stale_upstream_launchers() == [str(entry)]
venv = _make_venv(tmp_path / "live", _LEGACY_LAUNCH)
_write_wrapper(path_bin / _LEGACY_LAUNCH, venv, _LEGACY_LAUNCH)
assert legacy_import.find_stale_upstream_launchers() == []
@pytest.mark.parametrize("exec_value", ["", "env", '"unterminated /usr/bin/x'])
def test_exec_that_names_no_usable_target_is_not_flagged(monkeypatch, tmp_path, exec_value) -> None:
"""Empty, env-only and unparseable Exec lines give no evidence, so they are not flagged."""
_, data_root, _ = _isolate_launchers(monkeypatch, tmp_path)
_write_desktop(data_root, exec_value)
assert legacy_import.find_stale_upstream_launchers() == []
def test_only_the_main_entry_exec_counts(monkeypatch, tmp_path) -> None:
"""An Exec inside a Desktop Action group is not the launcher's target."""
_, data_root, _ = _isolate_launchers(monkeypatch, tmp_path)
live = tmp_path / "live-launcher"
live.write_text("#!/bin/sh\n", encoding="utf-8")
_write_desktop(
data_root,
str(live),
extra=f"\n[Desktop Action Old]\nName=Old\nExec={tmp_path / 'gone'}\n",
)
assert legacy_import.find_stale_upstream_launchers() == []
def test_existing_marker_skips_the_scan_and_the_notice(monkeypatch, tmp_path) -> None:
"""Once the notice has been recorded, nothing is re-scanned or shown again."""
home, data_root, _ = _isolate_launchers(monkeypatch, tmp_path)
_write_desktop(data_root, str(_bin_dir(home) / _LEGACY_GUI))
marker = data_root / "voculinux" / legacy_import.STALE_LAUNCHER_MARKER
marker.parent.mkdir(parents=True)
marker.write_text("shown\n", encoding="utf-8")
calls: list = []
assert legacy_import.report_stale_upstream_launchers(notifier=_recorder(calls)) == []
assert calls == []
def test_empty_xdg_data_home_falls_back_to_the_default(monkeypatch, tmp_path) -> None:
"""An empty XDG_DATA_HOME means ~/.local/share, as the rest of the module reads it."""
home, _, _ = _isolate_launchers(monkeypatch, tmp_path)
monkeypatch.setenv("XDG_DATA_HOME", "")
entry = _write_desktop(home / ".local" / "share", str(_bin_dir(home) / _LEGACY_GUI))
assert legacy_import.find_stale_upstream_launchers() == [str(entry)]
def test_idle_callback_runs_the_check_once_and_then_stops(monkeypatch) -> None:
"""GLib repeats an idle callback while it returns truthy, so the wrapper must return False."""
from vocalinux import main as voculinux_main
calls: list[int] = []
monkeypatch.setattr(
legacy_import, "report_stale_upstream_launchers", lambda: calls.append(1) or []
)
assert voculinux_main._report_stale_upstream_launchers() is False
assert calls == [1]
def test_idle_callback_swallows_a_failing_check(monkeypatch) -> None:
"""A failing check is logged and never escapes into the GTK main loop."""
from vocalinux import main as voculinux_main
def _boom() -> list[str]:
raise OSError("disk gone")
monkeypatch.setattr(legacy_import, "report_stale_upstream_launchers", _boom)
assert voculinux_main._report_stale_upstream_launchers() is False
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "voculinux-website",
"version": "0.19.0",
"version": "0.19.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "voculinux-website",
"version": "0.19.0",
"version": "0.19.1",
"dependencies": {
"@types/node": "^20.14.10",
"@types/react": "^18.3.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "voculinux-website",
"version": "0.19.0",
"version": "0.19.1",
"private": true,
"type": "module",
"scripts": {
+1 -1
View File
@@ -29,7 +29,7 @@ const homeJsonLd = [
},
description:
"Offline voice dictation and speech-to-text for Linux with whisper.cpp, Faster Whisper, Whisper, VOSK, and Parakeet.",
softwareVersion: "0.19.0",
softwareVersion: "0.19.1",
author: {
"@type": "Person",
name: "Xavier Karma",