* 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>