From 295386f08b7bbdda5714b6c95fa8d6c3dd29576f Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 7 Oct 2026 18:51:49 -0700 Subject: [PATCH] fix(text-injection): end option parsing before typed text (#922) * fix(text-injection): end option parsing before typed text A 20-char xdotool chunk, or a 200-char wtype/ydotool chunk, that starts with '-' (a word hyphenated across the chunk boundary) is parsed as a flag: 'xdotool type --clearmodifiers -stickation' exits 1 with 'type: unrecognized option' and the rest of the dictation is dropped. Pass '--' before the text argument in the xdotool, wtype, and ydotool type invocations so the remaining argument is always typed literally. Fixes #921 * test(portal-fallback): expect the wtype option terminator The partial-type fallback assertion predates the -- terminator and still expected the bare ['wtype', 'llo'] argv. Refs #921 --------- Co-authored-by: Jatin K Malik --- src/vocalinux/text_injection/text_injector.py | 15 +++-- tests/test_remote_desktop_portal.py | 2 +- tests/test_text_injector.py | 67 ++++++++++++++++++- 3 files changed, 76 insertions(+), 8 deletions(-) diff --git a/src/vocalinux/text_injection/text_injector.py b/src/vocalinux/text_injection/text_injector.py index b9ddc7d..a701f26 100644 --- a/src/vocalinux/text_injection/text_injector.py +++ b/src/vocalinux/text_injection/text_injector.py @@ -1861,8 +1861,12 @@ class TextInjector: chunk = text[i : i + chunk_size] chunk_num = (i // chunk_size) + 1 - # First try with clearmodifiers - cmd = ["xdotool", "type", "--clearmodifiers", chunk] + # First try with clearmodifiers. ``--`` ends option + # parsing: a chunk that starts with '-' (a word + # hyphenated across the chunk boundary) is typed + # literally instead of read as a flag and rejected + # (#921). + cmd = ["xdotool", "type", "--clearmodifiers", "--", chunk] logger.debug(f"Injecting chunk {chunk_num}/{total_chunks}: '{chunk}'") subprocess.run( @@ -2660,7 +2664,9 @@ class TextInjector: raise _InjectionAborted chunk = text[i : i + chunk_size] if self.wayland_tool == "wtype": - cmd = ["wtype", chunk] + # ``--`` puts wtype in raw-text mode so a leading '-' is not + # parsed as an option. + cmd = ["wtype", "--", chunk] # wtype types at compositor pace; the budget scales with the # chunk length so only a wedged process can ever hit it. type_timeout = max(5, len(chunk) * 0.05) @@ -2668,7 +2674,8 @@ class TextInjector: # Keep key-delay > 0 to avoid Shift-leak ("Can you" -> "CAN YOu"). # Low delay so fallback typing finishes quickly for long phrases. key_delay = os.environ.get("VOCALINUX_YDOTOOL_KEY_DELAY", "2") - cmd = ["ydotool", "type", "--key-delay", key_delay, chunk] + # ``--`` ends ydotool's option parsing for the same reason. + cmd = ["ydotool", "type", "--key-delay", key_delay, "--", chunk] type_timeout = max(5, len(chunk) * self._key_delay_seconds(key_delay) * 4) try: subprocess.run( diff --git a/tests/test_remote_desktop_portal.py b/tests/test_remote_desktop_portal.py index 0d4d682..a00368c 100644 --- a/tests/test_remote_desktop_portal.py +++ b/tests/test_remote_desktop_portal.py @@ -695,7 +695,7 @@ class TestPortalInjectionPaths: ): injector._inject_with_wayland_tool("hello") # The portal typed "he" before failing; the fallback gets only "llo". - assert mock_run.call_args[0][0] == ["wtype", "llo"] + assert mock_run.call_args[0][0] == ["wtype", "--", "llo"] def test_backspace_retries_only_remaining_taps(self, monkeypatch: pytest.MonkeyPatch) -> None: injector = _bare_injector(environment=DesktopEnvironment.WAYLAND, wayland_tool="portal") diff --git a/tests/test_text_injector.py b/tests/test_text_injector.py index 2a04cd5..baee340 100644 --- a/tests/test_text_injector.py +++ b/tests/test_text_injector.py @@ -271,7 +271,7 @@ class TestTextInjector(unittest.TestCase): # Verify wtype was called correctly self.mock_subprocess.assert_any_call( - ["wtype", "Hello world"], + ["wtype", "--", "Hello world"], check=True, stderr=subprocess.PIPE, text=True, @@ -299,7 +299,7 @@ class TestTextInjector(unittest.TestCase): # Clipboard paste fails (no wl-copy/xclip/xsel in this mock); type fallback. # Default key-delay is 2 (overridable via VOCALINUX_YDOTOOL_KEY_DELAY). self.mock_subprocess.assert_any_call( - ["ydotool", "type", "--key-delay", "2", "Hello world"], + ["ydotool", "type", "--key-delay", "2", "--", "Hello world"], check=True, stderr=subprocess.PIPE, text=True, @@ -820,7 +820,7 @@ class TestTextInjector(unittest.TestCase): calls = [c.args[0] for c in mock_run.call_args_list if c.args] self.assertTrue( - any(c[0] == "wtype" and c[1] == "café" for c in calls), + any(c[0] == "wtype" and c[-1] == "café" for c in calls), "wtype should inject text directly", ) self.assertFalse( @@ -1786,6 +1786,67 @@ class TestTextInjectorEdgeCases(unittest.TestCase): ) self.assertFalse(any("Escape" in command for command in commands)) + def test_inject_with_xdotool_terminates_options_before_the_text(self) -> None: + """A chunk starting with '-' must type literally, not parse as a flag (#921). + + Text is typed in 20-char chunks, so a word hyphenated across the + boundary produces a chunk like "-stickation". Without the end-of-options + marker `xdotool type` reads it as a flag, exits nonzero, and drops the + rest of the dictation. + """ + injector = TextInjector.__new__(TextInjector) + injector.environment = DesktopEnvironment.X11 + + injector._inject_with_xdotool("this is a hyphenated-stickation") + + type_calls = [ + call.args[0] + for call in self.mock_subprocess.call_args_list + if call.args and call.args[0][:2] == ["xdotool", "type"] + ] + self.assertEqual( + type_calls, + [ + ["xdotool", "type", "--clearmodifiers", "--", "this is a hyphenated"], + ["xdotool", "type", "--clearmodifiers", "--", "-stickation"], + ], + ) + + def test_inject_with_wtype_terminates_options_before_the_text(self) -> None: + """`wtype` gets the same marker: its raw-text mode types the rest (#921).""" + injector = TextInjector.__new__(TextInjector) + injector.wayland_tool = "wtype" + injector._wait_for_modifiers_released = lambda: None + + injector._inject_with_wayland_tool("-leading dash") + + type_calls = [ + call.args[0] + for call in self.mock_subprocess.call_args_list + if call.args and call.args[0][:1] == ["wtype"] + ] + self.assertEqual(type_calls, [["wtype", "--", "-leading dash"]]) + + def test_inject_with_ydotool_terminates_options_before_the_text(self) -> None: + """`ydotool type` stops option parsing before the chunk as well (#921).""" + injector = TextInjector.__new__(TextInjector) + injector.wayland_tool = "ydotool" + injector._wait_for_modifiers_released = lambda: None + injector._ensure_ydotoold = lambda: True + injector._inject_via_clipboard_paste = lambda text, **kwargs: False + + injector._inject_with_wayland_tool("-leading dash") + + type_calls = [ + call.args[0] + for call in self.mock_subprocess.call_args_list + if call.args and call.args[0][:2] == ["ydotool", "type"] + ] + self.assertEqual(len(type_calls), 1) + cmd = type_calls[0] + self.assertEqual(cmd[:3], ["ydotool", "type", "--key-delay"]) + self.assertEqual(cmd[-2:], ["--", "-leading dash"]) + def test_inject_with_wayland_tool_ydotool(self): """Test text injection with ydotool.""" with patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland"}):