fix(injection): release ydotool Ctrl after a timed-out paste (#675)

subprocess.run(..., timeout=3) SIGKILLs the ydotool client. ydotoold
keeps any keys it already applied, so virtual Left Ctrl can stay held
until logout. Send a best-effort V-up/Ctrl-up (or a legacy ctrl tap)
after paste failure.

Fixes #658
This commit is contained in:
Jatin K Malik
2026-09-07 20:47:07 -07:00
committed by GitHub
parent 2c6acbc15e
commit f90e4d5765
2 changed files with 212 additions and 3 deletions
+65 -3
View File
@@ -1433,20 +1433,25 @@ class TextInjector:
# and the evdev code that produces Latin 'v' on the active layout —
# KEY_V=47 only on QWERTY). Passing 1.x codes to 0.1.x does not
# paste; it types garbage (e.g. "2442").
paste_cmd: list = []
try:
use_terminal_paste = self._should_use_terminal_paste()
cmd = self._clipboard_paste_command(terminal=use_terminal_paste)
paste_cmd = self._clipboard_paste_command(terminal=use_terminal_paste)
logger.debug(
"Simulating %s paste with: %s",
"terminal" if use_terminal_paste else "standard",
cmd,
paste_cmd,
)
subprocess.run(
cmd, check=True, stderr=subprocess.PIPE, text=True, timeout=3, env=host_env()
paste_cmd, check=True, stderr=subprocess.PIPE, text=True, timeout=3, env=host_env()
)
logger.info(f"Text injected via clipboard paste: '{text[:20]}...' ({len(text)} chars)")
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
logger.warning(f"Paste simulation failed: {e}")
# timeout=3 SIGKILLs the ydotool client. ydotoold keeps any keys it
# already applied, so a virtual Ctrl can stay held until logout (#658).
if paste_cmd:
self._ydotool_release_paste_keys(paste_cmd)
with self._state_lock:
if generation == self._clipboard_restore_generation:
self._clipboard_restore_target = None
@@ -1745,6 +1750,63 @@ class TextInjector:
setattr(self, cache_attr, cmd)
return list(cmd)
def _ydotool_paste_release_command(self, paste_cmd: list) -> list:
"""Return argv that lifts keys held by a ydotool paste chord.
wtype chords are ignored: they do not go through ydotoold, so a killed
client cannot leave a virtual modifier down.
"""
if not paste_cmd or paste_cmd[0] != "ydotool":
return []
pressed: list = []
for token in paste_cmd:
if isinstance(token, str) and token.endswith(":1"):
code = token[:-2]
if code:
pressed.append(code)
if pressed:
# Reverse press order so the letter lifts before Shift/Ctrl.
return ["ydotool", "key"] + [f"{code}:0" for code in reversed(pressed)]
token = ""
for part in reversed(paste_cmd):
if isinstance(part, str) and part not in ("ydotool", "key"):
token = part.lower()
break
has_ctrl = "ctrl" in token
has_shift = "shift" in token
if has_ctrl and has_shift:
# 0.1.x has no keycode:value form. A named tap is down+up; if the
# modifier is already down, the up half should clear it.
return ["ydotool", "key", "ctrl+shift"]
if has_shift:
return ["ydotool", "key", "shift"]
if has_ctrl:
return ["ydotool", "key", "ctrl"]
return []
def _ydotool_release_paste_keys(self, paste_cmd: list) -> None:
"""Best-effort Ctrl/Shift/letter up after a failed ydotool paste (#658).
Releasing an already-up key is a no-op. If ydotoold is still wedged the
follow-up may also time out; we swallow that so paste failure stays False.
"""
release = self._ydotool_paste_release_command(paste_cmd)
if not release:
return
try:
subprocess.run(
release,
check=False,
stderr=subprocess.PIPE,
text=True,
timeout=1,
env=host_env(),
)
except (OSError, subprocess.TimeoutExpired, subprocess.CalledProcessError):
logger.debug("ydotool paste-key release did not complete")
# evdev keycodes for modifier keys. If any of these is still physically held
# when a Wayland injection fires, the injected keystrokes are modified: a
# held Alt turns the Ctrl+V paste into Ctrl+Alt+V (nothing pastes), and a