diff --git a/src/vocalinux/app_identity.py b/src/vocalinux/app_identity.py new file mode 100644 index 0000000..444e6fe --- /dev/null +++ b/src/vocalinux/app_identity.py @@ -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)", +) diff --git a/src/vocalinux/dbus_service.py b/src/vocalinux/dbus_service.py index e68b706..0632340 100644 --- a/src/vocalinux/dbus_service.py +++ b/src/vocalinux/dbus_service.py @@ -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""" - + diff --git a/src/vocalinux/main.py b/src/vocalinux/main.py index f7b6040..85a4bbf 100644 --- a/src/vocalinux/main.py +++ b/src/vocalinux/main.py @@ -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 diff --git a/src/vocalinux/speech_recognition/recognition_manager.py b/src/vocalinux/speech_recognition/recognition_manager.py index f4eed71..0fac4f8 100644 --- a/src/vocalinux/speech_recognition/recognition_manager.py +++ b/src/vocalinux/speech_recognition/recognition_manager.py @@ -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: diff --git a/src/vocalinux/text_injection/ibus_engine.py b/src/vocalinux/text_injection/ibus_engine.py index 327e29b..25ad59a 100644 --- a/src/vocalinux/text_injection/ibus_engine.py +++ b/src/vocalinux/text_injection/ibus_engine.py @@ -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, } diff --git a/src/vocalinux/text_injection/remote_desktop_portal.py b/src/vocalinux/text_injection/remote_desktop_portal.py index 478ebfe..955def7 100644 --- a/src/vocalinux/text_injection/remote_desktop_portal.py +++ b/src/vocalinux/text_injection/remote_desktop_portal.py @@ -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: diff --git a/src/vocalinux/text_injection/text_injector.py b/src/vocalinux/text_injection/text_injector.py index 1dd817c..23225ae 100644 --- a/src/vocalinux/text_injection/text_injector.py +++ b/src/vocalinux/text_injection/text_injector.py @@ -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) diff --git a/src/vocalinux/ui/audio_feedback.py b/src/vocalinux/ui/audio_feedback.py index 1a8ab73..67a64cd 100644 --- a/src/vocalinux/ui/audio_feedback.py +++ b/src/vocalinux/ui/audio_feedback.py @@ -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 diff --git a/src/vocalinux/ui/autostart_manager.py b/src/vocalinux/ui/autostart_manager.py index 1979891..d660975 100644 --- a/src/vocalinux/ui/autostart_manager.py +++ b/src/vocalinux/ui/autostart_manager.py @@ -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 diff --git a/src/vocalinux/ui/dictation_overlay.py b/src/vocalinux/ui/dictation_overlay.py index 268b621..5c5f974 100644 --- a/src/vocalinux/ui/dictation_overlay.py +++ b/src/vocalinux/ui/dictation_overlay.py @@ -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) diff --git a/src/vocalinux/ui/keyboard_backends/__init__.py b/src/vocalinux/ui/keyboard_backends/__init__.py index 81b4016..1dbb2f6 100644 --- a/src/vocalinux/ui/keyboard_backends/__init__.py +++ b/src/vocalinux/ui/keyboard_backends/__init__.py @@ -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", ) diff --git a/src/vocalinux/ui/logging_dialog.py b/src/vocalinux/ui/logging_dialog.py index c6cf297..7ee1c36 100644 --- a/src/vocalinux/ui/logging_dialog.py +++ b/src/vocalinux/ui/logging_dialog.py @@ -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 diff --git a/src/vocalinux/ui/notifications.py b/src/vocalinux/ui/notifications.py index 4de910f..a40bb87 100644 --- a/src/vocalinux/ui/notifications.py +++ b/src/vocalinux/ui/notifications.py @@ -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. diff --git a/src/vocalinux/ui/settings_dialog.py b/src/vocalinux/ui/settings_dialog.py index 37c6d27..c33eaf5 100644 --- a/src/vocalinux/ui/settings_dialog.py +++ b/src/vocalinux/ui/settings_dialog.py @@ -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), ) diff --git a/src/vocalinux/ui/tray_indicator.py b/src/vocalinux/ui/tray_indicator.py index 81435ca..71017e2 100644 --- a/src/vocalinux/ui/tray_indicator.py +++ b/src/vocalinux/ui/tray_indicator.py @@ -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 -* 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.", ], diff --git a/src/vocalinux/utils/model_checksums.py b/src/vocalinux/utils/model_checksums.py index 196a361..6fa3a7e 100644 --- a/src/vocalinux/utils/model_checksums.py +++ b/src/vocalinux/utils/model_checksums.py @@ -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 diff --git a/src/vocalinux/utils/paths.py b/src/vocalinux/utils/paths.py index 5855f0e..dcf09af 100644 --- a/src/vocalinux/utils/paths.py +++ b/src/vocalinux/utils/paths.py @@ -2,7 +2,7 @@ import os -APP_DIR_NAME = "vocalinux" +from ..app_identity import APP_DIR_NAME def xdg_config_home() -> str: diff --git a/src/vocalinux/utils/resource_manager.py b/src/vocalinux/utils/resource_manager.py index e94cecd..566b3c1 100644 --- a/src/vocalinux/utils/resource_manager.py +++ b/src/vocalinux/utils/resource_manager.py @@ -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) diff --git a/src/vocalinux/utils/update_checker.py b/src/vocalinux/utils/update_checker.py index 4d12804..5cb7816 100644 --- a/src/vocalinux/utils/update_checker.py +++ b/src/vocalinux/utils/update_checker.py @@ -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: diff --git a/tests/test_app_identity.py b/tests/test_app_identity.py new file mode 100644 index 0000000..ff0ded0 --- /dev/null +++ b/tests/test_app_identity.py @@ -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"