diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 48d12c7..dbef203 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -160,3 +160,60 @@ vocalinux --debug ``` See [TROUBLESHOOTING.md](TROUBLESHOOTING.md) for tray, audio, injection, and model issues. Distro notes: [DISTRO_COMPATIBILITY.md](DISTRO_COMPATIBILITY.md). Updates: [UPDATE.md](UPDATE.md). Help channels: [SUPPORT.md](../SUPPORT.md). + +### Text injection backend + +Vocalinux types your dictated text using one of several backends. It picks one +automatically, and on most desktops the automatic choice is correct. + +Autodetection can be wrong, though, and it fails in a way that is easy to +misread: on a compositor that does not relay IBus commits to native Wayland +applications, IBus reports the text as delivered while nothing appears. If +dictation works in some windows (typically XWayland ones, like a browser) but +silently does nothing in others, that is the symptom. + +Pin the backend explicitly in `~/.config/vocalinux/config.json`: + +```json +{ + "text_injection": { + "backend": "wtype" + } +} +``` + +| Value | Backend | +|---|---| +| `auto` | Autodetect (default; autodetection may select IBus) | +| `ibus` | IBus input method; on Wayland, bypasses compositor checks and may silently do nothing in native Wayland apps | +| `wtype` | wtype virtual keyboard (Wayland) | +| `ydotool` | ydotool uinput (Wayland; needs `ydotoold`) | +| `xdotool` | xdotool (X11). On Wayland it only turns IBus off -- the Wayland tool is still picked automatically | + +The setting takes effect on the next start. `auto` leaves normal autodetection +in place and may select IBus. An explicit non-IBus pin (`wtype`, `ydotool`, or +`xdotool`) skips IBus selection. + +On X11 the injection tool is `xdotool` regardless of which non-`ibus` value you +pin, so `xdotool` is the name to use there when IBus is unreliable in a +particular application. Pinning `ibus` keeps the IBus path; pinning anything +else turns it off. + +To try a backend for a single run without changing the saved setting, set +`VOCALINUX_FORCE_BACKEND`, which overrides the config value. Set it to `auto` +to ignore a saved pin for that run: + +```bash +VOCALINUX_FORCE_BACKEND=wtype vocalinux --debug +``` + +The setting and environment override are read at startup, so restart Vocalinux +after editing `config.json`. The startup log first records a backend pin request; +it does not prove the backend was available or that text reached the focused +application. Later logs identify a fallback when a pin was not applied. + +If a pinned tool is unavailable, Vocalinux warns and continues with its normal +fallback selection. `ydotool` also needs a usable `/dev/uinput` and a working +`ydotoold` setup. `xdotool` types into X11/XWayland windows, not native Wayland +windows. A live test in the target application is still the final confirmation +that text is delivered. diff --git a/src/vocalinux/text_injection/text_injector.py b/src/vocalinux/text_injection/text_injector.py index 62683ce..ce64702 100644 --- a/src/vocalinux/text_injection/text_injector.py +++ b/src/vocalinux/text_injection/text_injector.py @@ -14,7 +14,7 @@ import subprocess import threading import time from enum import Enum -from typing import Optional +from typing import Optional, Tuple from ..utils.host_process import host_env from ..utils.paths import config_dir @@ -106,6 +106,7 @@ class TextInjector: wayland_mode: Force Wayland compatibility mode """ self._ibus_injector: Optional[IBusTextInjector] = None + self._backend_pin: Tuple[str, Optional[str]] = ("auto", None) self.environment = self._detect_environment() self._session_environment = self.environment self._ibus_ready = False @@ -177,6 +178,11 @@ class TextInjector: except Exception as e: logger.error(f"XWayland fallback test failed: {e}") + # Last, so it sees the final selection: _check_dependencies() returns early + # on two paths, and the wtype probe above can still demote a pin that was + # honoured up to that point. + self._warn_if_pin_not_honoured(*self._backend_pin) + def stop(self) -> None: """ Clean up resources and restore previous state. @@ -249,6 +255,16 @@ class TextInjector: "weston", ) + # Backends a user may pin explicitly, via VOCALINUX_FORCE_BACKEND or the + # text_injection.backend setting. Named once so the two readers and the two + # "expected ..." messages cannot drift apart as values are added. + _SELECTABLE_BACKENDS = ("ibus", "wtype", "ydotool", "xdotool") + + @staticmethod + def _accepted_backends_help() -> str: + """The accepted pin values, for user-facing "expected ..." messages.""" + return "/".join(TextInjector._SELECTABLE_BACKENDS) + "/auto" + def _kde_virtual_keyboard_enabled(self) -> bool: """Return True when KWin VirtualKeyboard / input method is enabled. @@ -475,6 +491,38 @@ class TextInjector: logger.warning("ydotoold did not become ready in time") return False + @staticmethod + def _forced_backend_setting() -> Optional[str]: + """``VOCALINUX_FORCE_BACKEND`` as three distinct states. + + ``None`` when the variable is not set, ``"auto"`` when it is explicitly + set to auto, otherwise the backend name. The distinction matters: + ``auto`` is how a user asks for autodetection *this run* despite a saved + ``text_injection.backend`` pin, which is the one-run A/B test this + variable exists for. Collapsing it into "nothing was set" would let the + saved pin win and make the variable useless for that. + + Accepts ``ibus``, ``wtype``, ``ydotool``, ``xdotool`` or ``auto``. + """ + raw = os.environ.get("VOCALINUX_FORCE_BACKEND") + if raw is None: + return None + value = raw.strip().lower() + if not value: + return None + if value == "auto": + return "auto" + if value in TextInjector._SELECTABLE_BACKENDS: + return value + logger.warning( + "Ignoring unknown VOCALINUX_FORCE_BACKEND=%r (expected %s)", + value, + TextInjector._accepted_backends_help(), + ) + # Deliberately unset rather than "auto": a typo in a shell variable should + # not discard a valid saved pin, only fail to override it. + return None + @staticmethod def _uinput_usable() -> bool: """Return True when this process can open ``/dev/uinput`` for write.""" @@ -519,19 +567,15 @@ class TextInjector: when the inference is wrong, and makes the two paths A/B-testable without editing code. - Accepts ``ibus``, ``wtype``, ``ydotool`` or ``auto``. Anything else is + Accepts ``ibus``, ``wtype``, ``ydotool``, ``xdotool`` or ``auto``. Anything else is ignored with a warning, so a typo cannot silently pin a backend. + + This covers the environment variable only. ``_backend_preference()`` + combines it with the persistent ``text_injection.backend`` setting, and + needs unset and an explicit ``auto`` told apart -- see + ``_forced_backend_setting()``. """ - value = os.environ.get("VOCALINUX_FORCE_BACKEND", "").strip().lower() - if not value or value == "auto": - return "auto" - if value in ("ibus", "wtype", "ydotool"): - return value - logger.warning( - "Ignoring unknown VOCALINUX_FORCE_BACKEND=%r (expected ibus/wtype/ydotool/auto)", - value, - ) - return "auto" + return TextInjector._forced_backend_setting() or "auto" def _resolve_wayland_key_tool(self) -> Optional[str]: """Pick (or reuse) the Wayland virtual-keyboard tool for key events. @@ -566,12 +610,206 @@ class TextInjector: logger.info(f"Using {tool} for Wayland key events") return tool + @staticmethod + def _configured_backend() -> str: + """Backend pinned via ``text_injection.backend`` in config.json, or ``"auto"``. + + The environment variable is fine for a one-off experiment, but a user + whose compositor is autodetected wrongly needs the choice to survive a + restart without wrapping the launcher in a shell script (issue #476). + + Read from disk rather than through ConfigManager to keep this package + independent of the UI layer, matching ``_should_copy_to_clipboard()``. + + A file that cannot be used is reported at warning level, not debug. This + setting is hand-edited, so a stray comma is a likely way to reach it, and + the symptom of staying quiet is the silent IBus miss the pin was set to + avoid -- indistinguishable from the pin simply not working. + + The two shape checks below are what keep the lookup total, so the + handler only has to cover reading and parsing. Locating the file is + deliberately outside the handler: ``config_dir()`` resolving a path is + not a failure this should paper over, and computing it inside the + ``try`` only meant the handler could not name the file it failed on. + """ + import json + + config_path = os.path.join(config_dir(), "config.json") + if not os.path.exists(config_path): + return "auto" + + try: + with open(config_path, "r") as f: + config = json.load(f) + except (OSError, ValueError) as e: + # Corrupt or unreadable must not block startup, but must not pass + # unmentioned either: the user edited this file expecting an effect. + logger.warning( + "Ignoring text_injection.backend: could not read %s (%s). " + "Continuing with backend autodetection.", + config_path, + e, + ) + return "auto" + + if not isinstance(config, dict): + logger.warning( + "Ignoring text_injection.backend: %s is not a JSON object. " + "Continuing with backend autodetection.", + config_path, + ) + return "auto" + + section = config.get("text_injection") + if section is None: + return "auto" + if not isinstance(section, dict): + logger.warning( + "Ignoring text_injection.backend: the text_injection section of %s " + "is not a JSON object. Continuing with backend autodetection.", + config_path, + ) + return "auto" + + value = str(section.get("backend", "") or "").strip().lower() + if not value or value == "auto": + return "auto" + if value in TextInjector._SELECTABLE_BACKENDS: + return value + logger.warning( + "Ignoring unknown text_injection.backend=%r (expected %s)", + value, + TextInjector._accepted_backends_help(), + ) + return "auto" + + @staticmethod + def _resolve_backend_pin() -> Tuple[str, Optional[str]]: + """The pinned backend and where it came from, resolved once. + + Returns ``(backend, source)``. ``source`` names the setting that won, or + is ``None`` when nothing pinned anything. Both come from a single pass so + callers that report the source cannot disagree with the value actually + used -- and so the environment is parsed once per construction, which + also means a malformed value is reported once rather than per caller. + + ``VOCALINUX_FORCE_BACKEND`` wins so a backend can still be A/B-tested + for one run without editing (or permanently changing) the user's config. + That includes an explicit ``auto``, which asks for autodetection this run + and so must short-circuit here rather than fall through to the saved pin. + """ + env = TextInjector._forced_backend_setting() + if env is not None: + return env, "VOCALINUX_FORCE_BACKEND" + configured = TextInjector._configured_backend() + if configured != "auto": + return configured, "text_injection.backend" + return "auto", None + + @staticmethod + def _backend_preference() -> str: + """The backend to pin, from the environment or config.json.""" + return TextInjector._resolve_backend_pin()[0] + + @staticmethod + def _resolved_backend_from_state( + ibus_injector: Optional[IBusTextInjector], + environment: DesktopEnvironment, + wayland_tool: Optional[str], + ) -> Optional[str]: + """Resolve a backend from one consistent snapshot of injector state.""" + if ibus_injector is not None and environment in ( + DesktopEnvironment.X11_IBUS, + DesktopEnvironment.WAYLAND_IBUS, + ): + return "ibus" + if environment in ( + DesktopEnvironment.X11, + DesktopEnvironment.WAYLAND_XDOTOOL, + ): + return "xdotool" + return wayland_tool + + def _resolved_backend(self) -> Optional[str]: + """The backend actually in effect, read back from final state. + + Deliberately reads ``environment`` before ``wayland_tool``: the wtype + probe in ``__init__`` demotes a failed wtype to ``WAYLAND_XDOTOOL`` + without clearing ``wayland_tool``, and ``inject_text()`` follows + ``environment``. Reading the tool first would report wtype while + injection actually goes through XWayland. + """ + with self._state_lock: + ibus_injector = self._ibus_injector + environment = self.environment + wayland_tool = getattr(self, "wayland_tool", None) + return self._resolved_backend_from_state(ibus_injector, environment, wayland_tool) + + def _warn_if_pin_not_honoured(self, pinned: str, source: Optional[str]) -> None: + """Say so when the backend in use is not the one that was pinned. + + One comparison rather than a check per cause: a missing binary, a value + that does not apply to this session type, IBus being unavailable and the + wtype probe demoting to XWayland all end the same way -- something other + than the pinned backend is doing the typing -- and a new cause is covered + without adding a branch here. + + This is a startup diagnostic, not a live invariant. It runs once, at the + end of construction. ``_try_recover_from_fallback()`` can later switch + the tool when ydotoold appears mid-session, so a pin that becomes + honoured (or stops being honoured) after startup is not reported again. + """ + if not source or pinned == "auto": + return + with self._state_lock: + ibus_injector = self._ibus_injector + environment = self.environment + ibus_ready = self._ibus_ready + ibus_init_failed = self._ibus_init_failed + wayland_tool = getattr(self, "wayland_tool", None) + resolved = self._resolved_backend_from_state(ibus_injector, environment, wayland_tool) + + if ( + pinned == "ibus" + and ibus_injector is not None + and not ibus_ready + and not ibus_init_failed + and resolved != "ibus" + ): + logger.info( + "%s=%s: IBus initialization is pending; backend selection is not final.", + source, + pinned, + ) + return + # Unreachable today (every path either sets a backend or raises before + # construction finishes), but guarded so a future path cannot render + # "using None instead" at a user. + if resolved is None or resolved == pinned: + return + + if pinned in self._SELECTABLE_BACKENDS and pinned != "ibus": + reason = f" ({pinned} is not installed)" if not shutil.which(pinned) else "" + elif pinned == "ibus" and not is_ibus_available(): + reason = " (IBus support is not available)" + else: + reason = "" + logger.warning( + "%s=%s was not applied%s; using %s instead.", source, pinned, reason, resolved + ) + def _check_dependencies(self): """Check for the required tools for text injection.""" ibus_requested = False - forced = self._forced_backend() - if forced != "auto": - logger.info("VOCALINUX_FORCE_BACKEND=%s: overriding backend autodetection", forced) + forced, pin_source = self._resolve_backend_pin() + self._backend_pin = (forced, pin_source) + if pin_source and forced != "auto": + # States the request, not the result. Nothing has been checked for + # availability yet, and an unavailable pin falls through to + # autodetection further down -- so a word like "overriding" here + # would describe an outcome this line cannot know, and would read as + # contradicting the "was not applied" warning when it does not hold. + logger.info("%s=%s: backend pin requested", pin_source, forced) # Prefer IBus on both X11 and Wayland - it sends Unicode directly, # bypassing keyboard layout issues entirely @@ -629,6 +867,20 @@ class TextInjector: os.environ.get("XDG_CURRENT_DESKTOP", "unknown"), ) else: + # force_ibus short-circuits all three guards above, so a pinned + # run arrives here without any of them having been evaluated. + # This warns for the compositor guard only: that is the case + # with a documented silent failure (#478, #485), where IBus + # reports the commit as delivered and the text never arrives. + # The other two guards are out of scope for this warning. + if force_ibus and not self._wayland_compositor_bridges_ibus(): + logger.warning( + "Compositor '%s' does not bridge IBus to native Wayland apps, but an " + "explicit ibus pin overrides that check. Dictation may silently do " + "nothing in native Wayland windows; remove the pin to fall back to " + "wtype/ydotool.", + os.environ.get("XDG_CURRENT_DESKTOP", "unknown"), + ) try: if wayland_scoped_ibus and not ibus_active: logger.info( @@ -660,11 +912,11 @@ class TextInjector: # ydotool for native Wayland typing; wtype needs a Wayland socket. if forced == "wtype" and wtype_available: self.wayland_tool = "wtype" - logger.info("VOCALINUX_FORCE_BACKEND=wtype: using wtype for Wayland injection") + logger.info("%s=wtype: using wtype for Wayland injection", pin_source) elif forced == "ydotool" and ydotool_available: self._ensure_ydotoold() self.wayland_tool = "ydotool" - logger.info("VOCALINUX_FORCE_BACKEND=ydotool: using ydotool for Wayland injection") + logger.info("%s=ydotool: using ydotool for Wayland injection", pin_source) elif ydotool_available and self._ensure_ydotoold(): self.wayland_tool = "ydotool" logger.info("Using ydotool for Wayland text injection") diff --git a/src/vocalinux/ui/config_manager.py b/src/vocalinux/ui/config_manager.py index 8c498ed..5b7d089 100644 --- a/src/vocalinux/ui/config_manager.py +++ b/src/vocalinux/ui/config_manager.py @@ -49,6 +49,8 @@ PASTE_SHORTCUTS: tuple[tuple[str, str], ...] = ( ) PASTE_SHORTCUT_IDS = frozenset(shortcut_id for shortcut_id, _label in PASTE_SHORTCUTS) DEFAULT_PASTE_SHORTCUT = "auto" +DEFAULT_TEXT_INJECTION_BACKEND = "auto" +_NO_PRESERVED_TEXT_INJECTION_SECTION = object() def normalize_paste_shortcut(shortcut: Any) -> str: @@ -65,6 +67,24 @@ def normalize_sound_effect_tone(tone: Any) -> str: return DEFAULT_SOUND_EFFECT_TONE +def _backend_from_config(config: Any) -> tuple[bool, Any]: + """Return whether ``config`` has a usable text-injection section and its backend. + + A missing section/key means the documented default, ``"auto"``. A + non-object section is not loaded into the in-memory defaults; an unrelated + save preserves that value verbatim instead of discarding a hand edit. + """ + if not isinstance(config, dict): + return False, DEFAULT_TEXT_INJECTION_BACKEND + + if "text_injection" not in config: + return True, DEFAULT_TEXT_INJECTION_BACKEND + text_injection = config["text_injection"] + if not isinstance(text_injection, dict): + return False, DEFAULT_TEXT_INJECTION_BACKEND + return True, text_injection.get("backend", DEFAULT_TEXT_INJECTION_BACKEND) + + # Default configuration DEFAULT_CONFIG = { "speech_recognition": { # Changed section name @@ -144,6 +164,10 @@ DEFAULT_CONFIG = { # Clipboard-paste chord: auto-detect terminals, or force Ctrl+V / # Ctrl+Shift+V when a nested terminal panel is not detected. "paste_shortcut": "auto", + # Text-injection backend: "auto" autodetects, or pin "ibus"/"wtype"/ + # "ydotool"/"xdotool" when autodetection is wrong (#476). + # VOCALINUX_FORCE_BACKEND overrides this for a single run. + "backend": "auto", }, "advanced": { "power_user_mode": False, @@ -238,6 +262,10 @@ class ConfigManager: def __init__(self): """Initialize the configuration manager.""" self.config = copy.deepcopy(DEFAULT_CONFIG) + # The effective backend at load/save time. It lets save_config tell an + # intentional in-process change from a hand edit made on disk later. + self._backend_snapshot: Any = DEFAULT_TEXT_INJECTION_BACKEND + self._text_injection_snapshot = copy.deepcopy(DEFAULT_CONFIG["text_injection"]) self._ensure_config_dir() self.load_config() @@ -286,11 +314,24 @@ class ConfigManager: with open(CONFIG_FILE, "r") as f: user_config = json.load(f) + valid_backend_shape, backend = _backend_from_config(user_config) + if not isinstance(user_config, dict): + logger.error("Failed to load config: expected a JSON object") + return + if not valid_backend_shape: + # Keep unrelated valid settings, but do not replace the default + # text-injection mapping with a hand-edited scalar or list. + logger.error("Failed to load text_injection: expected a JSON object") + user_config = copy.deepcopy(user_config) + user_config.pop("text_injection", None) + self._backend_snapshot = backend + # Check if migration is needed BEFORE merging with defaults needs_migration = self._check_needs_migration(user_config) # Update the default config with user settings self._update_dict_recursive(self.config, user_config) + self._text_injection_snapshot = copy.deepcopy(self.config["text_injection"]) logger.info(f"Loaded configuration from {CONFIG_FILE}") # Migrate old config format if needed @@ -299,9 +340,33 @@ class ConfigManager: self._migrate_shortcuts_config(user_config) - except (json.JSONDecodeError, OSError) as e: + except (OSError, ValueError) as e: logger.error(f"Failed to load config: {e}") + def _backend_on_disk(self) -> tuple[bool, Any, Any]: + """Read only backend state needed to protect a hand edit during save.""" + if not os.path.exists(CONFIG_FILE): + return True, DEFAULT_TEXT_INJECTION_BACKEND, _NO_PRESERVED_TEXT_INJECTION_SECTION + + try: + with open(CONFIG_FILE, "r") as f: + disk_config = json.load(f) + except (OSError, ValueError) as e: + logger.error(f"Failed to save config: could not read existing config: {e}") + return False, DEFAULT_TEXT_INJECTION_BACKEND, _NO_PRESERVED_TEXT_INJECTION_SECTION + + if not isinstance(disk_config, dict): + logger.error("Failed to save config: existing config is not a JSON object") + return False, DEFAULT_TEXT_INJECTION_BACKEND, _NO_PRESERVED_TEXT_INJECTION_SECTION + + valid_backend_shape, backend = _backend_from_config(disk_config) + if not valid_backend_shape: + # A valid top-level config can still save unrelated settings. Keep + # the malformed section verbatim unless this process intentionally + # changed its backend, in which case it is explicitly repairing it. + return True, DEFAULT_TEXT_INJECTION_BACKEND, disk_config["text_injection"] + return True, backend, _NO_PRESERVED_TEXT_INJECTION_SECTION + def _check_needs_migration(self, user_config: dict) -> bool: """Check if the user config needs migration to add per-engine model sizes.""" sr_config = user_config.get("speech_recognition", {}) @@ -384,12 +449,43 @@ class ConfigManager: self.save_config() def save_config(self): - """Save the current configuration to the config file.""" + """Save the current configuration, preserving an unchanged hand-edited backend.""" try: # Ensure directory exists before writing self._ensure_config_dir() + config_to_save = copy.deepcopy(self.config) + text_injection = self.config.get("text_injection") + backend_written = False + if isinstance(text_injection, dict): + disk_ok, disk_backend, preserved_section = self._backend_on_disk() + if not disk_ok: + return False + in_memory_backend = text_injection.get("backend", DEFAULT_TEXT_INJECTION_BACKEND) + backend_snapshot = getattr( + self, "_backend_snapshot", DEFAULT_TEXT_INJECTION_BACKEND + ) + backend_changed = in_memory_backend != backend_snapshot + text_injection_changed = text_injection != self._text_injection_snapshot + + if ( + preserved_section is not _NO_PRESERVED_TEXT_INJECTION_SECTION + and not text_injection_changed + ): + config_to_save["text_injection"] = copy.deepcopy(preserved_section) + else: + backend_to_save = in_memory_backend if backend_changed else disk_backend + # Do not mutate live state before json.dump succeeds. A failed + # write must not make an external edit look like our saved state. + config_to_save["text_injection"]["backend"] = backend_to_save + backend_written = True with open(CONFIG_FILE, "w") as f: - json.dump(self.config, f, indent=4) + json.dump(config_to_save, f, indent=4) + + if isinstance(text_injection, dict) and backend_written: + text_injection["backend"] = backend_to_save + self._backend_snapshot = backend_to_save + if isinstance(text_injection, dict): + self._text_injection_snapshot = copy.deepcopy(text_injection) logger.info(f"Saved configuration to {CONFIG_FILE}") return True diff --git a/tests/test_config_manager.py b/tests/test_config_manager.py index 9eccefd..59b57f2 100644 --- a/tests/test_config_manager.py +++ b/tests/test_config_manager.py @@ -7,6 +7,7 @@ import os import shutil import tempfile import unittest +from typing import Any from unittest.mock import patch import pytest @@ -143,6 +144,16 @@ class TestConfigManager(unittest.TestCase): # Verify logger.error was called for the broken JSON self.mock_logger.error.assert_called() + def test_load_config_invalid_utf8_uses_defaults(self): + """A config saved with invalid bytes must not prevent application startup.""" + with open(self.temp_config_file, "wb") as handle: + handle.write(b"\xff\xfe{") + + config_manager = ConfigManager() + + self.assertEqual(config_manager.config, DEFAULT_CONFIG) + self.mock_logger.error.assert_called() + def test_save_config(self): """Test saving configuration to file.""" config_manager = ConfigManager() @@ -173,6 +184,179 @@ class TestConfigManager(unittest.TestCase): self.assertFalse(result) self.mock_logger.error.assert_called() + def _write_raw_config(self, content: str) -> None: + with open(self.temp_config_file, "w") as handle: + handle.write(content) + + def _write_config(self, config: Any) -> None: + with open(self.temp_config_file, "w") as handle: + json.dump(config, handle) + + def _read_config(self) -> dict[str, Any]: + with open(self.temp_config_file) as handle: + return json.load(handle) + + def test_unrelated_save_preserves_first_external_backend_edit(self): + """A documented hand-edit must survive a Settings-style save.""" + config_manager = ConfigManager() + self._write_config({"text_injection": {"backend": "wtype"}}) + + config_manager.set("general", "autostart", True) + + self.assertTrue(config_manager.save_config()) + saved = self._read_config() + self.assertEqual(saved["text_injection"]["backend"], "wtype") + self.assertTrue(saved["general"]["autostart"]) + + def test_second_external_backend_edit_survives_repeated_saves(self): + """Remembering the first edit must not make the second one stale again.""" + self._write_config({"text_injection": {"backend": "wtype"}}) + config_manager = ConfigManager() + + self._write_config({"text_injection": {"backend": "ydotool"}}) + config_manager.set("general", "autostart", True) + self.assertTrue(config_manager.save_config()) + self.assertEqual(self._read_config()["text_injection"]["backend"], "ydotool") + + self._write_config({"text_injection": {"backend": "xdotool"}}) + config_manager.set("ui", "start_minimized", True) + self.assertTrue(config_manager.save_config()) + saved = self._read_config() + self.assertEqual(saved["text_injection"]["backend"], "xdotool") + self.assertTrue(saved["ui"]["start_minimized"]) + + def test_programmatic_backend_change_wins_over_external_edit(self): + """An intentional in-process backend change is not discarded as stale.""" + self._write_config({"text_injection": {"backend": "wtype"}}) + config_manager = ConfigManager() + + self._write_config({"text_injection": {"backend": "ydotool"}}) + config_manager.set("text_injection", "backend", "xdotool") + + self.assertTrue(config_manager.save_config()) + self.assertEqual(self._read_config()["text_injection"]["backend"], "xdotool") + + def test_external_auto_or_key_removal_resets_backend(self): + """Both documented reset forms mean auto after an unrelated save.""" + self._write_config({"text_injection": {"backend": "wtype"}}) + config_manager = ConfigManager() + + for external in ( + {"text_injection": {"backend": "auto"}}, + {"text_injection": {}}, + None, + ): + with self.subTest(external=external): + if external is None: + os.remove(self.temp_config_file) + else: + self._write_config(external) + config_manager.set("general", "autostart", True) + self.assertTrue(config_manager.save_config()) + self.assertEqual(self._read_config()["text_injection"]["backend"], "auto") + + def test_save_does_not_overwrite_unreadable_or_invalid_external_config(self): + """A Settings toggle must not repair a hand-edited file by deleting it.""" + config_manager = ConfigManager() + + for raw in ("{broken json", "[]"): + with self.subTest(raw=raw): + self._write_raw_config(raw) + config_manager.set("general", "autostart", True) + + self.assertFalse(config_manager.save_config()) + with open(self.temp_config_file) as handle: + self.assertEqual(handle.read(), raw) + + def test_unrelated_save_preserves_malformed_text_injection_section(self): + """A bad backend section must not prevent unrelated Settings changes.""" + for malformed_section in ([], "wtype", None): + with self.subTest(section=malformed_section): + self._write_config({"text_injection": malformed_section}) + config_manager = ConfigManager() + + self.assertEqual(config_manager.get("text_injection", "backend"), "auto") + self.assertTrue(config_manager.get_bool("text_injection", "auto_capitalize")) + config_manager.set("general", "autostart", True) + + self.assertTrue(config_manager.save_config()) + saved = self._read_config() + self.assertEqual(saved["text_injection"], malformed_section) + self.assertTrue(saved["general"]["autostart"]) + + def test_programmatic_backend_change_repairs_malformed_text_injection_section(self): + """An intentional backend change can replace a section that cannot hold one.""" + self._write_config({"text_injection": []}) + config_manager = ConfigManager() + config_manager.set("text_injection", "backend", "wtype") + + self.assertTrue(config_manager.save_config()) + self.assertEqual(self._read_config()["text_injection"]["backend"], "wtype") + + def test_text_injection_change_repairs_malformed_section(self): + """A Settings change in that section must not disappear after a successful save.""" + for key, value in ( + ("auto_capitalize", False), + ("append_trailing_space", False), + ("paste_shortcut", "ctrl+shift+v"), + ): + with self.subTest(key=key): + self._write_config({"text_injection": []}) + config_manager = ConfigManager() + config_manager.set("text_injection", key, value) + + self.assertTrue(config_manager.save_config()) + self.assertEqual(self._read_config()["text_injection"][key], value) + + def test_failed_write_does_not_advance_text_injection_snapshot(self): + """A later save must still persist a text-setting change after a failed write.""" + config_manager = ConfigManager() + config_manager.set("text_injection", "auto_capitalize", False) + + with patch("vocalinux.ui.config_manager.json.dump", side_effect=OSError("disk full")): + self.assertFalse(config_manager.save_config()) + + self._write_config({"text_injection": []}) + self.assertTrue(config_manager.save_config()) + self.assertFalse(self._read_config()["text_injection"]["auto_capitalize"]) + + def test_save_does_not_overwrite_invalid_utf8_external_config(self): + """A decoding failure is just as unsafe to replace as malformed JSON.""" + config_manager = ConfigManager() + raw = b"\xff\xfe{" + with open(self.temp_config_file, "wb") as handle: + handle.write(raw) + + config_manager.set("general", "autostart", True) + + self.assertFalse(config_manager.save_config()) + with open(self.temp_config_file, "rb") as handle: + self.assertEqual(handle.read(), raw) + + def test_failed_write_does_not_advance_backend_snapshot(self): + """A failed save cannot make a later external edit look stale.""" + config_manager = ConfigManager() + self._write_config({"text_injection": {"backend": "wtype"}}) + + with patch("vocalinux.ui.config_manager.json.dump", side_effect=OSError("disk full")): + self.assertFalse(config_manager.save_config()) + self.assertEqual(config_manager.get("text_injection", "backend"), "auto") + + self._write_config({"text_injection": {"backend": "ydotool"}}) + config_manager.set("general", "autostart", True) + self.assertTrue(config_manager.save_config()) + self.assertEqual(self._read_config()["text_injection"]["backend"], "ydotool") + + def test_invalid_text_injection_shapes_do_not_break_loading(self): + """A hand-edited non-object section leaves text-injection defaults usable.""" + for config in ([], {"text_injection": []}, {"text_injection": "wtype"}): + with self.subTest(config=config): + self._write_config(config) + config_manager = ConfigManager() + + self.assertEqual(config_manager.get("text_injection", "backend"), "auto") + self.assertTrue(config_manager.get_bool("text_injection", "auto_capitalize")) + def test_get_existing_value(self): """Test getting an existing configuration value from defaults.""" # Test that DEFAULT_CONFIG constant has the expected default engine diff --git a/tests/test_text_injector.py b/tests/test_text_injector.py index 298e6eb..882c398 100644 --- a/tests/test_text_injector.py +++ b/tests/test_text_injector.py @@ -2,13 +2,17 @@ Tests for text injection functionality. """ +import contextlib +import json import os import subprocess import sys import threading import unittest +from collections.abc import Iterator +from typing import Any from unittest import mock -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock, mock_open, patch # Update import path to use the new package structure from vocalinux.text_injection.text_injector import ( @@ -2856,6 +2860,223 @@ class TestCompositorIBusBridging(unittest.TestCase): self.assertEqual(injector.wayland_tool, "ydotool") +@contextlib.contextmanager +def _fake_config(config: Any) -> Iterator[None]: + """Pretend config.json holds ``config``; ``None`` means no file at all. + + Kept off the filesystem on purpose: another suite patches + ``tempfile.mkdtemp`` globally, so a real temp dir makes these tests + order-dependent. + """ + with patch("vocalinux.text_injection.text_injector.config_dir", return_value="/fake/config"): + if config is None: + with patch("os.path.exists", return_value=False): + yield + return + payload = config if isinstance(config, str) else json.dumps(config) + with patch("os.path.exists", return_value=True): + with patch("builtins.open", mock_open(read_data=payload)): + yield + + +class TestConfiguredBackend(unittest.TestCase): + """text_injection.backend in config.json pins the injection backend (#476).""" + + def test_recognised_backends(self): + for value in ("ibus", "wtype", "ydotool", "xdotool"): + with _fake_config({"text_injection": {"backend": value}}): + self.assertEqual(TextInjector._configured_backend(), value) + + def test_value_is_normalised(self): + with _fake_config({"text_injection": {"backend": " WType "}}): + self.assertEqual(TextInjector._configured_backend(), "wtype") + + def test_auto_or_missing_means_auto(self): + for config in ( + {"text_injection": {"backend": "auto"}}, + {"text_injection": {"backend": ""}}, + {"text_injection": {}}, + {}, + None, # no config.json at all + ): + with _fake_config(config): + self.assertEqual(TextInjector._configured_backend(), "auto") + + def test_unknown_value_falls_back_to_auto(self): + """A typo must not silently pin the wrong backend.""" + with _fake_config({"text_injection": {"backend": "wtpye"}}): + self.assertEqual(TextInjector._configured_backend(), "auto") + + def test_corrupt_config_does_not_raise(self): + """An unreadable config must not stop text injection from starting. + + Only that it survives; that it also says so is + test_corrupt_config_warns_that_the_pin_is_ignored. + """ + with _fake_config("{not valid json"): + self.assertEqual(TextInjector._configured_backend(), "auto") + + def _warnings(self, logs: Any) -> list[str]: + return [line for line in logs.output if line.startswith("WARNING")] + + def test_corrupt_config_warns_that_the_pin_is_ignored(self): + """A stray comma is the likely way to break a file people hand-edit. + + Staying quiet leaves the user with autodetection and the silent IBus + miss they set the pin to avoid, with nothing to tell the two apart. + """ + with _fake_config("{not valid json"): + with self.assertLogs("vocalinux.text_injection.text_injector", level="DEBUG") as logs: + self.assertEqual(TextInjector._configured_backend(), "auto") + warned = self._warnings(logs) + self.assertTrue(warned, f"corrupt config was not reported: {logs.output}") + self.assertIn("text_injection.backend", warned[0]) + + def test_config_that_is_not_an_object_warns(self): + """Valid JSON that is not an object reaches the lookup as a non-mapping. + + Guarded rather than caught: the shape check is what lets the handler + stay narrow instead of swallowing every AttributeError raised below it. + """ + with _fake_config("[1, 2, 3]"): + with self.assertLogs("vocalinux.text_injection.text_injector", level="DEBUG") as logs: + self.assertEqual(TextInjector._configured_backend(), "auto") + warned = self._warnings(logs) + self.assertTrue(warned, f"non-object config was not reported: {logs.output}") + self.assertIn("not a JSON object", warned[0]) + + def test_text_injection_section_that_is_not_an_object_warns(self): + """The top level being a mapping is not enough; the section can still not be. + + Contrast with test_config_that_is_not_an_object_warns: a single + top-level check passes this input and then fails on the lookup. + """ + with _fake_config({"text_injection": [1, 2]}): + with self.assertLogs("vocalinux.text_injection.text_injector", level="DEBUG") as logs: + self.assertEqual(TextInjector._configured_backend(), "auto") + warned = self._warnings(logs) + self.assertTrue(warned, f"non-object section was not reported: {logs.output}") + self.assertIn("text_injection section", warned[0]) + + def test_absent_config_is_not_reported(self): + """No config.json is the default install, not a problem to warn about.""" + for config in (None, {}, {"text_injection": {}}): + with self.subTest(config=config): + # A mocked logger rather than assertLogs: the assertion is that + # nothing was logged, and assertLogs fails when nothing is. + with _fake_config(config): + with patch("vocalinux.text_injection.text_injector.logger") as mock_logger: + self.assertEqual(TextInjector._configured_backend(), "auto") + mock_logger.warning.assert_not_called() + + +class TestBackendPreference(unittest.TestCase): + """The environment variable wins over the persisted setting.""" + + def test_environment_overrides_config(self): + """A one-off experiment must not require editing the user's config.""" + with _fake_config({"text_injection": {"backend": "ibus"}}): + with patch.dict("os.environ", {"VOCALINUX_FORCE_BACKEND": "wtype"}): + self.assertEqual(TextInjector._backend_preference(), "wtype") + + def test_config_used_when_environment_unset(self): + with _fake_config({"text_injection": {"backend": "ydotool"}}): + with patch.dict("os.environ", {}, clear=True): + self.assertEqual(TextInjector._backend_preference(), "ydotool") + + def test_auto_when_neither_is_set(self): + with _fake_config(None): + with patch.dict("os.environ", {}, clear=True): + self.assertEqual(TextInjector._backend_preference(), "auto") + + def test_explicit_auto_overrides_a_saved_pin(self): + """``VOCALINUX_FORCE_BACKEND=auto`` asks for autodetection *this run*. + + It must not be read as "nothing was set" and fall through to the saved + pin, or the variable cannot undo a pin for a single run -- which is the + one-off A/B test it exists for. + """ + with _fake_config({"text_injection": {"backend": "wtype"}}): + with patch.dict("os.environ", {"VOCALINUX_FORCE_BACKEND": "auto"}): + self.assertEqual(TextInjector._backend_preference(), "auto") + + def test_unset_environment_uses_the_saved_pin(self): + """Regression guard for the case explicit ``auto`` must NOT behave like.""" + with _fake_config({"text_injection": {"backend": "wtype"}}): + with patch.dict("os.environ", {}, clear=True): + self.assertEqual(TextInjector._backend_preference(), "wtype") + + def test_typo_in_environment_is_treated_as_unset_not_as_auto(self): + """A typo falls through to the saved pin rather than discarding it. + + Deliberate: an unrecognised value means the user failed to override + their preference, not that they asked for autodetection. Contrast + ``test_explicit_auto_overrides_a_saved_pin``, where they did ask. + """ + with _fake_config({"text_injection": {"backend": "wtype"}}): + with patch.dict("os.environ", {"VOCALINUX_FORCE_BACKEND": "wtpye"}): + self.assertEqual(TextInjector._backend_preference(), "wtype") + + +class TestSelectableBackends(unittest.TestCase): + """xdotool is a pinnable value, and both readers accept the same set.""" + + def test_xdotool_is_accepted_from_the_environment(self): + with patch.dict("os.environ", {"VOCALINUX_FORCE_BACKEND": "xdotool"}): + self.assertEqual(TextInjector._forced_backend_setting(), "xdotool") + + def test_xdotool_is_accepted_from_config(self): + with _fake_config({"text_injection": {"backend": "xdotool"}}): + self.assertEqual(TextInjector._configured_backend(), "xdotool") + + def test_both_readers_accept_the_same_values(self): + """A value valid in one source must be valid in the other.""" + for value in TextInjector._SELECTABLE_BACKENDS: + with patch.dict("os.environ", {"VOCALINUX_FORCE_BACKEND": value}): + self.assertEqual(TextInjector._forced_backend_setting(), value) + with _fake_config({"text_injection": {"backend": value}}): + self.assertEqual(TextInjector._configured_backend(), value) + + def test_help_text_lists_every_selectable_value(self): + """Guards the 'expected ...' messages against drifting from the tuple.""" + help_text = TextInjector._accepted_backends_help() + for value in TextInjector._SELECTABLE_BACKENDS: + self.assertIn(value, help_text) + self.assertIn("auto", help_text) + + +class TestForcedBackendSetting(unittest.TestCase): + """The raw three-state reading of VOCALINUX_FORCE_BACKEND. + + ``_forced_backend()`` collapses unset and explicit ``auto`` together, which + is fine for its own callers but loses the distinction ``_backend_preference()`` + needs to tell "no opinion" from "autodetect this run". + """ + + def test_unset_is_none_and_is_distinct_from_explicit_auto(self): + with patch.dict("os.environ", {}, clear=True): + self.assertIsNone(TextInjector._forced_backend_setting()) + + def test_explicit_auto_is_the_string_not_none(self): + for value in ("auto", " AUTO "): + with patch.dict("os.environ", {"VOCALINUX_FORCE_BACKEND": value}): + self.assertEqual(TextInjector._forced_backend_setting(), "auto") + + def test_empty_value_counts_as_unset(self): + with patch.dict("os.environ", {"VOCALINUX_FORCE_BACKEND": " "}): + self.assertIsNone(TextInjector._forced_backend_setting()) + + def test_recognised_backends_are_returned(self): + for value in ("ibus", "wtype", "ydotool", "xdotool"): + with patch.dict("os.environ", {"VOCALINUX_FORCE_BACKEND": value.upper()}): + self.assertEqual(TextInjector._forced_backend_setting(), value) + + def test_unknown_value_is_unset_rather_than_auto(self): + """See ``test_typo_in_environment_is_treated_as_unset_not_as_auto``.""" + with patch.dict("os.environ", {"VOCALINUX_FORCE_BACKEND": "ibsu"}): + self.assertIsNone(TextInjector._forced_backend_setting()) + + class TestForcedBackend(unittest.TestCase): """VOCALINUX_FORCE_BACKEND overrides backend autodetection.""" @@ -2865,7 +3086,7 @@ class TestForcedBackend(unittest.TestCase): self.assertEqual(TextInjector._forced_backend(), "auto") def test_recognised_backends(self): - for value in ("ibus", "wtype", "ydotool"): + for value in ("ibus", "wtype", "ydotool", "xdotool"): with patch.dict("os.environ", {"VOCALINUX_FORCE_BACKEND": value.upper()}): self.assertEqual(TextInjector._forced_backend(), value) diff --git a/tests/test_text_injector_ext.py b/tests/test_text_injector_ext.py index bfc6069..30b4a6e 100644 --- a/tests/test_text_injector_ext.py +++ b/tests/test_text_injector_ext.py @@ -1,13 +1,14 @@ """Extra tests for text_injector.py to improve branch coverage.""" +import json import os import subprocess import sys import threading import unittest -from typing import Any, cast +from typing import Any, Optional, cast from unittest import mock -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock, mock_open, patch import pytest @@ -367,6 +368,500 @@ class TestCheckDependencies(unittest.TestCase): self.assertEqual(obj.wayland_tool, "wtype") mock_ibus_class.assert_not_called() + def test_config_backend_wtype_skips_ibus(self): + """text_injection.backend=wtype pins wtype with no environment variable set. + + The environment variable is a one-off; this is the persisted setting from + issue #476, so it has to survive a restart on its own. + """ + import json as _json + + from vocalinux.text_injection.text_injector import DesktopEnvironment + + obj = _make_injector(DesktopEnvironment.WAYLAND) + config = _json.dumps({"text_injection": {"backend": "wtype"}}) + + with ( + patch.dict(os.environ, {"XDG_SESSION_TYPE": "wayland"}, clear=True), + patch("vocalinux.text_injection.text_injector.config_dir", return_value="/fake/config"), + patch("os.path.exists", return_value=True), + patch("builtins.open", mock_open(read_data=config)), + patch("vocalinux.text_injection.text_injector.is_ibus_available", return_value=True), + patch("vocalinux.text_injection.text_injector.IBusTextInjector") as mock_ibus_class, + patch( + "shutil.which", + side_effect=lambda cmd: f"/usr/bin/{cmd}" if cmd in ("wtype", "ydotool") else None, + ), + ): + obj._check_dependencies() + + self.assertEqual(obj.wayland_tool, "wtype") + mock_ibus_class.assert_not_called() + + def test_x11_config_pin_never_constructs_ibus(self): + """On X11 the user guide promises any non-ibus pin turns the IBus path off. + + The X11 reporter on #476 wanted exactly that. The pin does not choose the + tool there -- xdotool always does the typing -- so the only thing the pin + changes on X11 is whether IBus runs, and that is what this asserts. + """ + import json as _json + + from vocalinux.text_injection.text_injector import DesktopEnvironment + + def run(pinned: Optional[str]) -> MagicMock: + """Drive _check_dependencies on X11 under conditions IBus would pass.""" + obj = _make_injector(DesktopEnvironment.X11) + section = {"backend": pinned} if pinned else {} + config = _json.dumps({"text_injection": section}) + + with ( + patch.dict(os.environ, {"XDG_SESSION_TYPE": "x11"}, clear=True), + patch( + "vocalinux.text_injection.text_injector.config_dir", + return_value="/fake/config", + ), + patch("os.path.exists", return_value=True), + patch("builtins.open", mock_open(read_data=config)), + patch( + "vocalinux.text_injection.text_injector.is_ibus_available", return_value=True + ), + patch( + "vocalinux.text_injection.text_injector.is_ibus_active_input_method", + return_value=True, + ), + patch( + "vocalinux.text_injection.text_injector.is_ibus_daemon_running", + return_value=True, + ), + patch("vocalinux.text_injection.text_injector.IBusTextInjector") as mock_ibus_class, + patch("shutil.which", side_effect=lambda c: f"/usr/bin/{c}"), + ): + obj._check_dependencies() + return mock_ibus_class + + # Control: with no pin these same conditions do construct IBus, so a + # not-called assertion below is the pin's doing and not the environment's. + self.assertTrue(run(None).called, "control failed: IBus was not constructed without a pin") + + for pinned in ("xdotool", "wtype"): + with self.subTest(backend=pinned): + run(pinned).assert_not_called() + + def test_explicit_auto_environment_ignores_the_saved_config_pin(self): + """VOCALINUX_FORCE_BACKEND=auto is how you get one run of plain autodetection. + + The resolver already knows an explicit auto from an unset variable; this + drives the whole of _check_dependencies to show the saved pin really is + not consulted, rather than only that the resolver returned "auto". + """ + import json as _json + + from vocalinux.text_injection.text_injector import DesktopEnvironment, TextInjector + + obj = _make_injector(DesktopEnvironment.WAYLAND) + obj._backend_pin = ("auto", None) + config = _json.dumps({"text_injection": {"backend": "wtype"}}) + + with ( + patch.dict( + os.environ, + {"XDG_SESSION_TYPE": "wayland", "VOCALINUX_FORCE_BACKEND": "auto"}, + clear=True, + ), + patch("vocalinux.text_injection.text_injector.config_dir", return_value="/fake/config"), + patch("os.path.exists", return_value=True), + patch("builtins.open", mock_open(read_data=config)), + patch("vocalinux.text_injection.text_injector.is_ibus_available", return_value=False), + patch.object(TextInjector, "_ensure_ydotoold", lambda s: True), + patch("shutil.which", side_effect=lambda c: f"/usr/bin/{c}"), + self.assertLogs("vocalinux.text_injection.text_injector", level="DEBUG") as logs, + ): + obj._check_dependencies() + obj._warn_if_pin_not_honoured(*obj._backend_pin) + + # Autodetection prefers ydotool when its daemon is ready; the saved wtype + # pin would have won had the explicit auto not discarded it. + self.assertEqual(obj.wayland_tool, "ydotool") + self.assertFalse( + [line for line in logs.output if "backend pin requested" in line], + f"an explicit auto still reported a pin: {logs.output}", + ) + self.assertFalse(self._not_applied(logs.output)) + + def _run_ibus_pin( + self, + bridges: bool, + desktop: str = "Hyprland", + env: Any = None, + config_pin: bool = False, + ) -> tuple[list[str], int]: + """Drive _check_dependencies with an ibus pin; return (logs, bridge_call_count).""" + from vocalinux.text_injection.text_injector import DesktopEnvironment, TextInjector + + obj = _make_injector(env or DesktopEnvironment.WAYLAND) + calls = [] + + def fake_bridges(self): + calls.append(1) + return bridges + + environ = { + "XDG_SESSION_TYPE": "wayland", + "XDG_CURRENT_DESKTOP": desktop, + } + if not config_pin: + environ["VOCALINUX_FORCE_BACKEND"] = "ibus" + + with ( + patch.dict( + os.environ, + environ, + clear=True, + ), + patch("vocalinux.text_injection.text_injector.config_dir", return_value="/fake/config"), + patch("os.path.exists", return_value=True), + patch( + "builtins.open", + mock_open(read_data=json.dumps({"text_injection": {"backend": "ibus"}})), + ), + patch.object(TextInjector, "_wayland_compositor_bridges_ibus", fake_bridges), + patch("vocalinux.text_injection.text_injector.is_ibus_available", return_value=True), + patch( + "vocalinux.text_injection.text_injector.is_ibus_active_input_method", + return_value=True, + ), + patch( + "vocalinux.text_injection.text_injector.is_ibus_daemon_running", return_value=True + ), + patch("vocalinux.text_injection.text_injector.IBusTextInjector"), + patch.object(TextInjector, "_start_ibus_initialization", lambda s: None), + # shutil.which is patched to claim every tool exists, so without this + # the real probe shells out to a ydotoold that may not be installed + # and logs a warning that has nothing to do with the pin. + patch.object(TextInjector, "_ensure_ydotoold", lambda s: True), + patch("shutil.which", side_effect=lambda c: f"/usr/bin/{c}"), + self.assertLogs("vocalinux.text_injection.text_injector", level="DEBUG") as logs, + ): + obj._check_dependencies() + return logs.output, len(calls) + + @staticmethod + def _silent_failure_warnings(output): + """Only the denylist-bypass warning, not every warning the run emits.""" + return [line for line in output if "may silently do nothing" in line] + + def test_ibus_pin_on_unbridged_compositor_warns_about_silent_failure(self): + """Pinning ibus can re-enable the silent drop the denylist exists to stop.""" + output, _ = self._run_ibus_pin(bridges=False) + self.assertTrue( + self._silent_failure_warnings(output), + f"expected a silent-failure warning, got: {output}", + ) + + def test_ibus_pin_on_bridged_compositor_does_not_warn(self): + """The guard must key off the compositor, not merely off the pin. + + A bridged desktop -- including a denylisted compositor with the + ibus-wayland bridge running -- is not a bypass and must stay quiet. + """ + output, _ = self._run_ibus_pin(bridges=True) + self.assertFalse( + self._silent_failure_warnings(output), + f"expected no silent-failure warning on a bridged compositor, got: {output}", + ) + + def test_config_ibus_pin_checks_the_compositor_bridge(self): + """A saved IBus pin follows the same denylist warning as an env pin.""" + for bridges in (False, True): + with self.subTest(bridges=bridges): + output, _ = self._run_ibus_pin(bridges=bridges, config_pin=True) + if bridges: + self.assertFalse(self._silent_failure_warnings(output)) + else: + self.assertTrue(self._silent_failure_warnings(output)) + self.assertTrue([line for line in output if "text_injection.backend=ibus" in line]) + + def test_ibus_pin_never_double_checks_the_compositor(self): + """The bridging check shells out (pgrep / gdbus), so it must run at most once. + + The elif chain short-circuits on ``not force_ibus``, so a pinned run + reaches the new guard without having evaluated the check already. If a + future edit drops that short-circuit, this catches the extra subprocess. + """ + for bridges in (False, True): + _, call_count = self._run_ibus_pin(bridges=bridges) + self.assertEqual(call_count, 1, f"bridges={bridges}: expected 1 call, got {call_count}") + + def test_ibus_pin_on_x11_does_not_warn(self): + """The denylist is a Wayland concern; X11 reaches apps through XIM.""" + from vocalinux.text_injection.text_injector import DesktopEnvironment + + output, _ = self._run_ibus_pin(bridges=True, env=DesktopEnvironment.X11) + self.assertFalse(self._silent_failure_warnings(output)) + + def _run_with_pin( + self, + pin: Optional[str], + tools: tuple[str, ...], + ibus_available: bool = True, + bridges: bool = True, + env: Any = None, + ydotoold_ready: Optional[bool] = None, + ibus_ready: bool = False, + ) -> tuple[Any, list[str]]: + """Construct through _check_dependencies with a pin; return (injector, logs). + + ydotoold_ready defaults to whether ydotool is installed, which is what + every caller wanted before the daemonless fallback needed exercising. + ``ibus_ready`` promotes a pinned IBus path before reporting, modeling + the completed background initialization used by the honoured-pin cases. + Pass it explicitly to have ydotool present but its daemon unavailable. + """ + from vocalinux.text_injection.text_injector import DesktopEnvironment, TextInjector + + obj = _make_injector(env or DesktopEnvironment.WAYLAND) + obj._backend_pin = ("auto", None) + environ = { + "XDG_SESSION_TYPE": "x11" if env == DesktopEnvironment.X11 else "wayland", + "XDG_CURRENT_DESKTOP": "Hyprland", + } + if pin: + environ["VOCALINUX_FORCE_BACKEND"] = pin + with ( + patch.dict(os.environ, environ, clear=True), + patch( + "vocalinux.text_injection.text_injector.is_ibus_available", + return_value=ibus_available, + ), + patch( + "vocalinux.text_injection.text_injector.is_ibus_active_input_method", + return_value=True, + ), + patch( + "vocalinux.text_injection.text_injector.is_ibus_daemon_running", return_value=True + ), + patch.object(TextInjector, "_wayland_compositor_bridges_ibus", lambda s: bridges), + patch("vocalinux.text_injection.text_injector.IBusTextInjector"), + patch.object(TextInjector, "_start_ibus_initialization", lambda s: None), + patch.object( + TextInjector, + "_ensure_ydotoold", + lambda s: ("ydotool" in tools) if ydotoold_ready is None else ydotoold_ready, + ), + patch("shutil.which", side_effect=lambda c: f"/usr/bin/{c}" if c in tools else None), + self.assertLogs("vocalinux.text_injection.text_injector", level="DEBUG") as logs, + ): + obj._check_dependencies() + if ibus_ready: + obj.environment = ( + DesktopEnvironment.X11_IBUS + if env == DesktopEnvironment.X11 + else DesktopEnvironment.WAYLAND_IBUS + ) + obj._ibus_ready = True + obj._warn_if_pin_not_honoured(*obj._backend_pin) + return obj, logs.output + + def _not_applied(self, output): + return [line for line in output if "was not applied" in line] + + def test_pin_with_missing_binary_warns_and_names_the_reason(self): + """A pin for a tool that is not installed must not resume autodetection silently.""" + _, output = self._run_with_pin("wtype", tools=("ydotool",)) + warned = self._not_applied(output) + self.assertTrue(warned, f"expected a not-applied warning, got: {output}") + self.assertIn("is not installed", warned[0]) + self.assertIn("using ydotool instead", warned[0]) + + def test_pin_that_does_not_apply_to_this_session_warns_without_a_reason(self): + """xdotool on Wayland resolves to whatever autodetection picks; say so plainly.""" + _, output = self._run_with_pin("xdotool", tools=("wtype", "ydotool", "xdotool")) + warned = self._not_applied(output) + self.assertTrue(warned, f"expected a not-applied warning, got: {output}") + self.assertNotIn("is not installed", warned[0]) + + def test_ibus_pin_warns_when_ibus_support_is_unavailable(self): + _, output = self._run_with_pin("ibus", tools=("wtype", "ydotool"), ibus_available=False) + warned = self._not_applied(output) + self.assertTrue(warned, f"expected a not-applied warning, got: {output}") + self.assertIn("IBus support is not available", warned[0]) + + def test_honoured_pin_does_not_warn(self): + _, output = self._run_with_pin("wtype", tools=("wtype", "ydotool")) + self.assertFalse(self._not_applied(output)) + + def test_no_pin_never_warns(self): + _, output = self._run_with_pin(None, tools=("wtype", "ydotool")) + self.assertFalse(self._not_applied(output)) + + def test_honoured_ibus_pin_on_unbridged_compositor_warns_only_once(self): + """The denylist-bypass warning and this one must not both fire. + + The pin IS honoured there, so the backend in use matches what was + pinned; only the bypass warning is appropriate. + """ + _, output = self._run_with_pin( + "ibus", tools=("wtype", "ydotool"), bridges=False, ibus_ready=True + ) + self.assertTrue([line for line in output if "overrides that check" in line]) + self.assertFalse(self._not_applied(output)) + + def test_honoured_ibus_pin_on_bridged_compositor_warns_neither_way(self): + _, output = self._run_with_pin( + "ibus", tools=("wtype", "ydotool"), bridges=True, ibus_ready=True + ) + self.assertFalse([line for line in output if "overrides that check" in line]) + self.assertFalse(self._not_applied(output)) + + def test_pinned_log_line_names_the_config_source_not_the_variable(self): + """A config-sourced pin must not be reported as VOCALINUX_FORCE_BACKEND.""" + from vocalinux.text_injection.text_injector import DesktopEnvironment, TextInjector + + obj = _make_injector(DesktopEnvironment.WAYLAND) + obj._backend_pin = ("auto", None) + config = json.dumps({"text_injection": {"backend": "wtype"}}) + with ( + patch.dict(os.environ, {"XDG_SESSION_TYPE": "wayland"}, clear=True), + patch("vocalinux.text_injection.text_injector.config_dir", return_value="/fake/config"), + patch("os.path.exists", return_value=True), + patch("builtins.open", mock_open(read_data=config)), + patch("vocalinux.text_injection.text_injector.is_ibus_available", return_value=False), + patch.object(TextInjector, "_ensure_ydotoold", lambda s: False), + patch("shutil.which", side_effect=lambda c: f"/usr/bin/{c}" if c == "wtype" else None), + self.assertLogs("vocalinux.text_injection.text_injector", level="DEBUG") as logs, + ): + obj._check_dependencies() + pinned_lines = [line for line in logs.output if "wtype" in line and "=" in line] + self.assertTrue(pinned_lines) + self.assertFalse( + [line for line in pinned_lines if "VOCALINUX_FORCE_BACKEND" in line], + f"config-sourced pin reported as the env var: {pinned_lines}", + ) + + def test_x11_pin_resolving_to_xdotool_warns(self): + """On X11 the tool is always xdotool, so a wtype pin cannot be honoured. + + This is the X11 reporter's case on #476: they wanted IBus off, which a + non-ibus pin does, but the pin they set is not what ends up typing. + """ + from vocalinux.text_injection.text_injector import DesktopEnvironment + + _, output = self._run_with_pin( + "wtype", tools=("xdotool", "wtype"), env=DesktopEnvironment.X11 + ) + warned = self._not_applied(output) + self.assertTrue(warned, f"expected a not-applied warning, got: {output}") + self.assertIn("using xdotool instead", warned[0]) + + def test_ibus_pin_that_fails_to_initialise_warns_without_a_reason(self): + """IBus is available but construction blew up, so no cheap reason applies.""" + from vocalinux.text_injection.text_injector import DesktopEnvironment, TextInjector + + obj = _make_injector(DesktopEnvironment.WAYLAND) + obj._backend_pin = ("auto", None) + with ( + patch.dict( + os.environ, + {"XDG_SESSION_TYPE": "wayland", "VOCALINUX_FORCE_BACKEND": "ibus"}, + clear=True, + ), + patch("vocalinux.text_injection.text_injector.is_ibus_available", return_value=True), + patch( + "vocalinux.text_injection.text_injector.is_ibus_active_input_method", + return_value=True, + ), + patch( + "vocalinux.text_injection.text_injector.is_ibus_daemon_running", return_value=True + ), + patch.object(TextInjector, "_wayland_compositor_bridges_ibus", lambda s: True), + patch( + "vocalinux.text_injection.text_injector.IBusTextInjector", + side_effect=RuntimeError("boom"), + ), + patch.object(TextInjector, "_ensure_ydotoold", lambda s: True), + patch("shutil.which", side_effect=lambda c: f"/usr/bin/{c}"), + self.assertLogs("vocalinux.text_injection.text_injector", level="DEBUG") as logs, + ): + obj._check_dependencies() + obj._warn_if_pin_not_honoured(*obj._backend_pin) + warned = self._not_applied(logs.output) + self.assertTrue(warned, f"expected a not-applied warning, got: {logs.output}") + self.assertNotIn("not available", warned[0]) + self.assertNotIn("not installed", warned[0]) + + def test_ydotool_pin_with_missing_binary_warns_and_names_the_reason(self): + """The wtype case has a counterpart: every selectable tool can be absent. + + Contrast with test_pin_with_missing_binary_warns_and_names_the_reason, + which pins the tool the fallback then chooses; here the pin and the + fallback are swapped, so a check keyed on one name would still pass. + """ + _, output = self._run_with_pin("ydotool", tools=("wtype",)) + warned = self._not_applied(output) + self.assertTrue(warned, f"expected a not-applied warning, got: {output}") + self.assertIn("ydotool is not installed", warned[0]) + self.assertIn("using wtype instead", warned[0]) + + def test_wtype_pin_without_wtype_falls_back_to_daemonless_ydotool(self): + """ydotool present but ydotoold not ready is its own branch, and a pin reaches it. + + Both diagnostics have to survive together: the fallback says the daemon + is missing, and the pin check says the pin is not what ended up typing. + """ + obj, output = self._run_with_pin("wtype", tools=("ydotool",), ydotoold_ready=False) + self.assertEqual(obj.wayland_tool, "ydotool") + self.assertTrue( + [line for line in output if "ydotoold not ready" in line], + f"expected the daemonless-ydotool warning, got: {output}", + ) + warned = self._not_applied(output) + self.assertTrue(warned, f"expected a not-applied warning, got: {output}") + self.assertIn("using ydotool instead", warned[0]) + + def test_ibus_pin_survives_the_x11_missing_xdotool_early_return(self): + """_check_dependencies returns early here, before any tool is selected. + + The pin is still honoured -- IBus is what types -- so the pin check must + not report a failure just because the usual selection chain was skipped. + """ + from vocalinux.text_injection.text_injector import DesktopEnvironment + + obj, output = self._run_with_pin("ibus", tools=(), env=DesktopEnvironment.X11) + self.assertIsNotNone(obj._ibus_injector) + self.assertFalse( + self._not_applied(output), + f"honoured ibus pin reported as not applied: {output}", + ) + + def test_ibus_pin_survives_the_no_tools_early_return(self): + """The other early return: Wayland with nothing installed but IBus asked for. + + Without the early return this path raises RuntimeError, so reaching the + pin check at all is part of what this asserts. + """ + obj, output = self._run_with_pin("ibus", tools=()) + self.assertIsNotNone(obj._ibus_injector) + self.assertFalse( + self._not_applied(output), + f"honoured ibus pin reported as not applied: {output}", + ) + + def test_pin_log_states_the_request_without_claiming_the_outcome(self): + """The pin line runs before anything is checked, so it may not claim success. + + A pin that is not installed is reported by this line and then + contradicted by the not-applied warning, which is what made an + outcome-shaped word wrong here. + """ + _, output = self._run_with_pin("wtype", tools=("ydotool",)) + requested = [line for line in output if "backend pin requested" in line] + self.assertTrue(requested, f"expected the pin-requested line, got: {output}") + self.assertFalse( + [line for line in output if "overriding" in line], + f"pin line still claims an outcome it cannot know: {output}", + ) + def test_force_backend_ydotool_skips_ibus_and_wtype(self): """VOCALINUX_FORCE_BACKEND=ydotool pins ydotool even when wtype is available.""" from vocalinux.text_injection.text_injector import DesktopEnvironment @@ -1148,6 +1643,189 @@ class TestStop(unittest.TestCase): class TestBackgroundIBusInitialization(unittest.TestCase): + def test_constructor_reports_pending_ibus_pin_without_claiming_success(self): + """A constructed injector is not active until its background warmup succeeds.""" + from vocalinux.text_injection.text_injector import DesktopEnvironment, TextInjector + + ibus = MagicMock() + with ( + patch.dict( + os.environ, + {"XDG_SESSION_TYPE": "x11", "VOCALINUX_FORCE_BACKEND": "ibus"}, + clear=True, + ), + patch("vocalinux.text_injection.text_injector.is_ibus_available", return_value=True), + patch( + "vocalinux.text_injection.text_injector.is_ibus_active_input_method", + return_value=True, + ), + patch( + "vocalinux.text_injection.text_injector.is_ibus_daemon_running", + return_value=True, + ), + patch("vocalinux.text_injection.text_injector.IBusTextInjector", return_value=ibus), + patch.object(TextInjector, "_start_ibus_initialization"), + patch("shutil.which", return_value=None), + self.assertLogs("vocalinux.text_injection.text_injector", level="DEBUG") as logs, + ): + obj = TextInjector() + + self.assertEqual(obj.environment, DesktopEnvironment.X11) + self.assertIs(obj._ibus_injector, ibus) + self.assertTrue( + [ + line + for line in logs.output + if line.endswith("IBus initialization is pending; backend selection is not final.") + ], + f"expected pending diagnostic, got: {logs.output}", + ) + self.assertFalse([line for line in logs.output if "was not applied" in line]) + + def test_constructor_reports_ready_ibus_as_honoured(self): + """A completed warmup promotes the environment before startup reporting.""" + from vocalinux.text_injection.text_injector import DesktopEnvironment, TextInjector + + ibus = MagicMock() + + def initialize_now(obj: TextInjector) -> None: + obj._ibus_injector = ibus + obj._initialize_ibus_in_background() + + with ( + patch.dict( + os.environ, + {"XDG_SESSION_TYPE": "wayland", "VOCALINUX_FORCE_BACKEND": "ibus"}, + clear=True, + ), + patch("vocalinux.text_injection.text_injector.is_ibus_available", return_value=True), + patch( + "vocalinux.text_injection.text_injector.is_ibus_active_input_method", + return_value=True, + ), + patch( + "vocalinux.text_injection.text_injector.is_ibus_daemon_running", + return_value=True, + ), + patch("vocalinux.text_injection.text_injector.IBusTextInjector", return_value=ibus), + patch.object(TextInjector, "_start_ibus_initialization", initialize_now), + patch.object(TextInjector, "_wayland_compositor_bridges_ibus", lambda s: True), + patch.object(TextInjector, "_ensure_ydotoold", lambda s: True), + patch("shutil.which", side_effect=lambda name: "/usr/bin/" + name), + self.assertLogs("vocalinux.text_injection.text_injector", level="DEBUG") as logs, + ): + obj = TextInjector() + + self.assertEqual(obj.environment, DesktopEnvironment.WAYLAND_IBUS) + self.assertTrue(obj._ibus_ready) + self.assertFalse([line for line in logs.output if "was not applied" in line]) + self.assertFalse([line for line in logs.output if "initialization is pending" in line]) + + def test_constructor_reports_ibus_failure_before_startup_diagnostic(self): + """A known warmup failure must use the ordinary fallback mismatch warning.""" + from vocalinux.text_injection.text_injector import TextInjector + + ibus = MagicMock() + ibus.prepare_engine.side_effect = RuntimeError("not ready") + + def initialize_now(obj: TextInjector) -> None: + obj._ibus_injector = ibus + obj._initialize_ibus_in_background() + + with ( + patch.dict( + os.environ, + {"XDG_SESSION_TYPE": "x11", "VOCALINUX_FORCE_BACKEND": "ibus"}, + clear=True, + ), + patch("vocalinux.text_injection.text_injector.is_ibus_available", return_value=True), + patch( + "vocalinux.text_injection.text_injector.is_ibus_active_input_method", + return_value=True, + ), + patch( + "vocalinux.text_injection.text_injector.is_ibus_daemon_running", + return_value=True, + ), + patch("vocalinux.text_injection.text_injector.IBusTextInjector", return_value=ibus), + patch.object(TextInjector, "_start_ibus_initialization", initialize_now), + patch("shutil.which", side_effect=lambda name: "/usr/bin/" + name), + self.assertLogs("vocalinux.text_injection.text_injector", level="DEBUG") as logs, + ): + obj = TextInjector() + + self.assertTrue(obj._ibus_init_failed) + self.assertTrue([line for line in logs.output if "IBus initialization failed" in line]) + warned = [line for line in logs.output if "was not applied" in line] + self.assertTrue(warned, f"expected mismatch warning, got: {logs.output}") + self.assertIn("using xdotool instead", warned[0]) + self.assertFalse([line for line in logs.output if "initialization is pending" in line]) + + def test_constructor_ibus_construction_failure_keeps_fallback_diagnostic(self): + """A constructor failure has no injector and still reports the fallback.""" + from vocalinux.text_injection.text_injector import TextInjector + + with ( + patch.dict( + os.environ, + {"XDG_SESSION_TYPE": "x11", "VOCALINUX_FORCE_BACKEND": "ibus"}, + clear=True, + ), + patch("vocalinux.text_injection.text_injector.is_ibus_available", return_value=True), + patch( + "vocalinux.text_injection.text_injector.is_ibus_active_input_method", + return_value=True, + ), + patch( + "vocalinux.text_injection.text_injector.is_ibus_daemon_running", + return_value=True, + ), + patch( + "vocalinux.text_injection.text_injector.IBusTextInjector", + side_effect=RuntimeError("constructor failed"), + ), + patch("shutil.which", side_effect=lambda name: "/usr/bin/" + name), + self.assertLogs("vocalinux.text_injection.text_injector", level="DEBUG") as logs, + ): + obj = TextInjector() + + self.assertIsNone(obj._ibus_injector) + warned = [line for line in logs.output if "was not applied" in line] + self.assertTrue(warned, f"expected mismatch warning, got: {logs.output}") + self.assertIn("using xdotool instead", warned[0]) + + def test_late_ibus_failure_keeps_background_warning_after_pending_state(self): + """A later failure retains the existing background warning and fallback.""" + from vocalinux.text_injection.text_injector import DesktopEnvironment, TextInjector + + obj = _make_injector(DesktopEnvironment.WAYLAND) + obj._ibus_injector = MagicMock() + obj.wayland_tool = "ydotool" + obj._ibus_injector.prepare_engine.side_effect = RuntimeError("late failure") + obj._backend_pin = ("ibus", "VOCALINUX_FORCE_BACKEND") + + with self.assertLogs("vocalinux.text_injection.text_injector", level="DEBUG") as logs: + obj._warn_if_pin_not_honoured(*obj._backend_pin) + obj._initialize_ibus_in_background() + + self.assertTrue([line for line in logs.output if "initialization is pending" in line]) + self.assertTrue([line for line in logs.output if "IBus initialization failed" in line]) + self.assertTrue(obj._ibus_init_failed) + self.assertEqual(obj._resolved_backend(), "ydotool") + + def test_resolved_backend_requires_ibus_environment_and_injector(self): + """A stale injector object cannot override the final non-IBus route.""" + from vocalinux.text_injection.text_injector import DesktopEnvironment + + obj = _make_injector(DesktopEnvironment.WAYLAND_XDOTOOL) + obj._ibus_injector = MagicMock() + obj.wayland_tool = "wtype" + self.assertEqual(obj._resolved_backend(), "xdotool") + + obj._ibus_injector = None + obj.environment = DesktopEnvironment.WAYLAND_IBUS + self.assertEqual(obj._resolved_backend(), "wtype") + def test_check_dependencies_starts_ibus_in_background(self): from vocalinux.text_injection.text_injector import DesktopEnvironment