From 3cddb0ebeca06049040545469f518df9989c092a Mon Sep 17 00:00:00 2001 From: goodroot <9484709+goodroot@users.noreply.github.com> Date: Sun, 24 May 2026 14:56:58 -0700 Subject: [PATCH 1/2] tests etc --- docs/CONFIGURATION.md | 4 +- lib/main.py | 5 +- lib/src/text_injector.py | 57 +++++++++++- tests/test_main_startup_safety.py | 34 +++++++ tests/test_text_injector_injection.py | 122 ++++++++++++++++++++++++++ 5 files changed, 216 insertions(+), 6 deletions(-) create mode 100644 tests/test_text_injector_injection.py diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 7583da0..083da68 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -911,9 +911,9 @@ Useful for chat applications, search boxes, or any input where you want to submi ### Clipboard behavior -hyprwhspr saves your clipboard before injection and restores it automatically afterward — your clipboard contents are never permanently overwritten by dictated text. +hyprwhspr saves your clipboard before injection and restores it automatically afterward -- your clipboard contents are never permanently overwritten by dictated text. -The paste hotkey is sent via `wtype` (Wayland virtual-keyboard protocol), which works correctly in all applications including Kitty-protocol terminals (Ghostty, Kitty, WezTerm). `ydotool` is used as a fallback when `wtype` is not installed. +The paste hotkey is sent via `wtype` (Wayland virtual-keyboard protocol), which works correctly in all applications including Kitty-protocol terminals (Ghostty, Kitty, WezTerm). `ydotool key` is used as a fallback when `wtype` is not installed or cannot paste. On GNOME/Mutter Wayland, where synthetic modifier chords may be dropped, hyprwhspr falls back to `ydotool type` and injects the text directly. ### Post-transcription hook diff --git a/lib/main.py b/lib/main.py index 0d6ee9e..2cc3b6c 100644 --- a/lib/main.py +++ b/lib/main.py @@ -1571,7 +1571,10 @@ def _inject_text(self, text): return try: - self.text_injector.inject_text(text) + if not self.text_injector.inject_text(text): + print(f"[ERROR] Text injection failed ({len(text)} chars)", flush=True) + return + print(f"[INJECT] Text injected ({len(text)} chars)", flush=True) # Text injection succeeded - system is fully healthy diff --git a/lib/src/text_injector.py b/lib/src/text_injector.py index 0b1be23..3060f54 100644 --- a/lib/src/text_injector.py +++ b/lib/src/text_injector.py @@ -350,6 +350,49 @@ def _key(*args): print(f"Slow paste key injection failed: {e}") return False + def _is_gnome_wayland_session(self) -> bool: + """Return True for Mutter/GNOME Wayland sessions where uinput chords are unreliable.""" + session_type = os.environ.get('XDG_SESSION_TYPE', '').lower() + if session_type and session_type != 'wayland': + return False + if not session_type and not os.environ.get('WAYLAND_DISPLAY'): + return False + + desktop_values = [ + os.environ.get('XDG_CURRENT_DESKTOP', ''), + os.environ.get('XDG_SESSION_DESKTOP', ''), + os.environ.get('DESKTOP_SESSION', ''), + ] + desktop = ':'.join(desktop_values).lower() + desktop_tokens = set(filter(None, re.split(r'[^a-z0-9]+', desktop))) + return bool(desktop_tokens & {'gnome', 'pop'}) + + def _type_text_ydotool(self, text: str) -> bool: + """ + Type text directly with ydotool. + + This is slower and less layout-aware than clipboard paste on compositors + where paste chords work, but it avoids Mutter dropping synthetic modifier + combos from uinput devices. + """ + if not self.ydotool_available: + return False + + try: + result = subprocess.run( + ['ydotool', 'type', '--key-delay', '5', '--key-hold', '5', '--', text], + capture_output=True, + timeout=10, + ) + if result.returncode != 0: + stderr = (result.stderr or b'').decode('utf-8', 'ignore') + print(f" ydotool type failed: {stderr}") + return False + return True + except Exception as e: + print(f"ydotool type injection failed: {e}") + return False + def _save_clipboard(self) -> Optional[bytes]: """Save current clipboard contents. Returns raw bytes or None.""" if shutil.which("wl-paste"): @@ -645,7 +688,9 @@ def _inject_via_clipboard_and_hotkey(self, text: str) -> bool: else: paste_mode = self._detect_paste_mode(window_info) - # Send paste hotkey: prefer wtype (Wayland virtual-keyboard), fall back to ydotool + # Send paste hotkey: prefer wtype (Wayland virtual-keyboard), fall back to ydotool. + # Mutter/GNOME accepts individual uinput key events but drops modifier + # chords, so skip ydotool's paste chord there and use direct typing. pasted = False if self.wtype_available: pasted = self._send_paste_keys_wtype(paste_mode) @@ -654,18 +699,24 @@ def _inject_via_clipboard_and_hotkey(self, text: str) -> bool: # state so subsequent physical keypresses are not affected. self._clear_stuck_modifiers() - if not pasted and self.ydotool_available: + gnome_wayland_session = self._is_gnome_wayland_session() + if not pasted and self.ydotool_available and not gnome_wayland_session: self._clear_stuck_modifiers() time.sleep(0.02) pasted = self._send_paste_keys_slow(paste_mode) + if not pasted and self.ydotool_available and gnome_wayland_session: + self._clear_stuck_modifiers() + time.sleep(0.05) + pasted = self._type_text_ydotool(text) + if not pasted and not self.wtype_available and not self.ydotool_available: print("No key-injection tool available; text is on the clipboard.") # Text is clipboard-only: don't restore old clipboard (would erase it) # and don't auto-submit (nothing was pasted into the field). return True - # Only restore clipboard after a successful hotkey paste — if paste failed, + # Only restore clipboard after successful injection — if injection failed, # leave dictated text on clipboard so the user can paste manually. if pasted: restore_delay = 0.5 diff --git a/tests/test_main_startup_safety.py b/tests/test_main_startup_safety.py index 31248b8..5ca48a8 100644 --- a/tests/test_main_startup_safety.py +++ b/tests/test_main_startup_safety.py @@ -132,6 +132,40 @@ def test_process_audio_finally_clears_transcript_preview(self): self.assertTrue(clears_in_finally) + def test_inject_text_checks_injector_result_before_success_log(self): + tree = ast.parse((ROOT / "lib" / "main.py").read_text(encoding="utf-8")) + + inject_func = self._find_function(tree, "_inject_text") + self.assertIsNotNone(inject_func) + + result_checked_line = None + success_log_line = None + for node in ast.walk(inject_func): + if ( + isinstance(node, ast.UnaryOp) + and isinstance(node.op, ast.Not) + and isinstance(node.operand, ast.Call) + and isinstance(node.operand.func, ast.Attribute) + and node.operand.func.attr == "inject_text" + ): + result_checked_line = node.lineno + elif ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id == "print" + and node.args + and isinstance(node.args[0], ast.JoinedStr) + and any( + isinstance(part, ast.Constant) and "[INJECT] Text injected" in str(part.value) + for part in node.args[0].values + ) + ): + success_log_line = node.lineno + + self.assertIsNotNone(result_checked_line) + self.assertIsNotNone(success_log_line) + self.assertLess(result_checked_line, success_log_line) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_text_injector_injection.py b/tests/test_text_injector_injection.py new file mode 100644 index 0000000..4149d21 --- /dev/null +++ b/tests/test_text_injector_injection.py @@ -0,0 +1,122 @@ +import sys +import types +import unittest +from pathlib import Path +from unittest import mock + + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "lib" / "src")) +sys.modules.setdefault("pyperclip", types.SimpleNamespace(copy=lambda text: None, paste=lambda: "")) + +from text_injector import TextInjector + + +class ConfigStub: + def get_setting(self, name, default=None): + return default + + +class TextInjectorInjectionTests(unittest.TestCase): + def _injector(self): + injector = TextInjector.__new__(TextInjector) + injector.config_manager = ConfigStub() + injector.ydotool_available = True + injector.wtype_available = False + return injector + + def test_gnome_wayland_uses_ydotool_type_instead_of_paste_chord(self): + injector = self._injector() + + with ( + mock.patch("text_injector.shutil.which", return_value=None), + mock.patch("text_injector.pyperclip.copy"), + mock.patch.object(injector, "_get_active_window_info", return_value=None), + mock.patch.object(injector, "_save_clipboard", return_value=b"old clipboard"), + mock.patch.object(injector, "_send_paste_keys_slow", return_value=True) as paste_chord, + mock.patch.object(injector, "_type_text_ydotool", return_value=True) as direct_type, + mock.patch.object(injector, "_restore_clipboard") as restore_clipboard, + mock.patch.object(injector, "_send_enter_if_auto_submit"), + mock.patch.dict( + "text_injector.os.environ", + { + "XDG_SESSION_TYPE": "wayland", + "XDG_CURRENT_DESKTOP": "pop:GNOME", + "XDG_SESSION_DESKTOP": "pop", + "DESKTOP_SESSION": "pop", + "WAYLAND_DISPLAY": "wayland-0", + }, + clear=True, + ), + ): + self.assertTrue(injector._inject_via_clipboard_and_hotkey("hello")) + + paste_chord.assert_not_called() + direct_type.assert_called_once_with("hello") + restore_clipboard.assert_called_once_with( + b"old clipboard", + injected=b"hello", + delay=0.5, + ) + + def test_non_gnome_leaves_clipboard_on_failed_paste_chord(self): + injector = self._injector() + + with ( + mock.patch("text_injector.shutil.which", return_value=None), + mock.patch("text_injector.pyperclip.copy"), + mock.patch.object(injector, "_get_active_window_info", return_value=None), + mock.patch.object(injector, "_save_clipboard", return_value=None), + mock.patch.object(injector, "_send_paste_keys_slow", return_value=False) as paste_chord, + mock.patch.object(injector, "_type_text_ydotool", return_value=True) as direct_type, + mock.patch.object(injector, "_restore_clipboard"), + mock.patch.object(injector, "_send_enter_if_auto_submit"), + mock.patch.dict( + "text_injector.os.environ", + { + "XDG_SESSION_TYPE": "wayland", + "XDG_CURRENT_DESKTOP": "sway", + "XDG_SESSION_DESKTOP": "sway", + "DESKTOP_SESSION": "sway", + "WAYLAND_DISPLAY": "wayland-1", + }, + clear=True, + ), + ): + self.assertFalse(injector._inject_via_clipboard_and_hotkey("hello")) + + paste_chord.assert_called_once_with("ctrl") + direct_type.assert_not_called() + + def test_ydotool_type_command_uses_small_delay_and_argument_separator(self): + injector = self._injector() + completed = types.SimpleNamespace(returncode=0, stderr=b"") + + with mock.patch("text_injector.subprocess.run", return_value=completed) as run: + self.assertTrue(injector._type_text_ydotool("hello -- world")) + + run.assert_called_once_with( + ['ydotool', 'type', '--key-delay', '5', '--key-hold', '5', '--', 'hello -- world'], + capture_output=True, + timeout=10, + ) + + def test_gnome_detection_does_not_match_pop_substrings(self): + injector = self._injector() + + with mock.patch.dict( + "text_injector.os.environ", + { + "XDG_SESSION_TYPE": "wayland", + "XDG_CURRENT_DESKTOP": "popup", + "XDG_SESSION_DESKTOP": "popterm", + "DESKTOP_SESSION": "custom", + "WAYLAND_DISPLAY": "wayland-1", + }, + clear=True, + ): + self.assertFalse(injector._is_gnome_wayland_session()) + + +if __name__ == "__main__": + unittest.main() From 0ff587c9536557b9e0476233265c605fc69f0812 Mon Sep 17 00:00:00 2001 From: goodroot <9484709+goodroot@users.noreply.github.com> Date: Sun, 24 May 2026 15:05:41 -0700 Subject: [PATCH 2/2] cleaner clipboard behaviour --- docs/CONFIGURATION.md | 6 ++- lib/src/text_injector.py | 42 ++++++++++++++++++--- tests/test_text_injector_injection.py | 53 ++++++++++++++++++++++----- 3 files changed, 84 insertions(+), 17 deletions(-) diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 083da68..fd11339 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -893,6 +893,8 @@ Quick way to fix it on Wayland (no arithmetic): Advanced (if you already know the Linux evdev keycode): set `paste_keycode` directly. +On GNOME/Mutter Wayland, hyprwhspr may use `ydotool type` because Mutter can drop synthetic paste chords. If you set `paste_keycode_wev` or a non-default `paste_keycode`, this direct typing fallback is disabled because it cannot honor that layout workaround; the dictated text remains on the clipboard if no paste backend succeeds. + ### Auto-submit Automatically press Enter after pasting. @@ -911,9 +913,9 @@ Useful for chat applications, search boxes, or any input where you want to submi ### Clipboard behavior -hyprwhspr saves your clipboard before injection and restores it automatically afterward -- your clipboard contents are never permanently overwritten by dictated text. +For clipboard-based paste injection, hyprwhspr saves your clipboard before injection and restores it automatically afterward -- your clipboard contents are never permanently overwritten by dictated text. -The paste hotkey is sent via `wtype` (Wayland virtual-keyboard protocol), which works correctly in all applications including Kitty-protocol terminals (Ghostty, Kitty, WezTerm). `ydotool key` is used as a fallback when `wtype` is not installed or cannot paste. On GNOME/Mutter Wayland, where synthetic modifier chords may be dropped, hyprwhspr falls back to `ydotool type` and injects the text directly. +The paste hotkey is sent via `wtype` (Wayland virtual-keyboard protocol), which works correctly in all applications including Kitty-protocol terminals (Ghostty, Kitty, WezTerm). `ydotool key` is used as a fallback when `wtype` is not installed or cannot paste. On GNOME/Mutter Wayland, where synthetic modifier chords may be dropped, hyprwhspr can use `ydotool type` and inject the text directly without touching the clipboard. ### Post-transcription hook diff --git a/lib/src/text_injector.py b/lib/src/text_injector.py index 3060f54..00d4808 100644 --- a/lib/src/text_injector.py +++ b/lib/src/text_injector.py @@ -85,6 +85,24 @@ def _get_paste_keycode(self) -> int: except Exception: return DEFAULT_PASTE_KEYCODE + def _has_custom_paste_keycode(self) -> bool: + """Return True when config indicates a non-QWERTY paste key workaround.""" + if not self.config_manager: + return False + + wev_keycode = self.config_manager.get_setting('paste_keycode_wev', None) + if wev_keycode is not None: + try: + return int(wev_keycode) - 8 != DEFAULT_PASTE_KEYCODE + except Exception: + return True + + paste_keycode = self.config_manager.get_setting('paste_keycode', DEFAULT_PASTE_KEYCODE) + try: + return int(paste_keycode) != DEFAULT_PASTE_KEYCODE + except Exception: + return True + def _get_active_window_info(self) -> Optional[Dict[str, Any]]: """Get active window info, trying multiple compositor APIs.""" # Niri @@ -667,6 +685,24 @@ def _inject_via_clipboard_and_hotkey(self, text: str) -> bool: """Copy text to clipboard, then trigger paste via wtype (or ydotool fallback).""" try: window_info = self._get_active_window_info() + gnome_wayland_session = self._is_gnome_wayland_session() + + # On GNOME/Mutter, both wtype and ydotool paste chords are unreliable. + # Type directly before touching the clipboard, unless the user has + # configured a non-QWERTY paste key workaround that ydotool type + # cannot honor. + if ( + gnome_wayland_session + and self.ydotool_available + and not self._has_custom_paste_keycode() + ): + self._clear_stuck_modifiers() + time.sleep(0.05) + typed = self._type_text_ydotool(text) + if typed: + self._send_enter_if_auto_submit() + return True + saved_clipboard = self._save_clipboard() # Copy text to clipboard @@ -699,17 +735,11 @@ def _inject_via_clipboard_and_hotkey(self, text: str) -> bool: # state so subsequent physical keypresses are not affected. self._clear_stuck_modifiers() - gnome_wayland_session = self._is_gnome_wayland_session() if not pasted and self.ydotool_available and not gnome_wayland_session: self._clear_stuck_modifiers() time.sleep(0.02) pasted = self._send_paste_keys_slow(paste_mode) - if not pasted and self.ydotool_available and gnome_wayland_session: - self._clear_stuck_modifiers() - time.sleep(0.05) - pasted = self._type_text_ydotool(text) - if not pasted and not self.wtype_available and not self.ydotool_available: print("No key-injection tool available; text is on the clipboard.") # Text is clipboard-only: don't restore old clipboard (would erase it) diff --git a/tests/test_text_injector_injection.py b/tests/test_text_injector_injection.py index 4149d21..3a06233 100644 --- a/tests/test_text_injector_injection.py +++ b/tests/test_text_injector_injection.py @@ -13,8 +13,11 @@ class ConfigStub: + def __init__(self, settings=None): + self.settings = settings or {} + def get_setting(self, name, default=None): - return default + return self.settings.get(name, default) class TextInjectorInjectionTests(unittest.TestCase): @@ -30,13 +33,13 @@ def test_gnome_wayland_uses_ydotool_type_instead_of_paste_chord(self): with ( mock.patch("text_injector.shutil.which", return_value=None), - mock.patch("text_injector.pyperclip.copy"), + mock.patch("text_injector.pyperclip.copy") as copy, mock.patch.object(injector, "_get_active_window_info", return_value=None), - mock.patch.object(injector, "_save_clipboard", return_value=b"old clipboard"), + mock.patch.object(injector, "_save_clipboard", return_value=b"old clipboard") as save_clipboard, mock.patch.object(injector, "_send_paste_keys_slow", return_value=True) as paste_chord, mock.patch.object(injector, "_type_text_ydotool", return_value=True) as direct_type, mock.patch.object(injector, "_restore_clipboard") as restore_clipboard, - mock.patch.object(injector, "_send_enter_if_auto_submit"), + mock.patch.object(injector, "_send_enter_if_auto_submit") as auto_submit, mock.patch.dict( "text_injector.os.environ", { @@ -53,11 +56,43 @@ def test_gnome_wayland_uses_ydotool_type_instead_of_paste_chord(self): paste_chord.assert_not_called() direct_type.assert_called_once_with("hello") - restore_clipboard.assert_called_once_with( - b"old clipboard", - injected=b"hello", - delay=0.5, - ) + auto_submit.assert_called_once_with() + save_clipboard.assert_not_called() + copy.assert_not_called() + restore_clipboard.assert_not_called() + + def test_gnome_wayland_with_custom_paste_keycode_keeps_clipboard_fallback(self): + injector = self._injector() + injector.config_manager = ConfigStub({"paste_keycode": 54}) + + with ( + mock.patch("text_injector.shutil.which", return_value=None), + mock.patch("text_injector.pyperclip.copy") as copy, + mock.patch.object(injector, "_get_active_window_info", return_value=None), + mock.patch.object(injector, "_save_clipboard", return_value=b"old clipboard"), + mock.patch.object(injector, "_send_paste_keys_slow", return_value=True) as paste_chord, + mock.patch.object(injector, "_type_text_ydotool", return_value=True) as direct_type, + mock.patch.object(injector, "_restore_clipboard") as restore_clipboard, + mock.patch.object(injector, "_send_enter_if_auto_submit") as auto_submit, + mock.patch.dict( + "text_injector.os.environ", + { + "XDG_SESSION_TYPE": "wayland", + "XDG_CURRENT_DESKTOP": "GNOME", + "XDG_SESSION_DESKTOP": "gnome", + "DESKTOP_SESSION": "gnome", + "WAYLAND_DISPLAY": "wayland-0", + }, + clear=True, + ), + ): + self.assertFalse(injector._inject_via_clipboard_and_hotkey("hello")) + + copy.assert_called_once_with("hello") + paste_chord.assert_not_called() + direct_type.assert_not_called() + restore_clipboard.assert_not_called() + auto_submit.assert_not_called() def test_non_gnome_leaves_clipboard_on_failed_paste_chord(self): injector = self._injector()