fix(launcher): report leftover upstream launchers that no longer start #54

Merged
xavierk merged 1 commits from fix/stale-upstream-launcher into main 2026-11-06 21:03:29 +00:00
4 changed files with 454 additions and 1 deletions
+11
View File
@@ -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
+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()
+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