From 12e440ce1c799f77c79834670530b555134c8553 Mon Sep 17 00:00:00 2001 From: Hashing <88584784+HashimAbdulaziz@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:24:59 +0300 Subject: [PATCH] feat(text-injection): let config.json pin the injection backend (#649) * feat(text-injection): let config.json pin the injection backend (#476) Autodetection has to infer whether IBus commits actually reach the focused application, and it cannot verify that: commit_text() reports success even when the compositor drops the text. When the inference is wrong the failure is silent, and dictation appears to work in XWayland windows while doing nothing in native ones. VOCALINUX_FORCE_BACKEND already provides an escape hatch, but it is an environment variable: it covers a single run, is undocumented outside the test suite, and a user whose compositor is misdetected has to wrap the launcher in a shell script to make the choice stick. Add a persistent `text_injection.backend` setting alongside it, accepting the same ibus/wtype/ydotool/auto values. The environment variable still wins so a backend can be A/B-tested for one run without touching the saved configuration, and the startup log now names which of the two pinned the choice so a stale setting is diagnosable from --debug output. Reads the file directly rather than through ConfigManager, keeping this package independent of the UI layer, exactly as _should_copy_to_clipboard already does. An unreadable or corrupt config falls back to autodetection instead of blocking startup, and an unrecognised value is ignored with a warning so a typo cannot silently pin the wrong backend. Defaults to "auto", so existing installs behave exactly as before. * fix(text-injection): tell an explicit VOCALINUX_FORCE_BACKEND=auto from unset The variable is meant to override the saved text_injection.backend pin for a single run, which is what makes the two paths A/B-testable without editing config. Setting it to auto -- the value that means "autodetect this time" -- did not do that: the reader collapsed unset and an explicit auto into the same "auto" return, so the caller read both as "nothing was forced" and fell through to the saved pin. The pin won, and there was no way to ask for autodetection for one run short of editing the config file the variable exists to avoid. Read the variable as three states instead: unset, an explicit auto, or a backend name. Only the unset state consults config, so an explicit auto now short-circuits to autodetection. An unrecognised value is deliberately treated as unset rather than as auto. A typo means the user failed to override their preference, not that they asked to discard it, so falling through to a valid saved pin is the kinder reading. _forced_backend() keeps its signature and its collapsed return, so its existing callers and tests are untouched; the new three-state reading lives alongside it and is what the preference resolver consumes. * feat(text-injection): accept an xdotool pin and warn when ibus overrides the denylist Two gaps in the pinnable backend set. xdotool was not accepted, even though on X11 it is the tool that actually does the typing. Users were told to pin a *Wayland* backend to mean "not IBus", and get xdotool as a side effect. Naming it directly is what they were reaching for. On Wayland it needs no special handling: the value skips IBus like any other non-ibus pin and falls through to the existing XWayland fallback, so no branch is added to the selection chain. The accepted values now live in one tuple, because four places listed them -- both readers and both "expected ..." messages -- and a fifth value would have had to be added to all four. The two messages derive from the tuple, so they cannot fall behind it. Pinning ibus also silently bypasses the compositor denylist. That check exists because a denylisted compositor makes IBus report a commit as delivered while the text never arrives (#478, #485), so a pin is the one setting that can put a user back into a failure with no symptom other than nothing happening. The pin is still honoured -- an override that ignores you is worse than one that warns -- but it now says so at warning level, names the compositor, and gives the remedy. It stays quiet when the compositor genuinely bridges IBus, including a denylisted one running the ibus-wayland bridge, and on X11 where the denylist does not apply. The check is evaluated once. The guard chain short-circuits on the pin, so a pinned run never reaches the existing compositor branch, and this is its first and only call -- worth knowing because the check can shell out to pgrep or gdbus. A test asserts the call count so that stays true. Documents xdotool in the value table and rewrites the X11 paragraph, which described xdotool as the implicit result of pinning something else rather than as a value you can name. * fix(text-injection): say so when the pinned backend is not the one in use A pin could be quietly ignored. Pinning a tool that is not installed fell through to autodetection and typed with whatever it found, under a log line that had already announced the pin was overriding autodetection. Pinning xdotool on Wayland did nothing at all, since the selection chain reaches ydotool and wtype first. Pinning ibus where IBus support is unavailable skipped the whole IBus branch. In each case the user set a preference, saw no complaint, and got something else. These are one condition, not three: whatever ended up doing the typing is not what was pinned. The check compares the pin against the backend read back from final state, so a cause nobody has thought of yet is covered without another branch. It runs at the end of construction rather than inside _check_dependencies, which returns early on two paths and which the wtype probe can still override afterwards. The reason clause is added only where a generic check answers it -- the binary is absent, or IBus is unavailable -- and omitted otherwise rather than guessing. Reporting the source separately was letting the environment variable and the config setting disagree. _check_dependencies resolved the backend, then re-derived which source had won by asking again -- so the environment was parsed twice per construction, and a malformed value was reported to the user twice. Resolution now returns the backend and its source together, which fixes the duplicate warning and means the source named in a log line is the one that actually chose the backend. _backend_preference() keeps its signature and reads the first element. Two log lines also claimed VOCALINUX_FORCE_BACKEND regardless of where the pin came from, so a config-set backend was reported as an environment variable the user had never set. They now name the real source. The new warning is a startup diagnostic, not a live invariant: a tool switched later by _try_recover_from_fallback is not reported again. Noted in the docstring so the boundary is deliberate. * docs(user-guide): file the injection-backend section under Troubleshooting The section sat under Customization, directly beneath GPU Acceleration -- where the paragraph immediately above it reads "To check which backend is being used" about Vulkan and CUDA. The word means something else there, and two adjacent sections using it for different things is a bad place to explain either. It also is not a customization option. It exists because a compositor can make IBus report a commit as delivered while nothing arrives (#478, #485), and someone hitting that reads Troubleshooting, not Customization. The section already opens on the symptom, so it needs no rewriting to belong there. Pure reorder: no wording changes, and the heading text is unchanged so any existing link still resolves. * test(text-injection): cover the pin paths that skip the selection chain _check_dependencies had three uncovered branches, all reachable by pinning a backend and none of them exercised: the two early returns that hand over to IBus before a tool is chosen, and the daemonless-ydotool fallback a wtype pin lands on when wtype is absent. The early returns matter most, since the pin check runs at the end of construction precisely so those paths cannot skip it, and nothing proved that. Adds the ydotool counterpart to the missing-binary case, so a check keyed on one tool name cannot pass by coincidence; an X11 test for the guide's claim that any non-ibus pin turns the IBus path off, with a control asserting IBus is constructed without a pin so the assertion is the pin's doing; and an end-to-end explicit-auto case, which until now was only proven at the resolver rather than through selection. The pin log line said it was "overriding backend autodetection" before anything had been checked. An unavailable pin falls through to autodetection, so that line announced an outcome it could not know and then read as contradicting the "was not applied" warning. It now states the request only. _run_with_pin gains ydotoold_ready, defaulting to the previous behaviour so existing callers are unchanged. * fix(text-injection): report a config file the backend pin cannot be read from A corrupt config.json disappeared into logger.debug. The setting is hand-edited -- that is the workflow the user guide documents -- so a stray comma is a likely way to reach this, and staying quiet left the user with autodetection and the silent IBus miss the pin was set to avoid, with nothing to tell that apart from the pin not working. Unknown values already warned; an unusable file now does too, naming the file and the parse error. The bare handler was also catching AttributeError by accident. Valid JSON that is not an object -- a bare list, string, number or null -- reaches the lookup and raises there rather than at parse time, and so does a text_injection section that is not an object, where the top level is a mapping and the section is not. Both are now shape checks rather than exception handling, which is what lets the handler narrow to OSError and ValueError without those five shapes escaping it. Locating the file moves out of the try. It fixes the handler being unable to name the path it failed on, and it is a deliberate boundary: config_dir() resolving a path is not a failure this should paper over, so it is no longer guarded here at all. A missing config.json stays silent, which is the default install. * test(text-injection): stop the ibus-pin tests depending on an installed ydotoold The bridged-compositor and X11 cases asserted that a run logs no warnings at all. That is broader than what they are about, and _run_ibus_pin patches shutil.which to claim every tool exists without patching the probe that then shells out to one, so the real _ensure_ydotoold ran and warned whenever /usr/bin/ydotoold was absent. The tests passed on a machine that happens to have it installed and failed on the CI runners that do not. The assertions now name the warning they are about, and the helper patches _ensure_ydotoold the way its sibling _run_with_pin already did, so nothing in this class of test reads the host's installed packages. * fix(text-injection): report pending IBus initialization state * docs(text-injection): clarify backend pin behavior * fix(config): preserve a hand-edited backend across unrelated saves ConfigManager writes its full in-memory configuration on every save. A user who edits text_injection.backend in config.json while Vocalinux is running therefore loses that choice when an unrelated Settings or tray save writes the stale default back. Remember only the backend value observed at load or after a successful save. If the in-memory value is unchanged, preserve the current value from disk; if it changed through ConfigManager, treat that as the intentional write. Missing and explicit auto both reset to auto. The save aborts rather than overwriting a file whose JSON or text_injection shape cannot be read safely. This is deliberately per-key: no recursive configuration merge or injection-layer dependency is added. * style(text-injection): remove redundant typing suppression * fix(config): keep invalid UTF-8 config from blocking startup * fix(config): preserve malformed backend sections on unrelated saves * fix(config): retain text setting changes after malformed config * test(text-injection): annotate backend pin test helpers --- docs/USER_GUIDE.md | 57 ++ src/vocalinux/text_injection/text_injector.py | 286 +++++++- src/vocalinux/ui/config_manager.py | 102 ++- tests/test_config_manager.py | 184 +++++ tests/test_text_injector.py | 225 +++++- tests/test_text_injector_ext.py | 682 +++++++++++++++++- 6 files changed, 1512 insertions(+), 24 deletions(-) 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