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 <jatinkrmalik@gmail.com>
This commit is contained in:
devin-ai-integration[bot]
2026-10-07 18:51:49 -07:00
committed by GitHub
co-authored by Jatin K Malik
parent d021e64813
commit 295386f08b
3 changed files with 76 additions and 8 deletions
+11 -4
View File
@@ -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(
+1 -1
View File
@@ -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")
+64 -3
View File
@@ -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"}):