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 <noreply@anthropic.com>
This commit is contained in:
2026-04-06 21:26:51 +02:00
parent edece0488d
commit 9c058a1ec8
2 changed files with 39 additions and 28 deletions
+16 -16
View File
@@ -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()
with pytest.raises(OSError):
await inserter.insert("Test")
# Clipboard muss trotzdem wiederhergestellt werden
# Clipboard muss trotzdem wiederhergestellt werden (finally-Block)
assert call_count == 4
+22 -11
View File
@@ -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))