From aae1f4b474eae578cdc55b7323b5c16d98ab2069 Mon Sep 17 00:00:00 2001 From: Jakob Jensen Date: Tue, 27 Oct 2026 04:25:40 +0100 Subject: [PATCH] =?UTF-8?q?fix(clipboard):=20keep=20paste-injection=20copi?= =?UTF-8?q?es=20out=20of=20clipboard=20history=20=F0=9F=A4=96=F0=9F=A4=96?= =?UTF-8?q?=F0=9F=A4=96=20(#956)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- docs/USER_GUIDE.md | 2 + src/vocalinux/text_injection/text_injector.py | 55 ++++++++- tests/test_clipboard_restore.py | 109 ++++++++++++++++++ tests/test_text_injector.py | 4 +- 4 files changed, 164 insertions(+), 6 deletions(-) diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 276db71..a949b04 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -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 diff --git a/src/vocalinux/text_injection/text_injector.py b/src/vocalinux/text_injection/text_injector.py index ce7cbec..1dd817c 100644 --- a/src/vocalinux/text_injection/text_injector.py +++ b/src/vocalinux/text_injection/text_injector.py @@ -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 diff --git a/tests/test_clipboard_restore.py b/tests/test_clipboard_restore.py index d7a4390..e3f37b3 100644 --- a/tests/test_clipboard_restore.py +++ b/tests/test_clipboard_restore.py @@ -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() diff --git a/tests/test_text_injector.py b/tests/test_text_injector.py index bb7adab..553f560 100644 --- a/tests/test_text_injector.py +++ b/tests/test_text_injector.py @@ -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()