fix(app): drop upstream references from user-facing source
CI / security (pull_request) Successful in 1m10s
CI / lint (pull_request) Successful in 23s
CI / ai-review (pull_request) Successful in 8s

The missing-dependency guidance now names the Void xbps package and this
fork's install doc, instead of piping the upstream install script to bash
(and drops the pipx line, since pipx is not a published channel). The IBus
daemon hint points at the fork's docs; on niri it points at NIRI.md, because
IBus is excluded there. The IBus component homepage is this fork's repository.

VocaGateway keeps its upstream clone URL: Settings clones that separate
project at its pinned tag at runtime. The URL is now recorded as a credit,
and the gateway guide says the project is upstream's.

The rename sweep no longer lets upstream slugs through under src/. Each
upstream reference in source is a recorded line, matched exactly and once.

Refs #16
This commit is contained in:
2026-11-06 21:57:53 +05:30
parent 37cf549956
commit b24398657c
10 changed files with 178 additions and 11 deletions
+3
View File
@@ -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
+3
View File
@@ -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
@@ -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
+4 -6
View File
@@ -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
+2 -2
View File
@@ -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,
}
+17 -2
View File
@@ -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
+4
View File
@@ -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")
+19
View File
@@ -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."""
+94 -1
View File
@@ -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"
+29
View File
@@ -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.