diff --git a/docs/GATEWAY_EMBED.md b/docs/GATEWAY_EMBED.md index 177b897..295b863 100644 --- a/docs/GATEWAY_EMBED.md +++ b/docs/GATEWAY_EMBED.md @@ -6,6 +6,9 @@ optional power-user path. It is **not** on-device processing: microphone audio still leaves the desktop client and is transcribed by the gateway process on your machine (or LAN). +VocaGateway is a separate upstream project by VocaHQ, not part of Voculinux. +Voculinux does not publish it; the Settings action clones the pinned upstream tag. + The default speech engine remains **whisper.cpp** on-device. Enabling a local gateway never changes that default by itself. Use **Use this Gateway** only when you want Voculinux to talk to the gateway over the existing Remote API settings diff --git a/src/vocalinux/gateway_embed/__init__.py b/src/vocalinux/gateway_embed/__init__.py index 2885620..062bcb5 100644 --- a/src/vocalinux/gateway_embed/__init__.py +++ b/src/vocalinux/gateway_embed/__init__.py @@ -4,6 +4,9 @@ This is an opt-in path for power users who want to run https://github.com/VocaHQ/vocagateway on the same Linux box via podman (or docker). It is not on-device processing: audio still leaves the desktop client and goes to the local gateway container. + +VocaGateway is a separate upstream project, not part of Voculinux. This fork +does not publish it; the helpers clone its pinned upstream tag. """ import logging diff --git a/src/vocalinux/gateway_embed/paths_embed.py b/src/vocalinux/gateway_embed/paths_embed.py index e46eb9c..5e0d870 100644 --- a/src/vocalinux/gateway_embed/paths_embed.py +++ b/src/vocalinux/gateway_embed/paths_embed.py @@ -13,6 +13,9 @@ logger = logging.getLogger(__name__) # Keep pin explicit: never float on latest. GATEWAY_RELEASE_TAG = "v0.1.0" +# VocaGateway is a separate upstream project, not part of Voculinux, and this fork +# does not publish it. The URL is upstream's on purpose: the Settings action clones +# this pinned tag from it. Do not point it at a fork. GATEWAY_GIT_URL = "https://github.com/VocaHQ/vocagateway.git" COMPOSE_PROJECT = "vocagateway" DEFAULT_PORT = 8765 diff --git a/src/vocalinux/main.py b/src/vocalinux/main.py index f6e479e..3b4a1ab 100644 --- a/src/vocalinux/main.py +++ b/src/vocalinux/main.py @@ -256,14 +256,12 @@ def check_dependencies(): "typelib-1_0-Notify-0_7 libnotify4" ) logger.error("") + logger.error(" Void Linux (signed xbps package, pulls in GTK and PyGObject):") + logger.error(" Register the repository once, as described in:") logger.error( - "For pipx users: Install system packages BEFORE running 'pipx install voculinux'" - ) - logger.error("") - logger.error("For the best experience, use the recommended installer:") - logger.error( - " curl -fsSL https://raw.githubusercontent.com/VocaHQ/vocalinux/main/install.sh | bash" + " https://git.bongbetic.com/xavierk/voculinux/src/branch/main/docs/INSTALL.md" ) + logger.error(" sudo xbps-install -Su voculinux") return False return True diff --git a/src/vocalinux/text_injection/ibus_engine.py b/src/vocalinux/text_injection/ibus_engine.py index 7509e7b..250d880 100644 --- a/src/vocalinux/text_injection/ibus_engine.py +++ b/src/vocalinux/text_injection/ibus_engine.py @@ -106,7 +106,7 @@ except (ImportError, ValueError) as e: # File paths for communication ($XDG_DATA_HOME, defaults to ~/.local/share). # Identity strings come from vocalinux/app_identity.py (see the import above); -# the homepage below is the retained upstream URL, never renamed. +# the homepage below is this fork's repository, not upstream's. VOCULINUX_IBUS_DIR = Path(xdg_data_home()) / IBUS_DIR_NAME SOCKET_PATH = VOCULINUX_IBUS_DIR / "inject.sock" PID_FILE = VOCULINUX_IBUS_DIR / "engine.pid" @@ -125,7 +125,7 @@ _COMPONENT_META = { "version": "1.0", "license": "AGPL-3.0", "author": APP_NAME, - "homepage": "https://github.com/VocaHQ/vocalinux", + "homepage": "https://git.bongbetic.com/xavierk/voculinux", "textdomain": IBUS_TEXTDOMAIN, } diff --git a/src/vocalinux/text_injection/text_injector.py b/src/vocalinux/text_injection/text_injector.py index 6836b9e..aada2a0 100644 --- a/src/vocalinux/text_injection/text_injector.py +++ b/src/vocalinux/text_injection/text_injector.py @@ -133,6 +133,21 @@ def _kde_wayland_ibus_hint() -> str: ) +#: This fork's docs on Gitea. The upstream wiki describes a different project. +_FORK_DOCS_URL = "https://git.bongbetic.com/xavierk/voculinux/src/branch/main/docs" + + +def _ibus_daemon_missing_hint() -> str: + """Return where to look when the IBus daemon is not running. + + niri excludes IBus (see ``TextInjector._wayland_compositor_bridges_ibus``), so + there the hint names niri's notes rather than an IBus setup guide. + """ + if _is_niri_session(): + return f"IBus is excluded on niri; see {_FORK_DOCS_URL}/NIRI.md" + return f"For IBus setup, see: {_FORK_DOCS_URL}/TROUBLESHOOTING.md" + + class DesktopEnvironment(Enum): """Enum representing the desktop environment.""" @@ -977,8 +992,8 @@ class TextInjector: elif not force_ibus and not is_ibus_daemon_running(): logger.info( "IBus daemon not running. This is normal on some desktop environments " - "(e.g., KDE Plasma). Using alternative text injection method. " - "For IBus setup, see: https://github.com/VocaHQ/vocalinux/wiki/IBus-Setup" + "(e.g., KDE Plasma). Using alternative text injection method. %s", + _ibus_daemon_missing_hint(), ) # Some Wayland compositors (COSMIC, Sway, Hyprland, ...) do not deliver # IBus commits to native Wayland apps, so IBus would silently drop the diff --git a/tests/test_ibus_engine_core.py b/tests/test_ibus_engine_core.py index 16e4c5f..014bff9 100644 --- a/tests/test_ibus_engine_core.py +++ b/tests/test_ibus_engine_core.py @@ -806,6 +806,10 @@ class TestVocalinuxEngineApplication(unittest.TestCase): component_kwargs = mock_component_cls.call_args.kwargs self.assertIn("--ibus", component_kwargs["command_line"]) + # The component's homepage is this fork's repository, not upstream's. + self.assertEqual( + component_kwargs["homepage"], "https://git.bongbetic.com/xavierk/voculinux" + ) @patch("vocalinux.text_injection.ibus_engine.VocalinuxEngine._start_socket_server") @patch("vocalinux.text_injection.ibus_engine.IBus.Factory.new") diff --git a/tests/test_main_args_deps.py b/tests/test_main_args_deps.py index 3e0d46a..85153e9 100644 --- a/tests/test_main_args_deps.py +++ b/tests/test_main_args_deps.py @@ -224,6 +224,25 @@ class TestCheckDependencies(unittest.TestCase): ) mock_logger.error.assert_any_call(" sudo apt install gnome-shell-extension-appindicator") + def test_check_dependencies_points_void_users_at_the_xbps_package(self): + """Voculinux ships only as a Void xbps package: the hint names it, no upstream script.""" + from vocalinux.main import check_dependencies + + with patch("vocalinux.main.logger") as mock_logger: + with patch("builtins.__import__", side_effect=ImportError("No module")): + result = check_dependencies() + + assert result is False + logged = [call.args[0] for call in mock_logger.error.call_args_list if call.args] + assert " sudo xbps-install -Su voculinux" in logged + assert ( + " https://git.bongbetic.com/xavierk/voculinux/src/branch/main/docs/INSTALL.md" + in logged + ) + assert not any( + marker in line for line in logged for marker in ("curl", "| bash", "pipx", "VocaHQ") + ) + class TestCheckDisplayAvailable(unittest.TestCase): """Tests for check_display_available() function.""" diff --git a/tests/test_rename_sweep.py b/tests/test_rename_sweep.py index 48d557a..4175b18 100644 --- a/tests/test_rename_sweep.py +++ b/tests/test_rename_sweep.py @@ -26,12 +26,17 @@ install message. On those, upstream references are not allowlisted by pattern at all. An upstream URL, handle or host is a regression unless the line is exactly the one credit line recorded for that file. +Under ``src/`` the slug and label rules are not allowlisted either. Every +upstream reference in source is a recorded line (``SRC_UPSTREAM_CREDITS``), +matched exactly and once, so a new one fails the source scan. + ``tests/test_rename_sweep.py`` also checks the allowlist itself, so a loosened rule fails here before it lets an old name onto a user-visible surface. """ import os import re +from collections import Counter REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) SRC_PACKAGE = os.path.join(REPO_ROOT, "src", "vocalinux") @@ -116,6 +121,34 @@ FORK_SURFACES = frozenset( "packaging/void/srcpkgs/voculinux/INSTALL.msg", } ) +#: Upstream references under ``src/``, by the release checklist's pattern. Each +#: hit must be a recorded line in SRC_UPSTREAM_CREDITS, not a pattern match. +SRC_UPSTREAM_REFERENCE = re.compile( + r"github\.com/(?:VocaHQ|jatinkrmalik)|vocahq|raw\.githubusercontent", re.IGNORECASE +) +#: Every upstream reference under src/, as stripped lines per file. The scan +#: counts occurrences, so a duplicated or stale entry fails too. +SRC_UPSTREAM_CREDITS: dict[str, frozenset[str]] = { + # About-page credit (also the fork-surface credit above). + "src/vocalinux/ui/settings_about.py": FORK_SURFACE_CREDITS[ + "src/vocalinux/ui/settings_about.py" + ], + # Provenance record in RETAINED_SURFACES. + "src/vocalinux/app_identity.py": frozenset( + {'"https://github.com/VocaHQ/vocalinux (upstream URLs and provenance)",'} + ), + # Docstring: the upstream repository is never queried. + "src/vocalinux/utils/update_checker.py": frozenset( + {"upstream VocaHQ repository is never queried, and packaged installs never query"} + ), + # VocaGateway: a separate upstream project, cloned at its pinned tag. + "src/vocalinux/gateway_embed/paths_embed.py": frozenset( + {'GATEWAY_GIT_URL = "https://github.com/VocaHQ/vocagateway.git"'} + ), + "src/vocalinux/gateway_embed/__init__.py": frozenset( + {"https://github.com/VocaHQ/vocagateway on the same Linux box via podman"} + ), +} #: Upstream hosts, handles and URL forms. Plain words such as "Flathub" are not #: listed, so a sentence that says a channel is not published stays legal. UPSTREAM_REFERENCE = re.compile( @@ -229,7 +262,12 @@ def _allowed(path: str, line: str) -> bool: if path == WEB_REBRAND_TEST and line.strip() in WEB_REBRAND_SCANNER_LINES: # The web rebrand test names the old word only to scan for it. return True - if _UPSTREAM_SLUG.search(line) or _UPSTREAM_LABEL.search(line): + if line.strip() in SRC_UPSTREAM_CREDITS.get(path, frozenset()): + return True + if not path.startswith("src/") and ( + _UPSTREAM_SLUG.search(line) or _UPSTREAM_LABEL.search(line) + ): + # Upstream provenance on tests, docs, web and packaging. Source is pinned line by line. return True if _MODULE_PATH.search(line) or _DOTTED_MODULE.search(line): return True @@ -305,6 +343,45 @@ def test_fork_surfaces_carry_their_credit_exactly_once() -> None: assert lines.count(credit) == 1, f"{rel}: credit line must appear exactly once" +def test_src_upstream_references_are_exactly_the_recorded_credits() -> None: + """Under src/, an upstream reference is allowed only on its recorded line, once each.""" + found: Counter[tuple[str, str]] = Counter() + for rel, full in _iter_files(): + if not rel.startswith("src/") or any( + part.endswith(SKIP_SUFFIXES) for part in rel.split("/") + ): + continue + try: + with open(full, encoding="utf-8") as handle: + text = handle.read() + except (UnicodeDecodeError, ValueError): + continue + for line in text.splitlines(): + if SRC_UPSTREAM_REFERENCE.search(line): + found[(rel, line.strip())] += 1 + expected: Counter[tuple[str, str]] = Counter( + (rel, line) for rel, lines in SRC_UPSTREAM_CREDITS.items() for line in lines + ) + unrecorded = sorted(f"{rel}: {line}" for rel, line in (found - expected).elements()) + missing = sorted(f"{rel}: {line}" for rel, line in (expected - found).elements()) + assert not unrecorded and not missing, ( + "src/ upstream references must match SRC_UPSTREAM_CREDITS exactly, once each.\n" + f"unrecorded: {unrecorded}\nmissing: {missing}" + ) + + +def test_gateway_docs_state_vocagateway_is_upstream() -> None: + """The gateway guide says VocaGateway is upstream's project, not Voculinux's.""" + path = os.path.join(REPO_ROOT, "docs", "GATEWAY_EMBED.md") + with open(path, encoding="utf-8") as handle: + text = " ".join(handle.read().split()) + sentence = ( + "VocaGateway is a separate upstream project by VocaHQ, not part of Voculinux. " + "Voculinux does not publish it; the Settings action clones the pinned upstream tag." + ) + assert sentence in text + + def test_owned_web_files_carry_new_name() -> None: """The two web rename surfaces name the fork (URLs aside).""" for rel in sorted(WEB_OWNED): @@ -337,6 +414,18 @@ def test_allowlist_denies_user_facing_old_names() -> None: ("src/vocalinux/ui/tray_indicator.py", 'ICON = "vocalinux-microphone"'), ("packaging/void/srcpkgs/voculinux/template", "pkgname=vocalinux"), ("docs/INSTALL.md", "Open vocalinux.desktop from the menu."), + ( + "src/vocalinux/main.py", + '" curl -fsSL https://raw.githubusercontent.com/VocaHQ/vocalinux/main/install.sh | bash"', + ), + ( + "src/vocalinux/text_injection/text_injector.py", + '"For IBus setup, see: https://github.com/VocaHQ/vocalinux/wiki/IBus-Setup"', + ), + ( + "src/vocalinux/text_injection/ibus_engine.py", + '"homepage": "https://github.com/VocaHQ/vocalinux",', + ), ] for path, line in denied: assert not _allowed(path, line), f"{path}: {line!r} must not be allowlisted" @@ -358,6 +447,10 @@ def test_allowlist_keeps_the_retained_surfaces() -> None: ("tests/test_app_identity.py", "from vocalinux import app_identity"), ("packaging/void/container-release.sh", 'grep -E "usr/share/applications/vocalinux"'), ("install.sh", 'local PKG_NAME="vocalinux"'), + ( + "src/vocalinux/app_identity.py", + '"https://github.com/VocaHQ/vocalinux (upstream URLs and provenance)",', + ), ] for path, line in kept: assert _allowed(path, line), f"{path}: {line!r} is a retained surface" diff --git a/tests/test_text_injector.py b/tests/test_text_injector.py index 6b486f0..2dba017 100644 --- a/tests/test_text_injector.py +++ b/tests/test_text_injector.py @@ -19,6 +19,7 @@ from unittest.mock import MagicMock, mock_open, patch from vocalinux.text_injection.text_injector import ( DesktopEnvironment, TextInjector, + _ibus_daemon_missing_hint, _warn_if_ydotool_globally_enabled, _ydotool_install_guidance, ) @@ -3410,6 +3411,34 @@ class TestCompositorIBusBridging(unittest.TestCase): self.assertEqual(injector.wayland_tool, "portal") +class TestIBusDaemonMissingHint(unittest.TestCase): + """The "IBus daemon not running" hint points at fork docs; niri gets its own notes.""" + + _SESSION_VARS = ("NIRI_SOCKET", "XDG_CURRENT_DESKTOP", "XDG_SESSION_DESKTOP", "DESKTOP_SESSION") + _FORK_DOCS = "https://git.bongbetic.com/xavierk/voculinux/src/branch/main/docs" + + def _hint_for(self, **session: str) -> str: + env = {name: "" for name in self._SESSION_VARS} | session + with mock.patch.dict(os.environ, env): + return _ibus_daemon_missing_hint() + + def test_niri_session_names_niri_notes_not_ibus_setup(self): + self.assertEqual( + self._hint_for(XDG_CURRENT_DESKTOP="niri"), + f"IBus is excluded on niri; see {self._FORK_DOCS}/NIRI.md", + ) + + def test_niri_socket_alone_counts_as_niri(self): + hint = self._hint_for(NIRI_SOCKET="/run/user/1000/niri.wayland-1.sock") + self.assertEqual(hint, f"IBus is excluded on niri; see {self._FORK_DOCS}/NIRI.md") + + def test_other_sessions_point_at_the_fork_ibus_docs(self): + self.assertEqual( + self._hint_for(XDG_CURRENT_DESKTOP="GNOME"), + f"For IBus setup, see: {self._FORK_DOCS}/TROUBLESHOOTING.md", + ) + + @contextlib.contextmanager def _fake_config(config: Any) -> Iterator[None]: """Pretend config.json holds ``config``; ``None`` means no file at all.