fix(app): drop upstream references from user-facing source #40

Merged
xavierk merged 1 commits from fix/src-upstream-references into main 2026-11-06 16:32:53 +00:00
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.