fix: restore clipboard after ydotool clipboard-paste injection (#588)

* fix: restore clipboard after ydotool clipboard-paste injection

When ydotool is used on Wayland to inject non-ASCII text (Arabic,
accented letters, CJK, etc.), the injection path copies the text to
the clipboard and simulates Ctrl+V. This permanently overwrites whatever
the user had on their clipboard, which is surprising and disruptive —
especially for users dictating in languages that always trigger this path.

This commit adds two changes:

1. `_read_clipboard()` — reads the current clipboard content using the
   first available tool (wl-paste, xclip, xsel), returning None if the
   clipboard is empty or unreadable.

2. `_inject_via_clipboard_paste()` now saves the clipboard before
   overwriting it and schedules a background thread to restore it 300 ms
   after the Ctrl+V paste completes. The delay gives the target application
   time to process the paste before the clipboard content changes.

The previous note "there is no safe race-free way to restore" was
accurate in the strict sense, but a best-effort restore after a short
delay is far better than never restoring, and covers all practical use
cases (the user is not expected to trigger another paste within 300 ms of
a dictation commit).

Fixes the UX issue where every dictation commit silently destroyed the
user's clipboard content.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* tests: add unit tests for clipboard save/restore (PR #588)

15 tests covering _read_clipboard() (tool fallback chain, error handling,
UTF-8 decoding) and the restore behaviour in _inject_via_clipboard_paste
(content restored after delay, no restore on empty clipboard or paste
failure, daemon thread assertion).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qg9NpTr6QaeXEyEBc5fvvz

* style: apply ruff formatting to test_clipboard_restore.py

Fixes CI lint failure — ruff format requires line length ≤ 88 chars.
No logic changes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qg9NpTr6QaeXEyEBc5fvvz

* style: apply black formatting to fix CI lint check

ruff format and black disagree on some style choices; the CI runs black.
No logic changes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qg9NpTr6QaeXEyEBc5fvvz

* fix: use text=True in _read_clipboard and respect copy_to_clipboard setting

Two issues:

1. _read_clipboard() called subprocess.run without text=True, meaning
   stdout was bytes and .decode() was called. Existing tests that mock
   subprocess.run return stdout="" (str), causing AttributeError on
   .decode(). Switch to text=True + encoding/errors params so stdout is
   always a str and no .decode() is needed.

2. When the user has copy_to_clipboard=true, inject_text() copies the
   dictated text to clipboard after injection. The restore thread in
   _inject_via_clipboard_paste was firing 300ms later and overwriting
   that with the old content, silently breaking the setting. Skip the
   restore when _should_copy_to_clipboard() returns True.

Tests: adds test_no_restore_when_copy_to_clipboard_setting_enabled,
updates all _read_clipboard mock stdout to str (was bytes).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qg9NpTr6QaeXEyEBc5fvvz

* fix: Python 3.9 compat and cover restore-failure branch

Use Optional[str] instead of str | None for _read_clipboard return type
so the code runs on Python 3.9 (PEP 604 union syntax requires 3.10+).

Add test_restore_failure_is_handled_gracefully to exercise the else-branch
inside the _restore() closure, covering the two previously uncovered lines
reported by Codecov.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qg9NpTr6QaeXEyEBc5fvvz

* fix: restore clipboard on paste failure and handle empty clipboard

Fix 1 (comment #1): when Ctrl+V simulation fails the clipboard was already
overwritten with dictated text but the original content was silently lost.
Now restore it immediately in the except block before returning False.

Fix 3 (comment #3): wl-paste and xclip signal an empty clipboard with a
non-zero exit code, causing _read_clipboard() to return None and skip the
restore — leaving dictated text in the clipboard forever. Detect the
"Nothing is copied" / "target STRING not available" stderr patterns and
return "" so the restore path can clear the clipboard back to empty.

Tests added for both behaviours; existing test updated to assert the
correct post-fix behaviour for paste failure.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qg9NpTr6QaeXEyEBc5fvvz

* test: cover paste-failure branch when clipboard is unreadable

Add test for the case where Ctrl+V fails and previous_clipboard is None
(clipboard was unreadable), ensuring the restore is correctly skipped.
Closes the 1298->1300 branch gap reported by Codecov.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qg9NpTr6QaeXEyEBc5fvvz

* test: cover wl-paste fallback on X11 (XWayland) path

Add a test for the `not host_is_wayland and shutil.which("wl-paste")`
branch in _read_clipboard(), which was showing as yellow/red on Codecov.
Simulates an X11 session with wl-paste available (XWayland environments).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qg9NpTr6QaeXEyEBc5fvvz

* fix: proper clipboard clear and race guard on restore

Fix 5 (comment #5): add _clear_clipboard() that uses each backend's
native clear command (wl-copy --clear, xsel --clear, xclip with empty
input) instead of passing "" to _copy_to_clipboard(). Called when
previous_clipboard is "" to truly clear rather than set an empty offer.

Fix 6 (comment #6): before restoring, read the clipboard inside the
_restore() thread and skip if it no longer holds the injected text —
protecting against the user manually copying something else during the
300ms window.

Tests added for _clear_clipboard() per backend and for both new
restore-path behaviours. Existing restore tests updated to use
side_effect lists since _read_clipboard() is now called twice.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qg9NpTr6QaeXEyEBc5fvvz

* test: cover remaining _clear_clipboard() branches for Codecov

Add tests for:
- xclip path in _clear_clipboard() (line 931)
- health-check skip (line 911)
- exception fallthrough to next tool (line 947)
- _clear_clipboard() called immediately when paste fails with empty
  previous clipboard (line 1354)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qg9NpTr6QaeXEyEBc5fvvz

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
This commit is contained in:
Mohamed Hashim
2026-08-05 22:08:13 -07:00
committed by GitHub
co-authored by Claude Sonnet 4.6 Jatin K Malik
parent 67dde781f5
commit a15baa8170
2 changed files with 854 additions and 5 deletions
+139 -5
View File
@@ -895,6 +895,58 @@ class TextInjector:
)
return False
def _clear_clipboard(self) -> bool:
"""
Clear the clipboard using the first available tool.
Each backend has its own clear command:
- wl-copy: ``--clear`` flag removes the selection entirely
- xsel: ``--clear`` flag
- xclip: pipe empty input (creates an empty text offer)
Returns True if the clipboard was cleared successfully.
"""
for tool in self._get_clipboard_tools():
if self._clipboard_tool_health.get(tool) is False:
continue
try:
if tool == "wl-copy":
subprocess.run(
["wl-copy", "--clear"],
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=self._clipboard_timeout,
)
return True
if tool == "xsel":
subprocess.run(
["xsel", "--clipboard", "--clear"],
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=self._clipboard_timeout,
)
return True
if tool == "xclip":
subprocess.run(
["xclip", "-selection", "clipboard"],
input="",
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
text=True,
timeout=self._clipboard_timeout,
)
return True
except (
subprocess.CalledProcessError,
subprocess.TimeoutExpired,
FileNotFoundError,
):
continue
return False
def _should_copy_to_clipboard(self) -> bool:
"""Check if copy-to-clipboard setting is enabled."""
try:
@@ -1204,6 +1256,56 @@ class TextInjector:
except UnicodeEncodeError:
return True
def _read_clipboard(self) -> Optional[str]:
"""
Read the current clipboard content.
Returns the clipboard text, "" if the clipboard is verifiably empty,
or None if it could not be read (non-text data or no tool available).
"""
host_is_wayland = (
self._session_environment == DesktopEnvironment.WAYLAND
or os.environ.get("XDG_SESSION_TYPE", "").lower() == "wayland"
or bool(os.environ.get("WAYLAND_DISPLAY"))
)
candidates: list[list[str]] = []
if host_is_wayland and shutil.which("wl-paste"):
candidates.append(["wl-paste", "--no-newline"])
if shutil.which("xclip"):
candidates.append(["xclip", "-selection", "clipboard", "-o"])
if shutil.which("xsel"):
candidates.append(["xsel", "--clipboard", "--output"])
if not host_is_wayland and shutil.which("wl-paste"):
candidates.append(["wl-paste", "--no-newline"])
for cmd in candidates:
try:
result = subprocess.run(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=1.0,
text=True,
encoding="utf-8",
errors="replace",
)
if result.returncode == 0:
return result.stdout
# wl-paste and xclip signal an empty clipboard with a non-zero
# exit code rather than empty stdout. Detect those messages so
# callers can distinguish "empty" (restore to empty) from
# "unreadable" (skip restore).
stderr_lower = result.stderr.lower()
if "nothing is copied" in stderr_lower or (
"target" in stderr_lower and "not available" in stderr_lower
):
return ""
except (subprocess.TimeoutExpired, OSError, UnicodeDecodeError):
continue
return None
def _inject_via_clipboard_paste(self, text: str) -> bool:
"""
Inject text by copying to clipboard and simulating Ctrl+V with ydotool.
@@ -1212,18 +1314,20 @@ class TextInjector:
characters (accented letters, CJK, etc.) because ydotool simulates evdev
key events which only cover US ASCII keycodes. See issue #362.
Note: this temporarily overwrites the user's clipboard. There is no
attempt to restore it afterward, as there is no safe race-free way to
do so on Wayland.
The previous clipboard content is saved before injection and restored
in a background thread after Ctrl+V completes, so the user's clipboard
is not permanently overwritten.
Returns:
True if successful, False otherwise
"""
logger.debug(
"Using clipboard-paste injection for non-ASCII text "
"(user clipboard will be temporarily overwritten)"
"(saving clipboard to restore after paste)"
)
previous_clipboard = self._read_clipboard()
if not self._copy_to_clipboard(text):
logger.warning("Could not copy text to clipboard for paste injection")
return False
@@ -1243,11 +1347,41 @@ class TextInjector:
timeout=3,
)
logger.info(f"Text injected via clipboard paste: '{text[:20]}...' ({len(text)} chars)")
return True
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
logger.warning(f"Paste simulation failed: {e}")
if previous_clipboard is not None and not self._should_copy_to_clipboard():
if previous_clipboard == "":
self._clear_clipboard()
else:
self._copy_to_clipboard(previous_clipboard)
return False
# Restore the previous clipboard content after a short delay so the
# Ctrl+V paste has time to land before the clipboard changes.
# Skip restore when the user has opted in to keeping dictated text in
# the clipboard (copy_to_clipboard setting) — restoring would undo that.
if previous_clipboard is not None and not self._should_copy_to_clipboard():
def _restore() -> None:
time.sleep(0.3)
# If the user copied something else during the delay, don't
# overwrite their new clipboard content with stale data.
if self._read_clipboard() != text:
logger.debug("Clipboard changed during restore delay; skipping restore")
return
if previous_clipboard == "":
success = self._clear_clipboard()
else:
success = self._copy_to_clipboard(previous_clipboard)
if success:
logger.debug("Clipboard restored to previous content")
else:
logger.debug("Could not restore previous clipboard content")
threading.Thread(target=_restore, daemon=True).start()
return True
# ydotool 1.x (Flatpak pins v1.0.4): KEY_LEFTCTRL=29, KEY_V=47 press/release.
_YDOTOOL_V1_CTRL_V = ["ydotool", "key", "29:1", "47:1", "47:0", "29:0"]
# ydotool 0.1.x (common distro packages): named key sequences.
+715
View File
@@ -0,0 +1,715 @@
"""Tests for _read_clipboard() and clipboard restore in _inject_via_clipboard_paste.
Covers the behaviour introduced in PR #588: before injecting text via the
ydotool clipboard-paste path, the existing clipboard content is saved and
restored ~300 ms after the Ctrl+V keystroke is sent.
"""
import os
import subprocess
import threading
import time
import unittest
from typing import Any, cast
from unittest.mock import MagicMock, patch
def _make_injector() -> Any:
from vocalinux.text_injection.text_injector import DesktopEnvironment, TextInjector
obj = cast(Any, TextInjector.__new__(TextInjector))
obj._ibus_injector = None
obj.environment = DesktopEnvironment.WAYLAND
obj._session_environment = DesktopEnvironment.WAYLAND
obj._ibus_ready = False
obj._ibus_init_failed = False
obj._ibus_init_thread = None
obj._state_lock = threading.Lock()
obj._clipboard_tool_health = {}
obj._clipboard_timeout = 0.35
return obj
class TestReadClipboard(unittest.TestCase):
"""Unit tests for the _read_clipboard() helper.
subprocess.run is called with text=True so stdout is always a str.
"""
def test_wl_paste_used_first_on_wayland(self):
"""On Wayland, wl-paste is the first candidate and its output is returned."""
obj = _make_injector()
with patch.dict(os.environ, {"WAYLAND_DISPLAY": "wayland-0"}):
with patch("vocalinux.text_injection.text_injector.shutil.which") as mock_which:
mock_which.side_effect = lambda cmd: (
"/usr/bin/wl-paste" if cmd == "wl-paste" else None
)
with patch("vocalinux.text_injection.text_injector.subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=0, stdout="copied text")
result = obj._read_clipboard()
self.assertEqual(result, "copied text")
def test_xclip_fallback_when_wl_paste_unavailable(self):
"""Falls back to xclip when wl-paste is not installed."""
obj = _make_injector()
with patch("vocalinux.text_injection.text_injector.shutil.which") as mock_which:
mock_which.side_effect = lambda cmd: ("/usr/bin/xclip" if cmd == "xclip" else None)
with patch("vocalinux.text_injection.text_injector.subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=0, stdout="xclip text")
result = obj._read_clipboard()
self.assertEqual(result, "xclip text")
def test_xsel_fallback_when_wl_paste_and_xclip_unavailable(self):
"""Falls back to xsel when wl-paste and xclip are both not installed."""
obj = _make_injector()
with patch("vocalinux.text_injection.text_injector.shutil.which") as mock_which:
mock_which.side_effect = lambda cmd: ("/usr/bin/xsel" if cmd == "xsel" else None)
with patch("vocalinux.text_injection.text_injector.subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=0, stdout="xsel text")
result = obj._read_clipboard()
self.assertEqual(result, "xsel text")
def test_returns_none_when_no_tool_installed(self):
"""Returns None when no clipboard read tool is available."""
obj = _make_injector()
with patch("vocalinux.text_injection.text_injector.shutil.which", return_value=None):
result = obj._read_clipboard()
self.assertIsNone(result)
def test_returns_none_when_tool_exits_nonzero_with_unknown_stderr(self):
"""Returns None when exit code is non-zero and stderr has no recognised empty-clipboard message."""
obj = _make_injector()
with patch.dict(os.environ, {"WAYLAND_DISPLAY": "wayland-0"}):
with patch(
"vocalinux.text_injection.text_injector.shutil.which",
return_value="/usr/bin/wl-paste",
):
with patch("vocalinux.text_injection.text_injector.subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="some error")
result = obj._read_clipboard()
self.assertIsNone(result)
def test_returns_none_and_does_not_raise_on_timeout(self):
"""Returns None without raising when the clipboard tool times out."""
obj = _make_injector()
with patch.dict(os.environ, {"WAYLAND_DISPLAY": "wayland-0"}):
with patch(
"vocalinux.text_injection.text_injector.shutil.which",
return_value="/usr/bin/wl-paste",
):
with patch(
"vocalinux.text_injection.text_injector.subprocess.run",
side_effect=subprocess.TimeoutExpired("wl-paste", 1.0),
):
result = obj._read_clipboard()
self.assertIsNone(result)
def test_returns_none_and_does_not_raise_on_oserror(self):
"""Returns None without raising when subprocess raises OSError."""
obj = _make_injector()
with patch.dict(os.environ, {"WAYLAND_DISPLAY": "wayland-0"}):
with patch(
"vocalinux.text_injection.text_injector.shutil.which",
return_value="/usr/bin/wl-paste",
):
with patch(
"vocalinux.text_injection.text_injector.subprocess.run",
side_effect=OSError("not found"),
):
result = obj._read_clipboard()
self.assertIsNone(result)
def test_tries_next_tool_after_failure(self):
"""When the first tool fails, the next candidate is tried."""
obj = _make_injector()
with patch("vocalinux.text_injection.text_injector.shutil.which") as mock_which:
mock_which.side_effect = lambda cmd: (
"/usr/bin/" + cmd if cmd in ("wl-paste", "xclip") else None
)
with patch("vocalinux.text_injection.text_injector.subprocess.run") as mock_run:
mock_run.side_effect = [
MagicMock(returncode=1, stdout=""), # wl-paste fails
MagicMock(returncode=0, stdout="via xclip"), # xclip succeeds
]
result = obj._read_clipboard()
self.assertEqual(result, "via xclip")
def test_returns_arabic_text(self):
"""Correctly returns multi-byte UTF-8 content such as Arabic text."""
obj = _make_injector()
arabic = "مرحبا بالعالم"
with patch.dict(os.environ, {"WAYLAND_DISPLAY": "wayland-0"}):
with patch(
"vocalinux.text_injection.text_injector.shutil.which",
return_value="/usr/bin/wl-paste",
):
with patch("vocalinux.text_injection.text_injector.subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=0, stdout=arabic)
result = obj._read_clipboard()
self.assertEqual(result, arabic)
def test_wl_paste_used_as_fallback_on_x11(self):
"""On X11, wl-paste is added as a last-resort candidate (XWayland environments)."""
from vocalinux.text_injection.text_injector import DesktopEnvironment
obj = _make_injector()
obj._session_environment = DesktopEnvironment.X11
with patch.dict(os.environ, {"XDG_SESSION_TYPE": "x11", "WAYLAND_DISPLAY": ""}):
with patch(
"vocalinux.text_injection.text_injector.shutil.which",
return_value="/usr/bin/wl-paste",
):
with patch("vocalinux.text_injection.text_injector.subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=0, stdout="x11 clipboard")
result = obj._read_clipboard()
self.assertEqual(result, "x11 clipboard")
class TestClipboardRestoreAfterInjection(unittest.TestCase):
"""Behavioural tests for the save/restore logic in _inject_via_clipboard_paste."""
def test_clipboard_restored_to_previous_content(self):
"""Previous clipboard content is written back ~300ms after injection."""
obj = _make_injector()
obj.wayland_tool = "ydotool"
copy_calls: list[str] = []
# First read: saves previous content. Second read (inside _restore): clipboard
# still holds injected text, confirming the user hasn't copied anything else.
with patch.object(
obj, "_read_clipboard", side_effect=["original clipboard", "injected text"]
):
with patch.object(
obj, "_copy_to_clipboard", side_effect=lambda t: copy_calls.append(t) or True
):
with patch.object(
obj,
"_ydotool_ctrl_v_command",
return_value=["ydotool", "key", "ctrl+v"],
):
with patch.object(obj, "_should_copy_to_clipboard", return_value=False):
with patch(
"vocalinux.text_injection.text_injector.subprocess.run",
return_value=MagicMock(returncode=0),
):
result = obj._inject_via_clipboard_paste("injected text")
time.sleep(0.5) # wait for the 300ms restore thread
self.assertTrue(result)
self.assertEqual(copy_calls[0], "injected text")
self.assertEqual(copy_calls[-1], "original clipboard")
def test_read_clipboard_is_called_before_overwriting(self):
"""_read_clipboard must be called first, before _copy_to_clipboard."""
obj = _make_injector()
obj.wayland_tool = "ydotool"
call_order: list[str] = []
with patch.object(
obj,
"_read_clipboard",
side_effect=lambda: call_order.append("read") or "prev",
):
with patch.object(
obj,
"_copy_to_clipboard",
side_effect=lambda t: call_order.append(f"copy:{t}") or True,
):
with patch.object(
obj,
"_ydotool_ctrl_v_command",
return_value=["ydotool", "key", "ctrl+v"],
):
with patch.object(obj, "_should_copy_to_clipboard", return_value=False):
with patch(
"vocalinux.text_injection.text_injector.subprocess.run",
return_value=MagicMock(returncode=0),
):
obj._inject_via_clipboard_paste("text")
time.sleep(0.5)
self.assertEqual(call_order[0], "read", "read must happen before any copy")
self.assertIn("copy:text", call_order)
def test_no_restore_when_clipboard_was_empty(self):
"""No restore is attempted when _read_clipboard() returns None (clipboard empty)."""
obj = _make_injector()
obj.wayland_tool = "ydotool"
copy_calls: list[str] = []
with patch.object(obj, "_read_clipboard", return_value=None):
with patch.object(
obj,
"_copy_to_clipboard",
side_effect=lambda t: copy_calls.append(t) or True,
):
with patch.object(
obj,
"_ydotool_ctrl_v_command",
return_value=["ydotool", "key", "ctrl+v"],
):
with patch.object(obj, "_should_copy_to_clipboard", return_value=False):
with patch(
"vocalinux.text_injection.text_injector.subprocess.run",
return_value=MagicMock(returncode=0),
):
result = obj._inject_via_clipboard_paste("text")
time.sleep(0.5)
self.assertTrue(result)
# Only one call: the injection. No second call for restore.
self.assertEqual(copy_calls, ["text"])
def test_no_restore_when_copy_to_clipboard_setting_enabled(self):
"""No restore when the user has opted-in to keeping dictated text in clipboard.
When copy_to_clipboard=true, the user explicitly wants the dictated text
to stay in the clipboard after injection. Restoring the old content would
silently undo that preference.
"""
obj = _make_injector()
obj.wayland_tool = "ydotool"
copy_calls: list[str] = []
with patch.object(obj, "_read_clipboard", return_value="old clipboard"):
with patch.object(
obj,
"_copy_to_clipboard",
side_effect=lambda t: copy_calls.append(t) or True,
):
with patch.object(
obj,
"_ydotool_ctrl_v_command",
return_value=["ydotool", "key", "ctrl+v"],
):
with patch.object(obj, "_should_copy_to_clipboard", return_value=True):
with patch(
"vocalinux.text_injection.text_injector.subprocess.run",
return_value=MagicMock(returncode=0),
):
result = obj._inject_via_clipboard_paste("dictated text")
time.sleep(0.5)
self.assertTrue(result)
# Only one copy call — the injection. The old content must NOT be restored.
self.assertEqual(copy_calls, ["dictated text"])
def test_clipboard_restored_when_ctrl_v_fails(self):
"""Clipboard is restored immediately when Ctrl+V fails, not silently lost."""
obj = _make_injector()
obj.wayland_tool = "ydotool"
copy_calls: list[str] = []
with patch.object(obj, "_read_clipboard", return_value="prev"):
with patch.object(
obj,
"_copy_to_clipboard",
side_effect=lambda t: copy_calls.append(t) or True,
):
with patch.object(
obj,
"_ydotool_ctrl_v_command",
return_value=["ydotool", "key", "ctrl+v"],
):
with patch.object(obj, "_should_copy_to_clipboard", return_value=False):
with patch(
"vocalinux.text_injection.text_injector.subprocess.run",
side_effect=subprocess.CalledProcessError(1, ["ydotool", "key"]),
):
result = obj._inject_via_clipboard_paste("text")
time.sleep(0.5)
self.assertFalse(result)
# First copy: injection; second copy: immediate restore after paste failure
self.assertEqual(copy_calls, ["text", "prev"])
def test_restore_thread_is_a_daemon(self):
"""The restore thread is a daemon so it never blocks interpreter shutdown."""
obj = _make_injector()
obj.wayland_tool = "ydotool"
started: list[threading.Thread] = []
original_start = threading.Thread.start
def capture_start(self_t: threading.Thread) -> None:
started.append(self_t)
original_start(self_t)
with patch.object(obj, "_read_clipboard", return_value="prev"):
with patch.object(obj, "_copy_to_clipboard", return_value=True):
with patch.object(
obj,
"_ydotool_ctrl_v_command",
return_value=["ydotool", "key", "ctrl+v"],
):
with patch.object(obj, "_should_copy_to_clipboard", return_value=False):
with patch(
"vocalinux.text_injection.text_injector.subprocess.run",
return_value=MagicMock(returncode=0),
):
with patch.object(threading.Thread, "start", capture_start):
obj._inject_via_clipboard_paste("text")
self.assertEqual(len(started), 1, "exactly one restore thread should start")
self.assertTrue(started[0].daemon, "restore thread must be a daemon")
def test_arabic_content_is_preserved(self):
"""Clipboard content containing Arabic text is correctly restored."""
obj = _make_injector()
obj.wayland_tool = "ydotool"
copy_calls: list[str] = []
arabic_previous = "نص عربي سابق"
with patch.object(obj, "_read_clipboard", side_effect=[arabic_previous, "مرحبا"]):
with patch.object(
obj,
"_copy_to_clipboard",
side_effect=lambda t: copy_calls.append(t) or True,
):
with patch.object(
obj,
"_ydotool_ctrl_v_command",
return_value=["ydotool", "key", "ctrl+v"],
):
with patch.object(obj, "_should_copy_to_clipboard", return_value=False):
with patch(
"vocalinux.text_injection.text_injector.subprocess.run",
return_value=MagicMock(returncode=0),
):
obj._inject_via_clipboard_paste("مرحبا")
time.sleep(0.5)
self.assertEqual(copy_calls[-1], arabic_previous)
def test_restore_failure_is_handled_gracefully(self):
"""No exception when _copy_to_clipboard returns False during restore."""
obj = _make_injector()
obj.wayland_tool = "ydotool"
copy_calls: list[str] = []
def fake_copy(t: str) -> bool:
copy_calls.append(t)
# injection copy succeeds; restore copy fails
return t != "previous_content"
with patch.object(
obj, "_read_clipboard", side_effect=["previous_content", "injected text"]
):
with patch.object(obj, "_copy_to_clipboard", side_effect=fake_copy):
with patch.object(
obj,
"_ydotool_ctrl_v_command",
return_value=["ydotool", "key", "ctrl+v"],
):
with patch.object(obj, "_should_copy_to_clipboard", return_value=False):
with patch(
"vocalinux.text_injection.text_injector.subprocess.run",
return_value=MagicMock(returncode=0),
):
result = obj._inject_via_clipboard_paste("injected text")
time.sleep(0.5)
self.assertTrue(result)
# The restore attempt was made but failed — no exception raised
self.assertIn("previous_content", copy_calls)
def test_clipboard_restored_immediately_when_paste_fails(self):
"""Original clipboard is restored right away when Ctrl+V fails, not after 300ms."""
obj = _make_injector()
obj.wayland_tool = "ydotool"
copy_calls: list[str] = []
with patch.object(obj, "_read_clipboard", return_value="original content"):
with patch.object(
obj,
"_copy_to_clipboard",
side_effect=lambda t: copy_calls.append(t) or True,
):
with patch.object(
obj,
"_ydotool_ctrl_v_command",
return_value=["ydotool", "key", "ctrl+v"],
):
with patch.object(obj, "_should_copy_to_clipboard", return_value=False):
with patch(
"vocalinux.text_injection.text_injector.subprocess.run",
side_effect=subprocess.CalledProcessError(1, ["ydotool", "key"]),
):
result = obj._inject_via_clipboard_paste("injected text")
self.assertFalse(result)
# First copy: injected text; second copy: immediate restore after failure
self.assertEqual(copy_calls, ["injected text", "original content"])
def test_no_restore_when_paste_fails_and_clipboard_was_unreadable(self):
"""No restore attempted when Ctrl+V fails and clipboard could not be read."""
obj = _make_injector()
obj.wayland_tool = "ydotool"
copy_calls: list[str] = []
with patch.object(obj, "_read_clipboard", return_value=None):
with patch.object(
obj,
"_copy_to_clipboard",
side_effect=lambda t: copy_calls.append(t) or True,
):
with patch.object(
obj,
"_ydotool_ctrl_v_command",
return_value=["ydotool", "key", "ctrl+v"],
):
with patch.object(obj, "_should_copy_to_clipboard", return_value=False):
with patch(
"vocalinux.text_injection.text_injector.subprocess.run",
side_effect=subprocess.CalledProcessError(1, ["ydotool", "key"]),
):
result = obj._inject_via_clipboard_paste("text")
self.assertFalse(result)
# Only injection copy — no restore since clipboard was unreadable
self.assertEqual(copy_calls, ["text"])
def test_empty_clipboard_is_cleared_after_injection(self):
"""When clipboard was empty, _clear_clipboard() is called instead of copy("")."""
obj = _make_injector()
obj.wayland_tool = "ydotool"
clear_called: list[bool] = []
with patch.object(obj, "_read_clipboard", side_effect=["", "new text"]):
with patch.object(obj, "_copy_to_clipboard", return_value=True):
with patch.object(
obj, "_clear_clipboard", side_effect=lambda: clear_called.append(True) or True
):
with patch.object(
obj,
"_ydotool_ctrl_v_command",
return_value=["ydotool", "key", "ctrl+v"],
):
with patch.object(obj, "_should_copy_to_clipboard", return_value=False):
with patch(
"vocalinux.text_injection.text_injector.subprocess.run",
return_value=MagicMock(returncode=0),
):
result = obj._inject_via_clipboard_paste("new text")
time.sleep(0.5)
self.assertTrue(result)
self.assertTrue(
clear_called, "_clear_clipboard() must be called for empty previous content"
)
def test_restore_skipped_when_clipboard_changed_during_delay(self):
"""Restore is skipped if the user copied something else during the 300ms window."""
obj = _make_injector()
obj.wayland_tool = "ydotool"
copy_calls: list[str] = []
# First read: previous content. Second read (inside _restore): user copied "new thing"
with patch.object(obj, "_read_clipboard", side_effect=["original", "new thing"]):
with patch.object(
obj,
"_copy_to_clipboard",
side_effect=lambda t: copy_calls.append(t) or True,
):
with patch.object(
obj,
"_ydotool_ctrl_v_command",
return_value=["ydotool", "key", "ctrl+v"],
):
with patch.object(obj, "_should_copy_to_clipboard", return_value=False):
with patch(
"vocalinux.text_injection.text_injector.subprocess.run",
return_value=MagicMock(returncode=0),
):
result = obj._inject_via_clipboard_paste("injected text")
time.sleep(0.5)
self.assertTrue(result)
# Only the injection copy — restore was skipped because clipboard changed
self.assertEqual(copy_calls, ["injected text"])
def test_restore_proceeds_when_clipboard_unchanged(self):
"""Restore fires when clipboard still holds the injected text after 300ms."""
obj = _make_injector()
obj.wayland_tool = "ydotool"
copy_calls: list[str] = []
# First read: previous content. Second read (inside _restore): still the injected text
with patch.object(obj, "_read_clipboard", side_effect=["original", "injected text"]):
with patch.object(
obj,
"_copy_to_clipboard",
side_effect=lambda t: copy_calls.append(t) or True,
):
with patch.object(
obj,
"_ydotool_ctrl_v_command",
return_value=["ydotool", "key", "ctrl+v"],
):
with patch.object(obj, "_should_copy_to_clipboard", return_value=False):
with patch(
"vocalinux.text_injection.text_injector.subprocess.run",
return_value=MagicMock(returncode=0),
):
result = obj._inject_via_clipboard_paste("injected text")
time.sleep(0.5)
self.assertTrue(result)
self.assertEqual(copy_calls, ["injected text", "original"])
class TestClearClipboard(unittest.TestCase):
"""Unit tests for the _clear_clipboard() helper."""
def test_wl_copy_clear_flag_used(self):
"""Uses 'wl-copy --clear' on Wayland."""
obj = _make_injector()
with patch(
"vocalinux.text_injection.text_injector.shutil.which", return_value="/usr/bin/wl-copy"
):
with patch("vocalinux.text_injection.text_injector.subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=0)
result = obj._clear_clipboard()
self.assertTrue(result)
cmd = mock_run.call_args[0][0]
self.assertEqual(cmd, ["wl-copy", "--clear"])
def test_xsel_clear_flag_used(self):
"""Uses 'xsel --clipboard --clear' when xsel is available."""
obj = _make_injector()
obj._session_environment = None
with patch(
"vocalinux.text_injection.text_injector.shutil.which",
side_effect=lambda cmd: "/usr/bin/xsel" if cmd == "xsel" else None,
):
with patch("vocalinux.text_injection.text_injector.subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=0)
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "x11", "WAYLAND_DISPLAY": ""}):
result = obj._clear_clipboard()
self.assertTrue(result)
cmd = mock_run.call_args[0][0]
self.assertEqual(cmd, ["xsel", "--clipboard", "--clear"])
def test_xclip_empty_input_used(self):
"""Uses xclip with empty input when xclip is the only available tool."""
obj = _make_injector()
obj._session_environment = None
with patch(
"vocalinux.text_injection.text_injector.shutil.which",
side_effect=lambda cmd: "/usr/bin/xclip" if cmd == "xclip" else None,
):
with patch("vocalinux.text_injection.text_injector.subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=0)
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "x11", "WAYLAND_DISPLAY": ""}):
result = obj._clear_clipboard()
self.assertTrue(result)
call_kwargs = mock_run.call_args
self.assertEqual(call_kwargs[0][0], ["xclip", "-selection", "clipboard"])
def test_skips_unhealthy_tool_and_tries_next(self):
"""Skips a tool marked unhealthy; returns False when it was the only tool."""
obj = _make_injector()
obj._clipboard_tool_health["wl-copy"] = False
with patch(
"vocalinux.text_injection.text_injector.shutil.which",
side_effect=lambda cmd: "/usr/bin/wl-copy" if cmd == "wl-copy" else None,
):
with patch("vocalinux.text_injection.text_injector.subprocess.run") as mock_run:
result = obj._clear_clipboard()
self.assertFalse(result)
mock_run.assert_not_called()
def test_exception_falls_through_to_next_tool(self):
"""When one tool throws, the next candidate is tried."""
obj = _make_injector()
obj._session_environment = None
with patch(
"vocalinux.text_injection.text_injector.shutil.which",
side_effect=lambda cmd: "/usr/bin/" + cmd if cmd in ("xclip", "xsel") else None,
):
with patch("vocalinux.text_injection.text_injector.subprocess.run") as mock_run:
mock_run.side_effect = [
subprocess.CalledProcessError(1, ["xclip"]), # xclip fails
MagicMock(returncode=0), # xsel succeeds
]
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "x11", "WAYLAND_DISPLAY": ""}):
result = obj._clear_clipboard()
self.assertTrue(result)
def test_clear_called_when_paste_fails_and_clipboard_was_empty(self):
"""_clear_clipboard() is called immediately when paste fails and clipboard was empty."""
obj = _make_injector()
obj.wayland_tool = "ydotool"
clear_called: list[bool] = []
with patch.object(obj, "_read_clipboard", return_value=""):
with patch.object(obj, "_copy_to_clipboard", return_value=True):
with patch.object(
obj, "_clear_clipboard", side_effect=lambda: clear_called.append(True) or True
):
with patch.object(
obj,
"_ydotool_ctrl_v_command",
return_value=["ydotool", "key", "ctrl+v"],
):
with patch.object(obj, "_should_copy_to_clipboard", return_value=False):
with patch(
"vocalinux.text_injection.text_injector.subprocess.run",
side_effect=subprocess.CalledProcessError(1, ["ydotool", "key"]),
):
result = obj._inject_via_clipboard_paste("text")
self.assertFalse(result)
self.assertTrue(
clear_called,
"_clear_clipboard() must be called when paste fails with empty previous clipboard",
)
def test_returns_false_when_no_tool_available(self):
"""Returns False when no clipboard tool is installed."""
obj = _make_injector()
with patch("vocalinux.text_injection.text_injector.shutil.which", return_value=None):
result = obj._clear_clipboard()
self.assertFalse(result)
class TestReadClipboardEmptyDetection(unittest.TestCase):
"""Tests for _read_clipboard() detecting empty clipboard via stderr patterns."""
def test_wl_paste_nothing_is_copied_returns_empty_string(self):
"""Returns '' when wl-paste stderr says 'Nothing is copied'."""
obj = _make_injector()
with patch("shutil.which", return_value="/usr/bin/wl-paste"):
with patch(
"vocalinux.text_injection.text_injector.subprocess.run",
return_value=MagicMock(returncode=1, stdout="", stderr="Nothing is copied"),
):
result = obj._read_clipboard()
self.assertEqual(result, "")
def test_xclip_target_not_available_returns_empty_string(self):
"""Returns '' when xclip stderr says target STRING not available."""
obj = _make_injector()
obj._session_environment = None # non-Wayland so xclip is tried
with patch(
"shutil.which", side_effect=lambda cmd: "/usr/bin/xclip" if cmd == "xclip" else None
):
with patch(
"vocalinux.text_injection.text_injector.subprocess.run",
return_value=MagicMock(
returncode=1, stdout="", stderr="Error: target STRING not available"
),
):
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "x11", "WAYLAND_DISPLAY": ""}):
result = obj._read_clipboard()
self.assertEqual(result, "")
def test_non_empty_clipboard_error_still_returns_none(self):
"""A non-zero exit code with unknown stderr does not trigger empty detection."""
obj = _make_injector()
with patch("shutil.which", return_value=None):
result = obj._read_clipboard()
self.assertIsNone(result)
if __name__ == "__main__":
unittest.main()