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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user