Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1a80e6ead | ||
|
|
8b842e5712 |
+7
-1
@@ -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`:
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
[](https://www.gnu.org/licenses/agpl-3.0)
|
||||
[](#privacy-and-security)
|
||||
[](#features)
|
||||
[](https://git.bongbetic.com/xavierk/voculinux/releases/tag/v0.19.0)
|
||||
[](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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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"
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "voculinux-website",
|
||||
"version": "0.19.0",
|
||||
"version": "0.19.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user