fix(clipboard): keep paste-injection copies out of clipboard history 🤖🤖🤖 (#956)

* fix(clipboard): keep paste-injection copies out of clipboard history

The clipboard-paste injection path writes the dictated text to the
clipboard only to feed the paste chord, then restores the previous
content (#588, #646). Clipboard managers such as Klipper and cliphist
still record that transient copy, so every dictation lands in history.

Mark the injection copy with `wl-copy --sensitive`, which clipboard
managers honour by skipping the entry. The flag is probed once via
`wl-copy --help` (added in wl-clipboard 2.2) so older builds keep
working. It is only set when Copy to Clipboard is off: with that
setting on, the dictated text is meant to stay, so it remains an
ordinary copy. Restoring the user's previous content is never marked
sensitive. xclip/xsel have no equivalent and are unchanged.

* test(clipboard): address review on sensitive paste copies

- Move the unittest main guard below TestSensitivePasteCopy so running
  the file directly also runs the new tests.
- Add -> None return hints to the new test methods.
- Cover _copy_to_clipboard forwarding the sensitive hint when a tool
  declines and the next one is tried.
- Docs: history suppression needs wl-copy 2.2+ and a clipboard manager
  that honours the hint; older wl-copy and the XWayland xclip/xsel path
  still produce ordinary copies.

* fix(clipboard): type the wl-copy probe cache and adapt the X11 paste test

- Declare _wl_copy_sensitive_supported as a typed class default instead
  of reading it through getattr(), which mypy (now in CI since #968)
  flags as returning Any.
- test_plain_x11_clipboard_paste_uses_xdotool_and_x11_clipboard
  asserted the exact _copy_to_clipboard kwargs. Check the text and the
  X11 tools instead, so it does not depend on the sensitive hint (which
  xclip ignores) or on the copy_to_clipboard setting.

* test(clipboard): wait for the restore thread instead of a fixed sleep

TestSensitivePasteCopy slept 0.5 s and hoped the background restore had
run. On a slow runner the patches could end first. Wait on an event set
by the restore copy, inside the patches, with a generous timeout.
This commit is contained in:
Jakob Jensen
2026-10-26 20:25:40 -07:00
committed by GitHub
parent 2fce81e94d
commit aae1f4b474
4 changed files with 164 additions and 6 deletions
+2
View File
@@ -36,6 +36,8 @@ On non-US layouts such as German Neo, that chord uses the key that types **v** o
On X11, **Paste long dictations** opts in to clipboard-pasting dictations of 30 or more characters instead of typing them key by key. It is off by default. Vocalinux restores the previous clipboard contents after pasting unless **Copy to Clipboard** is enabled. If the clipboard contains an image or other non-text content, Vocalinux types the dictation and leaves that content alone.
The clipboard copy behind these pastes is temporary: your previous clipboard comes back right after. When the copy goes through `wl-copy` 2.2 or newer, it is also marked sensitive (`wl-copy --sensitive`), and clipboard managers that honor that hint, such as Klipper or cliphist, keep it out of their history. Older `wl-copy` builds and X11 or XWayland pastes (`xclip`/`xsel`) have no such hint, so there the dictated text can still show up in history. With **Settings → Dictation → Copy to Clipboard** on, the dictated text is an ordinary copy and stays in history.
**Other audio.** Settings → Audio → Other audio can lower speakers and headphones while the microphone is open, then put that volume back. It stays off until you turn it on. Level while dictating is a percent of the current volume (0 is silent). Quitting mid-dictation puts the volume back; if the app crashes first, the next launch does.
## Shortcuts
+50 -5
View File
@@ -321,6 +321,10 @@ class TextInjector:
# one (readers go through getattr because __new__ tests skip __init__).
wayland_tool: Optional[str]
# Whether wl-copy accepts --sensitive; None until first probed. A class
# default rather than __init__ state so __new__-built test injectors work.
_wl_copy_sensitive_supported: Optional[bool] = None
@staticmethod
def _accepted_backends_help() -> str:
"""The accepted pin values, for user-facing "expected ..." messages."""
@@ -1310,7 +1314,32 @@ class TextInjector:
"""xclip/xsel only: the X11 CLIPBOARD an xdotool paste actually reads."""
return [name for name in ("xclip", "xsel") if shutil.which(name)]
def _run_clipboard_command(self, tool: str, text: str) -> bool:
def _wl_copy_supports_sensitive(self) -> bool:
"""Return True if the installed wl-copy understands ``--sensitive``.
The flag arrived in wl-clipboard 2.2; older builds reject it and the copy
fails. Probe ``wl-copy --help`` once and cache the answer.
"""
if self._wl_copy_sensitive_supported is not None:
return self._wl_copy_sensitive_supported
supported = False
try:
result = subprocess.run(
["wl-copy", "--help"],
capture_output=True,
text=True,
timeout=2,
env=host_env(),
)
supported = "--sensitive" in f"{result.stdout or ''}{result.stderr or ''}"
except (OSError, subprocess.SubprocessError) as e:
logger.debug(f"Could not probe wl-copy for --sensitive: {e}")
self._wl_copy_sensitive_supported = supported
return supported
def _run_clipboard_command(self, tool: str, text: str, sensitive: bool = False) -> bool:
# NB: wl-copy/xclip/xsel fork a background process that keeps owning the
# selection in order to serve it. That child inherits our pipes, so
# capturing stderr via subprocess.PIPE makes run() block until the child
@@ -1318,8 +1347,14 @@ class TextInjector:
# — even though the copy itself succeeded. Redirect to DEVNULL so run()
# only waits for the short-lived foreground process.
if tool == "wl-copy":
# --sensitive tells clipboard managers (Klipper, cliphist, ...) not to
# keep the entry. xclip/xsel have no equivalent.
cmd = ["wl-copy"]
if sensitive and self._wl_copy_supports_sensitive():
cmd.append("--sensitive")
cmd.append(text)
subprocess.run(
["wl-copy", text],
cmd,
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
@@ -1494,7 +1529,9 @@ class TextInjector:
logger.debug("No better tools available, continuing with xdotool fallback")
return False
def _copy_to_clipboard(self, text: str, tools: Optional[list] = None) -> bool:
def _copy_to_clipboard(
self, text: str, tools: Optional[list] = None, sensitive: bool = False
) -> bool:
"""
Copy text to clipboard.
@@ -1507,6 +1544,9 @@ class TextInjector:
tools: Clipboard binaries to try. Defaults to ``_get_clipboard_tools``
(session clipboard). XWayland paste passes xclip/xsel so the
text lands on the X11 CLIPBOARD that xdotool Ctrl+V reads.
sensitive: Mark the copy with ``wl-copy --sensitive`` (where
supported) so clipboard managers skip it. For the transient
paste-injection copy only.
Returns:
True if clipboard copy was successful, False otherwise
@@ -1518,7 +1558,7 @@ class TextInjector:
continue
try:
if self._run_clipboard_command(tool, text):
if self._run_clipboard_command(tool, text, sensitive=sensitive):
self._clipboard_tool_health[tool] = True
logger.info(f"Text copied to clipboard using {tool}")
return True
@@ -2267,7 +2307,12 @@ class TextInjector:
pending_target if pending_target is not None else self._read_clipboard()
)
if not self._copy_to_clipboard(text, tools=tools):
# The copy only exists to feed the paste chord, so keep it out of
# clipboard-manager history -- unless the user asked for dictated text
# to stay on the clipboard (copy_to_clipboard), then it is a real copy.
if not self._copy_to_clipboard(
text, tools=tools, sensitive=not self._should_copy_to_clipboard()
):
logger.warning("Could not copy text to clipboard for paste injection")
return False
+109
View File
@@ -889,5 +889,114 @@ class TestOverlappingClipboardRestore(unittest.TestCase):
self.assertIsNone(obj._clipboard_restore_target)
class TestSensitivePasteCopy(unittest.TestCase):
"""The transient paste-injection copy is marked wl-copy --sensitive."""
def _run_paste(self, obj: Any, copy_to_clipboard: bool) -> list:
copy_kwargs: list = []
restored = threading.Event()
def fake_copy(t: str, **kw: Any) -> bool:
copy_kwargs.append((t, kw))
if t == "original":
restored.set()
return True
with patch.object(obj, "_read_clipboard", side_effect=["original", "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=copy_to_clipboard
):
with patch(
"vocalinux.text_injection.text_injector.subprocess.run",
return_value=MagicMock(returncode=0),
):
self.assertTrue(obj._inject_via_clipboard_paste("injected text"))
if not copy_to_clipboard:
# The restore runs on a background thread; wait for it
# while the patches are still active.
self.assertTrue(restored.wait(timeout=5), "never restored")
return copy_kwargs
def test_paste_copy_is_sensitive_and_restore_is_not(self) -> None:
"""Injection copy asks for --sensitive; the restored user content does not."""
obj = _make_injector()
obj.wayland_tool = "ydotool"
calls = self._run_paste(obj, copy_to_clipboard=False)
self.assertEqual(calls[0], ("injected text", {"tools": None, "sensitive": True}))
self.assertEqual(calls[-1][0], "original")
self.assertFalse(calls[-1][1].get("sensitive", False))
def test_copy_to_clipboard_setting_keeps_copy_non_sensitive(self) -> None:
"""With copy_to_clipboard on, the dictated text is a real, history-worthy copy."""
obj = _make_injector()
obj.wayland_tool = "ydotool"
calls = self._run_paste(obj, copy_to_clipboard=True)
self.assertEqual(calls, [("injected text", {"tools": None, "sensitive": False})])
def _wl_copy_cmd(self, obj: Any, sensitive: bool) -> list:
with patch("vocalinux.text_injection.text_injector.subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=0)
self.assertTrue(obj._run_clipboard_command("wl-copy", "hello", sensitive=sensitive))
return mock_run.call_args[0][0]
def test_wl_copy_gets_sensitive_flag_when_supported(self) -> None:
obj = _make_injector()
obj._wl_copy_sensitive_supported = True
self.assertEqual(self._wl_copy_cmd(obj, True), ["wl-copy", "--sensitive", "hello"])
def test_wl_copy_omits_flag_on_old_wl_clipboard(self) -> None:
obj = _make_injector()
obj._wl_copy_sensitive_supported = False
self.assertEqual(self._wl_copy_cmd(obj, True), ["wl-copy", "hello"])
def test_wl_copy_omits_flag_when_not_sensitive(self) -> None:
obj = _make_injector()
with patch.object(obj, "_wl_copy_supports_sensitive") as probe:
self.assertEqual(self._wl_copy_cmd(obj, False), ["wl-copy", "hello"])
probe.assert_not_called()
def test_copy_to_clipboard_forwards_sensitive_to_each_tool(self) -> None:
"""A tool that declines falls through to the next with the same hint."""
obj = _make_injector()
with patch.object(obj, "_run_clipboard_command", side_effect=[False, True]) as run:
self.assertTrue(obj._copy_to_clipboard("hi", tools=["xsel", "wl-copy"], sensitive=True))
self.assertEqual(
[c.args + (c.kwargs["sensitive"],) for c in run.call_args_list],
[("xsel", "hi", True), ("wl-copy", "hi", True)],
)
def test_probe_reads_help_and_caches(self) -> None:
obj = _make_injector()
help_out = MagicMock(
stdout=" -s, --sensitive Hint that the content is sensitive.", stderr=""
)
with patch(
"vocalinux.text_injection.text_injector.subprocess.run", return_value=help_out
) as mock_run:
self.assertTrue(obj._wl_copy_supports_sensitive())
self.assertTrue(obj._wl_copy_supports_sensitive())
mock_run.assert_called_once()
self.assertEqual(mock_run.call_args[0][0], ["wl-copy", "--help"])
def test_probe_false_when_help_lacks_flag_or_fails(self) -> None:
obj = _make_injector()
with patch(
"vocalinux.text_injection.text_injector.subprocess.run",
return_value=MagicMock(stdout="Usage: wl-copy [options] text", stderr=""),
):
self.assertFalse(obj._wl_copy_supports_sensitive())
obj = _make_injector()
with patch(
"vocalinux.text_injection.text_injector.subprocess.run",
side_effect=FileNotFoundError("wl-copy"),
):
self.assertFalse(obj._wl_copy_supports_sensitive())
if __name__ == "__main__":
unittest.main()
+3 -1
View File
@@ -2030,7 +2030,9 @@ class TestTextInjectorEdgeCases(unittest.TestCase):
with patch.object(injector, "_copy_to_clipboard", return_value=True) as copy:
self.assertTrue(injector._inject_via_clipboard_paste("dictation"))
tools.assert_called_once_with()
copy.assert_called_once_with("dictation", tools=["xclip"])
copy.assert_called_once()
self.assertEqual(copy.call_args.args, ("dictation",))
self.assertEqual(copy.call_args.kwargs["tools"], ["xclip"])
def test_terminal_paste_detection_runs_on_plain_x11(self) -> None:
injector = TextInjector()