refactor(identity): centralise user-visible app identity

Single app_identity module owns APP_NAME/APP_ID, D-Bus names,
desktop/icon/IBus names and related surfaces; 16 consumers import
from it with zero value changes. Deliberately excludes
settings_about and dictation_pad literals pinned by existing tests.

Refs #17
This commit is contained in:
soubarnak
2026-11-06 00:05:26 +05:30
parent 0a4d637f5f
commit d7a0273da1
20 changed files with 350 additions and 99 deletions
+78
View File
@@ -0,0 +1,78 @@
"""Central user-visible application identity for Vocalinux.
Ticket #17 prefactor: every user-visible identity string that is hardcoded in
more than one place is driven from this single module. No value changes here;
the rename ticket (#18) flips them in one place instead of a scavenger hunt.
Retained internal names (the ``vocalinux`` Python package, upstream URLs, and
changelog history) are recorded in :data:`RETAINED_SURFACES`, not renamed.
"""
APP_NAME: str = "Vocalinux"
APP_ID: str = "vocalinux"
APP_DIR_NAME: str = "vocalinux"
CLI_PROG: str = "vocalinux"
BUS_NAME: str = "com.vocalinux.Vocalinux"
OBJECT_PATH: str = "/com/vocalinux/Vocalinux"
INTERFACE_NAME: str = "com.vocalinux.Vocalinux"
DESKTOP_FILENAME: str = "vocalinux.desktop"
DESKTOP_ICON_NAME: str = "vocalinux"
TRAY_TITLE: str = APP_NAME
ICON_DEFAULT: str = "vocalinux-microphone-off"
ICON_ACTIVE: str = "vocalinux-microphone"
ICON_PROCESSING: str = "vocalinux-microphone-process"
ICON_BASE_NAMES: tuple[str, ...] = (
"vocalinux",
ICON_ACTIVE,
ICON_DEFAULT,
ICON_PROCESSING,
)
ENGINE_NAME: str = "vocalinux"
ENGINE_LONGNAME: str = "Vocalinux"
ENGINE_DESCRIPTION: str = "Vocalinux voice dictation (use as default input method)"
COMPONENT_NAME: str = "org.freedesktop.IBus.Vocalinux"
IBUS_DIR_NAME: str = "vocalinux-ibus"
IBUS_TEXTDOMAIN: str = "vocalinux"
UPDATE_CHECKER_USER_AGENT: str = "Vocalinux-UpdateChecker"
RESOURCE_DIR_NAME: str = "vocalinux"
VERIFICATION_STAMP_NAME: str = ".vocalinux_verified"
LOG_FILE_PREFIX: str = "vocalinux_logs_"
PORTAL_TOKEN_PREFIX: str = "vocalinux_"
ENV_FORCE_BACKEND: str = "VOCALINUX_FORCE_BACKEND"
ENV_INJECT_MODIFIER_WAIT: str = "VOCALINUX_INJECT_MODIFIER_WAIT"
ENV_YDOTOOL_KEY_DELAY: str = "VOCALINUX_YDOTOOL_KEY_DELAY"
SITE_URL: str = "https://vocalinux.com"
#: Files the rename ticket (#18) must flip. Checked by
#: ``tests/test_app_identity.py`` so the list cannot drift silently.
RENAME_SURFACES: tuple[str, ...] = (
"pyproject.toml",
"vocalinux.desktop",
"snap/gui/vocalinux.desktop",
"snap/snapcraft.yaml",
"packaging/flatpak/com.vocalinux.Vocalinux.desktop",
"packaging/flatpak/com.vocalinux.Vocalinux.metainfo.xml",
"packaging/flatpak/com.vocalinux.Vocalinux.yml",
"packaging/aur/vocalinux-bin/vocalinux.desktop",
"web/src/app/page.tsx",
"web/package.json",
"README.md",
"docs/INSTALL.md",
)
#: Surfaces deliberately never renamed (internal module name, upstream URLs,
#: history). Recorded here so the rename ticket does not touch them.
RETAINED_SURFACES: tuple[str, ...] = (
"src/vocalinux (Python import package name)",
"https://github.com/VocaHQ/vocalinux (upstream URLs and provenance)",
"CHANGELOG.md (historical changelog entries)",
)
+4 -6
View File
@@ -14,11 +14,9 @@ from typing import Callable, Optional
from gi.repository import Gio, GLib
logger = logging.getLogger(__name__)
from .app_identity import BUS_NAME, INTERFACE_NAME, OBJECT_PATH
BUS_NAME = "com.vocalinux.Vocalinux"
OBJECT_PATH = "/com/vocalinux/Vocalinux"
INTERFACE_NAME = "com.vocalinux.Vocalinux"
logger = logging.getLogger(__name__)
# Maps CLI trigger names to the D-Bus method names exposed below.
COMMAND_METHODS = {
@@ -27,9 +25,9 @@ COMMAND_METHODS = {
"stop": "Stop",
}
_INTROSPECTION_XML = """
_INTROSPECTION_XML = f"""
<node>
<interface name="com.vocalinux.Vocalinux">
<interface name="{INTERFACE_NAME}">
<method name="Toggle"/>
<method name="Start"/>
<method name="Stop"/>
+10 -9
View File
@@ -13,6 +13,7 @@ import time
from concurrent.futures import Future, ThreadPoolExecutor
from typing import Any, Dict, Optional
from .app_identity import APP_ID, APP_NAME, CLI_PROG
from .utils.host_process import running_from_appimage
from .utils.vosk_model_info import SUPPORTED_LANGUAGES
from .version import __version__
@@ -58,12 +59,12 @@ def _should_append_trailing_space() -> bool:
def parse_arguments():
"""Parse command line arguments."""
parser = argparse.ArgumentParser(prog="vocalinux", description="Vocalinux")
parser = argparse.ArgumentParser(prog=CLI_PROG, description=APP_NAME)
parser.add_argument(
"--version",
action="version",
version=f"%(prog)s {__version__}",
help="Show the installed Vocalinux version and exit",
help=f"Show the installed {APP_NAME} version and exit",
)
parser.add_argument("--debug", action="store_true", help="Enable debug logging")
# default model, language and engine are loaded from default config
@@ -174,13 +175,13 @@ def _run_file_transcription(path: str) -> int:
blocks = transcribe_audio_file(path)
except (OSError, ValueError, RuntimeError) as error:
print(
f"vocalinux: could not transcribe {path}: {type(error).__name__}: {error}",
f"{CLI_PROG}: could not transcribe {path}: {type(error).__name__}: {error}",
file=sys.stderr,
)
return 1
transcript = format_transcript(blocks)
print(transcript if transcript else "vocalinux: no speech detected")
print(transcript if transcript else f"{CLI_PROG}: no speech detected")
return 0
@@ -376,9 +377,9 @@ def main():
try:
from gi.repository import Notify
Notify.init("Vocalinux")
Notify.init(APP_NAME)
notification = Notify.Notification.new(
"Vocalinux",
APP_NAME,
"Another instance is already running. Only one instance is allowed at a time.",
"dialog-error",
)
@@ -404,13 +405,13 @@ def main():
gi.require_version("GLib", "2.0")
from gi.repository import GLib
GLib.set_prgname("vocalinux")
GLib.set_application_name("Vocalinux")
GLib.set_prgname(APP_ID)
GLib.set_application_name(APP_NAME)
try:
gi.require_version("Gdk", "3.0")
from gi.repository import Gdk
Gdk.set_program_class("Vocalinux")
Gdk.set_program_class(APP_NAME)
except Exception:
pass
@@ -27,6 +27,7 @@ if TYPE_CHECKING:
import numpy as np
import requests
from ..app_identity import APP_NAME, RESOURCE_DIR_NAME
from ..audio.capture import ( # noqa: F401 (re-exported for compatibility)
_STICKY_LOCK_MIN_MEAN_SQUARE,
CaptureStalledError,
@@ -365,7 +366,7 @@ def _show_notification(title: str, message: str, icon: str = "dialog-warning"):
# Use notify-send which is available on most Linux desktops
subprocess.Popen(
["notify-send", "-i", icon, "-a", "Vocalinux", title, message],
["notify-send", "-i", icon, "-a", APP_NAME, title, message],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
env=host_env(),
@@ -394,7 +395,7 @@ def _get_system_model_paths() -> list:
xdg_data = os.environ.get("XDG_DATA_DIRS", "/usr/local/share:/usr/share")
for base in xdg_data.split(":"):
if base: # Skip empty strings
paths.append(os.path.join(base, "vocalinux", "models"))
paths.append(os.path.join(base, RESOURCE_DIR_NAME, "models"))
# Distribution-specific paths
# Try to detect the distribution from /etc/os-release
@@ -407,12 +408,12 @@ def _get_system_model_paths() -> list:
id in os_release
for id in ["fedora", "rhel", "centos", "rocky", "almalinux", "red hat"]
):
paths.append("/usr/lib64/vocalinux/models")
paths.append("/usr/lib/vocalinux/models")
paths.append(f"/usr/lib64/{RESOURCE_DIR_NAME}/models")
paths.append(f"/usr/lib/{RESOURCE_DIR_NAME}/models")
# Arch Linux doesn't use /usr/local
if "arch" in os_release:
local_share_path = "/usr/local/share/vocalinux/models"
local_share_path = f"/usr/local/share/{RESOURCE_DIR_NAME}/models"
if local_share_path in paths:
paths.remove(local_share_path)
@@ -421,10 +422,10 @@ def _get_system_model_paths() -> list:
# Add common fallback paths that might be used
additional_paths = [
"/usr/local/lib/vocalinux/models",
"/usr/lib/vocalinux/models",
"/usr/lib64/vocalinux/models",
"/opt/vocalinux/models", # Some distros use /opt
f"/usr/local/lib/{RESOURCE_DIR_NAME}/models",
f"/usr/lib/{RESOURCE_DIR_NAME}/models",
f"/usr/lib64/{RESOURCE_DIR_NAME}/models",
f"/opt/{RESOURCE_DIR_NAME}/models", # Some distros use /opt
]
for path in additional_paths:
+34 -11
View File
@@ -30,15 +30,42 @@ from typing import Any, Callable, Mapping, Optional
# Package import when loaded normally; absolute / inline fallbacks when this
# file is executed by path (Vocalinux start_engine_process / IBus component exec).
try:
from ..app_identity import (
APP_NAME,
COMPONENT_NAME,
ENGINE_DESCRIPTION,
ENGINE_LONGNAME,
ENGINE_NAME,
IBUS_DIR_NAME,
IBUS_TEXTDOMAIN,
)
from ..utils.host_process import host_env
from ..utils.paths import xdg_data_home
except ImportError: # pragma: no cover - exercised via script-path subprocess
try:
from vocalinux.app_identity import (
APP_NAME,
COMPONENT_NAME,
ENGINE_DESCRIPTION,
ENGINE_LONGNAME,
ENGINE_NAME,
IBUS_DIR_NAME,
IBUS_TEXTDOMAIN,
)
from vocalinux.utils.host_process import host_env
from vocalinux.utils.paths import xdg_data_home
except ImportError:
# Last resort: same semantics as the vocalinux.utils originals, except
# that host_env cannot strip a bundle it can no longer import.
# Identity literals below keep in step with vocalinux/app_identity.py.
APP_NAME = "Vocalinux"
COMPONENT_NAME = "org.freedesktop.IBus.Vocalinux"
ENGINE_DESCRIPTION = "Vocalinux voice dictation (use as default input method)"
ENGINE_LONGNAME = "Vocalinux"
ENGINE_NAME = "vocalinux"
IBUS_DIR_NAME = "vocalinux-ibus"
IBUS_TEXTDOMAIN = "vocalinux"
def host_env(base: Optional[Mapping[str, str]] = None) -> dict[str, str]:
return dict(os.environ if base is None else base)
@@ -77,33 +104,29 @@ except (ImportError, ValueError) as e:
GObject = None
# File paths for communication ($XDG_DATA_HOME, defaults to ~/.local/share)
VOCALINUX_IBUS_DIR = Path(xdg_data_home()) / "vocalinux-ibus"
# 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.
VOCALINUX_IBUS_DIR = Path(xdg_data_home()) / IBUS_DIR_NAME
SOCKET_PATH = VOCALINUX_IBUS_DIR / "inject.sock"
PID_FILE = VOCALINUX_IBUS_DIR / "engine.pid"
# Engine identification
ENGINE_NAME = "vocalinux"
ENGINE_LONGNAME = "Vocalinux"
ENGINE_DESCRIPTION = "Vocalinux voice dictation (use as default input method)"
COMPONENT_NAME = "org.freedesktop.IBus.Vocalinux"
# Shared metadata used by component XML, --xml output, and runtime registration.
# Kept in one place to prevent drift between the three consumers.
ENGINE_RANK = 50
_ENGINE_META = {
"language": "other",
"license": "AGPL-3.0",
"author": "Vocalinux",
"author": APP_NAME,
"icon": "audio-input-microphone",
"layout": "default",
}
_COMPONENT_META = {
"version": "1.0",
"license": "AGPL-3.0",
"author": "Vocalinux",
"author": APP_NAME,
"homepage": "https://github.com/VocaHQ/vocalinux",
"textdomain": "vocalinux",
"textdomain": IBUS_TEXTDOMAIN,
}
@@ -29,6 +29,7 @@ import threading
import time
from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple
from ..app_identity import PORTAL_TOKEN_PREFIX
from ..utils.paths import data_dir
logger = logging.getLogger(__name__)
@@ -196,7 +197,7 @@ def _save_restore_token(token: str) -> None:
def _new_token() -> str:
"""Return a unique D-Bus handle token."""
return f"vocalinux_{secrets.token_hex(8)}"
return f"{PORTAL_TOKEN_PREFIX}{secrets.token_hex(8)}"
def _predicted_request_path(unique_name: str, token: str) -> str:
+12 -6
View File
@@ -16,6 +16,12 @@ import time
from enum import Enum
from typing import List, Optional, Tuple, cast
from ..app_identity import (
APP_NAME,
ENV_FORCE_BACKEND,
ENV_INJECT_MODIFIER_WAIT,
ENV_YDOTOOL_KEY_DELAY,
)
from ..utils.host_process import host_env
from ..utils.paths import config_dir
from .focused_window import (
@@ -582,7 +588,7 @@ class TextInjector:
Accepts ``ibus``, ``portal``, ``wtype``, ``ydotool``, ``xdotool`` or
``auto``.
"""
raw = os.environ.get("VOCALINUX_FORCE_BACKEND")
raw = os.environ.get(ENV_FORCE_BACKEND)
if raw is None:
return None
value = raw.strip().lower()
@@ -781,7 +787,7 @@ class TextInjector:
"""
env = TextInjector._forced_backend_setting()
if env is not None:
return env, "VOCALINUX_FORCE_BACKEND"
return env, ENV_FORCE_BACKEND
configured = TextInjector._configured_backend()
if configured != "auto":
return configured, "text_injection.backend"
@@ -1661,7 +1667,7 @@ class TextInjector:
"-i",
"edit-paste",
"-a",
"Vocalinux",
APP_NAME,
"Text copied to clipboard",
"Text injection failed — paste with Ctrl+V " "(Ctrl+Shift+V in a terminal)",
],
@@ -2778,7 +2784,7 @@ class TextInjector:
would make the wait deadline infinite and block injection forever while a
modifier stays held.
"""
raw = os.environ.get("VOCALINUX_INJECT_MODIFIER_WAIT")
raw = os.environ.get(ENV_INJECT_MODIFIER_WAIT)
if raw is None:
return self._DEFAULT_INJECT_MODIFIER_WAIT
try:
@@ -2927,7 +2933,7 @@ class TextInjector:
else: # ydotool
# Keep key-delay > 0 to avoid Shift-leak ("Can you" -> "CAN YOu").
# Low delay so fallback typing finishes quickly for long phrases.
key_delay = os.environ.get("VOCALINUX_YDOTOOL_KEY_DELAY", "2")
key_delay = os.environ.get(ENV_YDOTOOL_KEY_DELAY, "2")
# ``--`` ends ydotool's option parsing for the same reason.
cmd = ["ydotool", "type", "--key-delay", key_delay, "--", chunk]
type_timeout = max(5, len(chunk) * self._key_delay_seconds(key_delay) * 4)
@@ -3372,7 +3378,7 @@ class TextInjector:
# 400-character deletion would otherwise take ~9.6s. Match the delay
# `ydotool type` already uses, and scale the budget with the count.
code = self._KEYCODES["backspace"]
delay = os.environ.get("VOCALINUX_YDOTOOL_KEY_DELAY", "2")
delay = os.environ.get(ENV_YDOTOOL_KEY_DELAY, "2")
cmd = ["ydotool", "key", "--key-delay", delay] + [f"{code}:1", f"{code}:0"] * count
timeout = max(3, count * 2 * self._key_delay_seconds(delay) * 3)
+2 -1
View File
@@ -15,6 +15,7 @@ import wave
from pathlib import Path # noqa: F401
from typing import Optional
from ..app_identity import APP_DIR_NAME
from .config_manager import DEFAULT_SOUND_EFFECT_TONE, normalize_sound_effect_tone
logger = logging.getLogger(__name__)
@@ -113,7 +114,7 @@ def _pcm_silence(nframes: int, nchannels: int, sampwidth: int) -> bytes:
def _preroll_cache_dir() -> str:
cache_home = os.environ.get("XDG_CACHE_HOME") or os.path.join(os.path.expanduser("~"), ".cache")
path = os.path.join(cache_home, "vocalinux", "audio-preroll")
path = os.path.join(cache_home, APP_DIR_NAME, "audio-preroll")
os.makedirs(path, exist_ok=True)
return path
+5 -5
View File
@@ -12,9 +12,9 @@ import shutil
import sys
from pathlib import Path
logger = logging.getLogger(__name__)
from ..app_identity import APP_ID, APP_NAME, DESKTOP_FILENAME
DESKTOP_FILENAME = "vocalinux.desktop"
logger = logging.getLogger(__name__)
def get_autostart_dir() -> Path:
@@ -34,7 +34,7 @@ def get_exec_command() -> str:
if flatpak_id:
return f"flatpak run {flatpak_id} --start-minimized"
installed_command = shutil.which("vocalinux")
installed_command = shutil.which(APP_ID)
if installed_command:
return f"{shlex.quote(installed_command)} --start-minimized"
@@ -62,9 +62,9 @@ def enable_autostart() -> bool:
desktop_entry = f"""[Desktop Entry]
Version=1.0
Type=Application
Name=Vocalinux
Name={APP_NAME}
Exec={exec_command}
Icon=vocalinux
Icon={APP_ID}
Comment=Voice dictation for Linux
Terminal=false
StartupNotify=false
+2 -1
View File
@@ -17,6 +17,7 @@ from typing import TYPE_CHECKING, Callable, Optional
if TYPE_CHECKING:
from gi.repository import Gdk, Gtk
from ..app_identity import APP_NAME
from ..common_types import RecognitionState
logger = logging.getLogger(__name__)
@@ -176,7 +177,7 @@ class DictationOverlay:
# keyboard focus so the active editor keeps receiving injected text.
# Size/position are applied after map via _layout_bottom_strip.
window = Gtk.Window(type=Gtk.WindowType.TOPLEVEL)
window.set_title("Vocalinux Dictation")
window.set_title(f"{APP_NAME} Dictation")
window.set_decorated(False)
window.set_resizable(False)
window.set_accept_focus(False)
@@ -9,6 +9,7 @@ import logging
import os
from typing import Optional
from ...app_identity import BUS_NAME, INTERFACE_NAME, OBJECT_PATH
from .base import (
DEFAULT_SHORTCUT,
DEFAULT_SHORTCUT_MODE,
@@ -98,9 +99,9 @@ def _log_flatpak_input_hint() -> None:
logger.warning(
"Grant --device=input (Flatpak 1.15.6+) or --device=all, or enable "
"External Activation and bind a desktop shortcut to: %s",
"gdbus call --session --dest com.vocalinux.Vocalinux "
"--object-path /com/vocalinux/Vocalinux "
"--method com.vocalinux.Vocalinux.Toggle",
f"gdbus call --session --dest {BUS_NAME} "
f"--object-path {OBJECT_PATH} "
f"--method {INTERFACE_NAME}.Toggle",
)
+2 -1
View File
@@ -22,6 +22,7 @@ gi.require_version("Gtk", "3.0")
gi.require_version("Gdk", "3.0")
from gi.repository import Gdk, GLib, Gtk, Pango # noqa: E402
from ..app_identity import LOG_FILE_PREFIX # noqa: E402
from ..utils.paths import collapse_repeated_extension # noqa: E402
from .logging_manager import LogRecord, get_logging_manager # noqa: E402
@@ -650,7 +651,7 @@ class LoggingDialog(Gtk.Dialog):
# Set default filename
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
default_filename = f"vocalinux_logs_{timestamp}.txt"
default_filename = f"{LOG_FILE_PREFIX}{timestamp}.txt"
file_dialog.set_current_name(default_filename)
# Add file filter
+1 -2
View File
@@ -10,12 +10,11 @@ import logging
import subprocess
from typing import Callable, Optional
from ..app_identity import APP_NAME
from ..utils.host_process import host_env
logger = logging.getLogger(__name__)
APP_NAME = "Vocalinux"
# Notifications waiting for their action to be clicked. Without a reference the
# notification can be garbage collected before the user answers it, and the
# callback then never runs.
+5 -4
View File
@@ -36,6 +36,7 @@ gi.require_version("Gdk", "3.0")
# Need GLib for idle_add
from gi.repository import Gdk, GLib, GObject, Gtk, Pango # noqa: E402
from ..app_identity import APP_ID, APP_NAME, RESOURCE_DIR_NAME # noqa: E402
from ..audio.pulse_sources import ( # noqa: E402
PULSE_INDEX_BASE,
PulseSource,
@@ -717,8 +718,8 @@ def _model_specialization_tooltip(model_name: str) -> str:
# Models directory
MODELS_DIR = models_dir()
SYSTEM_MODELS_DIRS = [
"/usr/local/share/vocalinux/models",
"/usr/share/vocalinux/models",
f"/usr/local/share/{RESOURCE_DIR_NAME}/models",
f"/usr/share/{RESOURCE_DIR_NAME}/models",
]
# CSS for modern styling
@@ -2534,7 +2535,7 @@ class SettingsDialog(AboutSectionMixin):
hotkey_passthrough_update_callback: Optional[Callable[[bool], None]] = None,
dictation_pad_open_callback: Optional[Callable[[], None]] = None,
) -> None:
super().__init__(title="Vocalinux Settings", transient_for=parent, flags=0)
super().__init__(title=f"{APP_NAME} Settings", transient_for=parent, flags=0)
# Force window decorations (title-bar close) on all WMs. An in-window
# Close button also lives in the sidebar footer so the dialog always
# has a visible way to dismiss it, even on window managers that hide
@@ -6460,7 +6461,7 @@ class SettingsDialog(AboutSectionMixin):
try:
response = requests.get(
"https://huggingface.co/api/models/ggerganov/whisper.cpp",
headers={"User-Agent": f"vocalinux/{__version__}"},
headers={"User-Agent": f"{APP_ID}/{__version__}"},
timeout=10,
proxies=requests_proxies(config),
)
+23 -15
View File
@@ -54,6 +54,14 @@ if not INDICATOR_USES_GMENU:
from gi.repository import GdkPixbuf, Gio, GLib, GObject, Gtk
from ..app_identity import APP_ID as CENTRAL_APP_ID
from ..app_identity import (
ICON_ACTIVE,
ICON_DEFAULT,
ICON_PROCESSING,
TRAY_TITLE,
)
# Import local modules - Use protocols to avoid circular imports
from ..auto_pause_monitor import DEFAULT_POLL_INTERVAL_SECONDS, AutoPauseMonitor
from ..common_types import RecognitionState, SpeechRecognitionManagerProtocol, TextInjectorProtocol
@@ -93,7 +101,7 @@ logger = logging.getLogger(__name__)
# Flatpak StatusNotifier host resolves exported <app-id>-* icons, not /app paths.
FLATPAK_ID = os.environ.get("FLATPAK_ID")
APP_ID = FLATPAK_ID or "vocalinux"
APP_ID = FLATPAK_ID or CENTRAL_APP_ID
# Initialize resource manager
_resource_manager = ResourceManager()
@@ -119,9 +127,9 @@ def _idle_once(func, *args):
# Bundled icon file names (paths under ICON_DIR).
DEFAULT_ICON = "vocalinux-microphone-off"
ACTIVE_ICON = "vocalinux-microphone"
PROCESSING_ICON = "vocalinux-microphone-process"
DEFAULT_ICON = ICON_DEFAULT
ACTIVE_ICON = ICON_ACTIVE
PROCESSING_ICON = ICON_PROCESSING
def _themed_icon_names() -> dict:
@@ -159,7 +167,7 @@ class TrayIndicator:
# Tests build TrayIndicator stubs with __new__ that skip __init__; keep a
# class-level default so teardown paths like _quit() still find the list.
_language_shortcut_managers: list = []
_indicator_title: str = "Vocalinux"
_indicator_title: str = TRAY_TITLE
#: Same for the push-to-talk owner slot: a __new__-built stub must answer
#: "no binding owns the live session" instead of raising on release paths.
@@ -264,7 +272,7 @@ class TrayIndicator:
# IconThemePath so gray→red→gray→red actually turns red again.
self._last_icon_key: Optional[str] = None
self._icon_theme_nudge = False
self._indicator_title = "Vocalinux"
self._indicator_title = TRAY_TITLE
# Register for speech recognition state changes
self.speech_engine.register_state_callback(self._on_recognition_state_changed)
@@ -601,8 +609,8 @@ class TrayIndicator:
indicator.set_icon_theme_path(ICON_DIR)
self.indicator = GMenuIndicator(indicator, Gio) if INDICATOR_USES_GMENU else indicator
self.indicator.set_status(AppIndicator.IndicatorStatus.ACTIVE)
self.indicator.set_title("Vocalinux")
self._indicator_title = "Vocalinux"
self.indicator.set_title(TRAY_TITLE)
self._indicator_title = TRAY_TITLE
except Exception as e:
logger.error(f"Failed to create AppIndicator: {e}")
GLib.idle_add(self._show_appindicator_error_dialog, str(e))
@@ -1263,7 +1271,7 @@ class TrayIndicator:
self._last_icon_key = icon_key
def _set_indicator_title(self, title: str) -> None:
if getattr(self, "_indicator_title", "Vocalinux") == title:
if getattr(self, "_indicator_title", TRAY_TITLE) == title:
return
self.indicator.set_title(title)
self._indicator_title = title
@@ -1297,12 +1305,12 @@ class TrayIndicator:
self._set_cancel_menu_item_visible(show_cancel)
if state == RecognitionState.IDLE:
self._set_indicator_title("Vocalinux")
self._set_indicator_title(TRAY_TITLE)
self._set_indicator_icon(self._icon_keys["default"], "Microphone off")
self._set_menu_item_enabled("Start Voice Typing", True)
self._set_menu_item_enabled("Stop Voice Typing", False)
elif state == RecognitionState.LISTENING:
self._set_indicator_title("Vocalinux")
self._set_indicator_title(TRAY_TITLE)
self._set_indicator_icon(self._icon_keys["active"], "Microphone on")
self._set_menu_item_enabled("Start Voice Typing", False)
self._set_menu_item_enabled("Stop Voice Typing", True)
@@ -1315,12 +1323,12 @@ class TrayIndicator:
description = f"Transcribing, press {name} again to cancel"
else:
description = "Processing speech"
self._set_indicator_title(description if transcribing else "Vocalinux")
self._set_indicator_title(description if transcribing else TRAY_TITLE)
self._set_indicator_icon(self._icon_keys["processing"], description)
self._set_menu_item_enabled("Start Voice Typing", False)
self._set_menu_item_enabled("Stop Voice Typing", True)
elif state == RecognitionState.ERROR:
self._set_indicator_title("Vocalinux")
self._set_indicator_title(TRAY_TITLE)
self._set_indicator_icon(self._icon_keys["default"], "Error")
self._set_menu_item_enabled("Start Voice Typing", True)
self._set_menu_item_enabled("Stop Voice Typing", False)
@@ -1578,8 +1586,8 @@ class TrayIndicator:
"-i",
"software-update-available",
"-a",
"Vocalinux",
"Vocalinux update available",
TRAY_TITLE,
f"{TRAY_TITLE} update available",
f"{release.tag_name} is ready. Open the tray menu or "
"Settings → About for release notes.",
],
+3 -1
View File
@@ -34,6 +34,8 @@ import re
from functools import lru_cache
from typing import Dict, NamedTuple, Optional
from ..app_identity import VERIFICATION_STAMP_NAME
logger = logging.getLogger(__name__)
_MANIFEST_NAME = "model_checksums.txt"
@@ -42,7 +44,7 @@ _MANIFEST_NAME = "model_checksums.txt"
# came out of. A directory has no hash of its own and the zip is deleted after
# unpacking, so this file is the only evidence the tree was ever verified.
# install.sh reads it under the same name; keep the two in step.
VERIFICATION_STAMP_NAME = ".vocalinux_verified"
# (Name itself lives in vocalinux/app_identity.py so the rename flips it once.)
# Hash of a 2GB model read in one go would sit in RAM; stream it instead.
_CHUNK_SIZE = 1024 * 1024
+1 -1
View File
@@ -2,7 +2,7 @@
import os
APP_DIR_NAME = "vocalinux"
from ..app_identity import APP_DIR_NAME
def xdg_config_home() -> str:
+13 -21
View File
@@ -11,6 +11,8 @@ import sys
from pathlib import Path
from typing import Optional, cast
from ..app_identity import ICON_BASE_NAMES, RESOURCE_DIR_NAME
logger = logging.getLogger(__name__)
@@ -55,7 +57,7 @@ class ResourceManager:
# For direct repository execution (go up from src/vocalinux/utils to root)
module_dir.parent.parent.parent / "resources",
# For installed package or virtual environment (install.sh copies here)
Path(sys.prefix) / "share" / "vocalinux" / "resources",
Path(sys.prefix) / "share" / RESOURCE_DIR_NAME / "resources",
# For development in virtual environment
Path(sys.prefix).parent / "resources",
]
@@ -64,19 +66,19 @@ class ResourceManager:
xdg_data = os.environ.get("XDG_DATA_DIRS", "/usr/local/share:/usr/share")
for base in xdg_data.split(":"):
if base: # Skip empty strings
candidates.append(Path(base) / "vocalinux" / "resources")
candidates.append(Path(base) / RESOURCE_DIR_NAME / "resources")
# Add distribution-specific standard paths
additional_paths = [
Path("/usr/local/share/vocalinux/resources"),
Path("/usr/share/vocalinux/resources"),
Path(f"/usr/local/share/{RESOURCE_DIR_NAME}/resources"),
Path(f"/usr/share/{RESOURCE_DIR_NAME}/resources"),
# Some distros use lib64 or lib for package data
Path("/usr/local/lib/vocalinux/resources"),
Path("/usr/lib/vocalinux/resources"),
Path("/usr/local/lib64/vocalinux/resources"),
Path("/usr/lib64/vocalinux/resources"),
Path(f"/usr/local/lib/{RESOURCE_DIR_NAME}/resources"),
Path(f"/usr/lib/{RESOURCE_DIR_NAME}/resources"),
Path(f"/usr/local/lib64/{RESOURCE_DIR_NAME}/resources"),
Path(f"/usr/lib64/{RESOURCE_DIR_NAME}/resources"),
# Flatpak specific path
Path("/app/share/vocalinux/resources"),
Path(f"/app/share/{RESOURCE_DIR_NAME}/resources"),
]
for path in additional_paths:
@@ -103,12 +105,7 @@ class ResourceManager:
if not candidate.exists():
return -1
expected_icons = [
"vocalinux",
"vocalinux-microphone",
"vocalinux-microphone-off",
"vocalinux-microphone-process",
]
expected_icons = list(ICON_BASE_NAMES)
expected_sounds = ["start_recording", "stop_recording", "error"]
icon_score = sum(
@@ -201,12 +198,7 @@ class ResourceManager:
}
# Expected icons
expected_icons = [
"vocalinux",
"vocalinux-microphone",
"vocalinux-microphone-off",
"vocalinux-microphone-process",
]
expected_icons = list(ICON_BASE_NAMES)
for icon in expected_icons:
icon_path = self.get_icon_path(icon)
+3 -2
View File
@@ -7,6 +7,7 @@ from typing import Optional
from urllib.parse import urlparse
from .. import version as _version_module
from ..app_identity import UPDATE_CHECKER_USER_AGENT
from ..version import __url__
from .proxy import requests_proxies
@@ -222,7 +223,7 @@ def _fetch_stable_from_releases_page(
url = f"https://github.com/{owner}/{repo}/releases/latest"
headers = {
"Accept": "application/json",
"User-Agent": "Vocalinux-UpdateChecker",
"User-Agent": UPDATE_CHECKER_USER_AGENT,
}
try:
response = requests.get(url, headers=headers, timeout=timeout, proxies=requests_proxies())
@@ -263,7 +264,7 @@ def fetch_latest_release(
api_base = _repo_api_base(repo_url)
headers = {
"Accept": "application/vnd.github+json",
"User-Agent": "Vocalinux-UpdateChecker",
"User-Agent": UPDATE_CHECKER_USER_AGENT,
}
try:
+136
View File
@@ -0,0 +1,136 @@
"""Ticket #17 prefactor inventory: central identity + rename surfaces.
Seams under test (per spec Testing Decisions):
- Seam 1 (Python behaviour suite): central constants hold current values and
every previously duplicated consumer resolves through the shared definition.
- Seam 2 (offline policy test): rename/retained inventories enumerate files
the rename ticket must flip (or deliberately keep) without network access.
No user-visible behaviour, name, path, or string changes: all asserted values
are the pre-rename Vocalinux/vocalinux literals.
"""
import ast
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
SRC_ROOT = REPO_ROOT / "src" / "vocalinux"
def _load_identity():
from vocalinux import app_identity
return app_identity
def test_central_values_are_current_names():
"""Shared definition still carries the pre-rename literals."""
identity = _load_identity()
assert identity.APP_NAME == "Vocalinux"
assert identity.APP_ID == "vocalinux"
assert identity.APP_DIR_NAME == "vocalinux"
assert identity.CLI_PROG == "vocalinux"
assert identity.BUS_NAME == "com.vocalinux.Vocalinux"
assert identity.OBJECT_PATH == "/com/vocalinux/Vocalinux"
assert identity.INTERFACE_NAME == "com.vocalinux.Vocalinux"
assert identity.DESKTOP_FILENAME == "vocalinux.desktop"
assert identity.DESKTOP_ICON_NAME == "vocalinux"
assert identity.ENGINE_NAME == "vocalinux"
assert identity.ENGINE_LONGNAME == "Vocalinux"
assert identity.COMPONENT_NAME == "org.freedesktop.IBus.Vocalinux"
assert identity.IBUS_DIR_NAME == "vocalinux-ibus"
assert identity.UPDATE_CHECKER_USER_AGENT == "Vocalinux-UpdateChecker"
assert identity.RESOURCE_DIR_NAME == "vocalinux"
assert identity.VERIFICATION_STAMP_NAME == ".vocalinux_verified"
assert identity.LOG_FILE_PREFIX == "vocalinux_logs_"
assert identity.PORTAL_TOKEN_PREFIX == "vocalinux_"
assert identity.ENV_FORCE_BACKEND == "VOCALINUX_FORCE_BACKEND"
assert identity.ENV_INJECT_MODIFIER_WAIT == "VOCALINUX_INJECT_MODIFIER_WAIT"
assert identity.ENV_YDOTOOL_KEY_DELAY == "VOCALINUX_YDOTOOL_KEY_DELAY"
def _imports_app_identity(path: Path) -> bool:
tree = ast.parse(path.read_text(encoding="utf-8"))
for node in ast.walk(tree):
if isinstance(node, ast.ImportFrom):
if node.module and node.module.endswith("app_identity"):
return True
return False
def test_consumers_resolve_through_shared_definition():
"""Previously duplicated surfaces import the shared definition (AST, offline)."""
consumers = [
SRC_ROOT / "utils" / "paths.py",
SRC_ROOT / "ui" / "notifications.py",
SRC_ROOT / "ui" / "tray_indicator.py",
SRC_ROOT / "dbus_service.py",
SRC_ROOT / "ui" / "autostart_manager.py",
SRC_ROOT / "text_injection" / "ibus_engine.py",
SRC_ROOT / "utils" / "update_checker.py",
SRC_ROOT / "utils" / "resource_manager.py",
SRC_ROOT / "main.py",
SRC_ROOT / "text_injection" / "text_injector.py",
SRC_ROOT / "text_injection" / "remote_desktop_portal.py",
SRC_ROOT / "ui" / "logging_dialog.py",
SRC_ROOT / "utils" / "model_checksums.py",
SRC_ROOT / "speech_recognition" / "recognition_manager.py",
SRC_ROOT / "ui" / "settings_dialog.py",
SRC_ROOT / "ui" / "dictation_overlay.py",
SRC_ROOT / "ui" / "audio_feedback.py",
SRC_ROOT / "ui" / "keyboard_backends" / "__init__.py",
]
missing = [str(p) for p in consumers if not p.exists()]
assert not missing, f"consumer files missing: {missing}"
not_centralised = [str(p) for p in consumers if not _imports_app_identity(p)]
assert not_centralised == [], f"consumers not resolving through app_identity: {not_centralised}"
def test_lightweight_consumers_match_shared_values_at_runtime():
"""Runtime check where importing the consumer needs no display server."""
identity = _load_identity()
from vocalinux.ui import autostart_manager, notifications
from vocalinux.utils import paths
assert paths.APP_DIR_NAME == identity.APP_DIR_NAME
assert autostart_manager.DESKTOP_FILENAME == identity.DESKTOP_FILENAME
assert notifications.APP_NAME == identity.APP_NAME
def test_source_pinned_literals_reserved_for_rename():
"""Literals existing source-shape tests pin stay literal until the rename.
``tests/test_settings_dialog.py`` (About page) and
``tests/test_dictation_pad.py`` (shelving) read module source or exec
function bodies in isolation, so ``settings_about.py`` and
``dictation_pad.py`` keep their current literals. They are recorded here
as checked rename surfaces for ticket #18 rather than centralised twice.
"""
about = (SRC_ROOT / "ui" / "settings_about.py").read_text(encoding="utf-8")
for literal in (
'VOCALINUX_SITE_URL = "https://vocalinux.com"',
'title="Vocalinux"',
'get_icon_path("vocalinux")',
):
assert literal in about, literal
pad = (SRC_ROOT / "ui" / "dictation_pad.py").read_text(encoding="utf-8")
assert 'window.set_title("Vocalinux Dictation Pad")' in pad
def test_rename_inventory_files_exist():
"""Every rename surface the rename ticket must flip is a real checked file."""
identity = _load_identity()
assert identity.RENAME_SURFACES, "rename inventory must not be empty"
missing = [rel for rel in identity.RENAME_SURFACES if not (REPO_ROOT / rel).exists()]
assert missing == [], f"rename inventory entries missing on disk: {missing}"
def test_retained_inventory_records_internal_names():
"""Internal module name and upstream URLs stay, by record not by accident."""
identity = _load_identity()
assert any("src/vocalinux" in entry for entry in identity.RETAINED_SURFACES)
assert any("github.com/VocaHQ/vocalinux" in entry for entry in identity.RETAINED_SURFACES)
# The retained Python package must still import under its current name.
import vocalinux
assert vocalinux.__name__ == "vocalinux"