From 9c058a1ec8ed006b8fe40bd37538d7ba34adb97c Mon Sep 17 00:00:00 2001 From: Vitali Graf Date: Mon, 6 Apr 2026 21:26:51 +0200 Subject: [PATCH] fix: use ydotool + wl-copy for text insertion on KDE Wayland Replace wtype (unsupported on KDE) with ydotool key for Ctrl+V simulation. Use wl-copy for clipboard-based insertion to avoid ydotool type's QWERTZ/QWERTY layout mismatch. Use DEVNULL instead of PIPE for wl-copy to prevent hanging on its forked background process. Co-Authored-By: Claude Opus 4.6 --- tests/test_inserter.py | 34 +++++++++++++++++----------------- whisper_local/inserter.py | 33 ++++++++++++++++++++++----------- 2 files changed, 39 insertions(+), 28 deletions(-) diff --git a/tests/test_inserter.py b/tests/test_inserter.py index 2e9f43d..e4c30d4 100644 --- a/tests/test_inserter.py +++ b/tests/test_inserter.py @@ -1,5 +1,5 @@ import asyncio -from unittest.mock import AsyncMock, MagicMock, patch, call +from unittest.mock import AsyncMock, patch, call import pytest @@ -11,22 +11,25 @@ class TestInserter: @patch("whisper_local.inserter.asyncio.sleep", new_callable=AsyncMock) @patch("whisper_local.inserter.asyncio.create_subprocess_exec") async def test_insert_text_calls_tools_in_order(self, mock_exec, mock_sleep): - # Mock für alle subprocess-Aufrufe mock_proc = AsyncMock() mock_proc.communicate.return_value = (b"alter clipboard", b"") mock_proc.returncode = 0 + mock_proc.wait = AsyncMock() mock_exec.return_value = mock_proc inserter = Inserter() await inserter.insert("Hallo Welt") - # Prüfe dass wl-paste, wl-copy, wtype, wl-copy aufgerufen werden calls = mock_exec.call_args_list assert len(calls) == 4 - assert calls[0][0][0] == "wl-paste" # Clipboard sichern - assert calls[1][0] == ("wl-copy", "Hallo Welt") # Text in Clipboard - assert calls[2][0][0] == "wtype" # Ctrl+V simulieren - assert calls[3][0] == ("wl-copy", "alter clipboard") # Clipboard wiederherstellen + # wl-paste mit PIPE (braucht stdout) + assert calls[0][0] == ("wl-paste", "--no-newline") + # wl-copy mit DEVNULL (forkt Hintergrundprozess) + assert calls[1][0] == ("wl-copy", "--", "Hallo Welt") + # ydotool key + assert calls[2][0][0] == "ydotool" + # wl-copy restore + assert calls[3][0] == ("wl-copy", "--", "alter clipboard") @pytest.mark.asyncio @patch("whisper_local.inserter.asyncio.create_subprocess_exec") @@ -46,24 +49,21 @@ class TestInserter: nonlocal call_count call_count += 1 mock_proc = AsyncMock() + mock_proc.wait = AsyncMock() if call_count == 1: # wl-paste mock_proc.communicate.return_value = (b"original", b"") mock_proc.returncode = 0 - elif call_count == 2: # wl-copy - mock_proc.communicate.return_value = (b"", b"") - mock_proc.returncode = 0 - elif call_count == 3: # wtype — fails - mock_proc.communicate.return_value = (b"", b"error") - mock_proc.returncode = 1 - else: # wl-copy restore - mock_proc.communicate.return_value = (b"", b"") + elif call_count == 3: # ydotool — fails + mock_proc.wait.side_effect = OSError("ydotool not found") + else: # wl-copy calls mock_proc.returncode = 0 return mock_proc mock_exec.side_effect = mock_create_proc inserter = Inserter() - await inserter.insert("Test") + with pytest.raises(OSError): + await inserter.insert("Test") - # Clipboard muss trotzdem wiederhergestellt werden + # Clipboard muss trotzdem wiederhergestellt werden (finally-Block) assert call_count == 4 diff --git a/whisper_local/inserter.py b/whisper_local/inserter.py index d73c6a0..782c9ab 100644 --- a/whisper_local/inserter.py +++ b/whisper_local/inserter.py @@ -1,4 +1,4 @@ -"""Text-Einfügung via Clipboard und wtype.""" +"""Text-Einfügung via wl-copy + ydotool Ctrl+V.""" import asyncio import logging @@ -9,8 +9,8 @@ PASTE_DELAY = 0.2 # Sekunden Wartezeit vor Clipboard-Restore class Inserter: - async def _run(self, *cmd: str) -> tuple[bytes, int]: - """Führt einen Befehl aus, gibt (stdout, returncode) zurück.""" + async def _run_capture(self, *cmd: str) -> bytes: + """Führt einen Befehl aus und gibt stdout zurück.""" proc = await asyncio.create_subprocess_exec( *cmd, stdout=asyncio.subprocess.PIPE, @@ -19,7 +19,16 @@ class Inserter: stdout, stderr = await proc.communicate() if proc.returncode != 0: logger.warning("Befehl fehlgeschlagen: %s (stderr: %s)", cmd, stderr.decode()) - return stdout, proc.returncode + return stdout + + async def _run_fire(self, *cmd: str) -> None: + """Führt einen Befehl aus ohne auf Pipes zu warten.""" + proc = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.DEVNULL, + ) + await proc.wait() async def insert(self, text: str) -> None: """Fügt Text ins aktive Fenster ein via Clipboard + Ctrl+V.""" @@ -27,19 +36,21 @@ class Inserter: return # 1. Aktuelle Zwischenablage sichern - old_clipboard, _ = await self._run("wl-paste") + old_clipboard = await self._run_capture("wl-paste", "--no-newline") try: # 2. Text in Zwischenablage kopieren - await self._run("wl-copy", text) + await self._run_fire("wl-copy", "--", text) - # 3. Ctrl+V simulieren - await self._run("wtype", "-M", "ctrl", "v", "-m", "ctrl") + # 3. Kurz warten damit Clipboard bereit ist + await asyncio.sleep(0.05) - # 4. Kurz warten, dann Clipboard wiederherstellen + # 4. Ctrl+V simulieren via ydotool (Ctrl=29, V=47) + await self._run_fire("ydotool", "key", "29:1", "47:1", "47:0", "29:0") + + # 5. Warten, dann Clipboard wiederherstellen await asyncio.sleep(PASTE_DELAY) finally: - # Clipboard immer wiederherstellen - await self._run("wl-copy", old_clipboard.decode()) + await self._run_fire("wl-copy", "--", old_clipboard.decode()) logger.info("Text eingefügt (%d Zeichen)", len(text))