Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
162 changes: 153 additions & 9 deletions app/paste_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,42 @@
# KEY_LEFTCTRL=29, KEY_V=47 (siehe /usr/include/linux/input-event-codes.h).
# Sequenz: Strg down, V down, V up, Strg up.
_CTRL_V_KEYCODES = ["29:1", "47:1", "47:0", "29:0"]
# Strg+Shift+V fuer Terminals (dort ist Strg+V meist "nichts tun" oder Copy).
# KEY_LEFTSHIFT=42 zusaetzlich zu KEY_LEFTCTRL=29, KEY_V=47.
_CTRL_SHIFT_V_KEYCODES = ["29:1", "42:1", "47:1", "47:0", "42:0", "29:0"]
# Bekannte Terminal-Emulator-Fensterklassen (lowercase-Vergleich). X11-only --
# unter Wayland gibt es ohne Compositor-spezifische Erweiterung keine
# generische "aktives Fenster"-Abfrage wie xdotool sie fuer X11 bietet.
_KNOWN_TERMINAL_WINDOW_CLASSES = frozenset(
{
"gnome-terminal-server",
"xterm",
"konsole",
"kitty",
"alacritty",
"kgx",
"tilix",
"xfce4-terminal",
"terminator",
"mate-terminal",
"org.wezfurlong.wezterm",
"foot",
"footclient",
"lxterminal",
"ghostty",
"org.gnome.terminal",
"com.github.alacritty.alacritty",
}
)
# Subprocess-Timeouts: verhindern, dass ein haengendes wl-copy/ydotool den
# Transkriptions-Worker dauerhaft blockiert (sonst bleibt der App-State auf
# TRANSCRIBING/LLM_REWRITING haengen und kein neuer Hotkey-Toggle ist moeglich).
_WL_COPY_TIMEOUT = 5.0
_YDOTOOL_TIMEOUT = 5.0
_WL_PASTE_TIMEOUT = 5.0
_XCLIP_PASTE_TIMEOUT = 5.0
_XDOTOOL_TIMEOUT = 2.0
_COPYQ_TIMEOUT = 2.0
_YDOTOOL_MISSING_DAEMON_MARKERS = (
"failed to connect",
"no such file or directory",
Expand All @@ -44,6 +75,33 @@
)


def _detect_active_window_class() -> Optional[str]:
# X11-only: xdotool benoetigt DISPLAY und kann unter Wayland ohne
# Compositor-spezifische Erweiterungen das aktive Fenster nicht generisch abfragen.
if not os.environ.get("DISPLAY"):
return None
if shutil.which("xdotool") is None:
return None
try:
result = subprocess.run(
["xdotool", "getactivewindow", "getwindowclassname"],
check=True,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
text=True,
timeout=_XDOTOOL_TIMEOUT,
)
except (subprocess.TimeoutExpired, subprocess.CalledProcessError, OSError):
return None
window_class = result.stdout.strip().lower()
return window_class or None


def _is_terminal_active() -> bool:
window_class = _detect_active_window_class()
return bool(window_class and window_class in _KNOWN_TERMINAL_WINDOW_CLASSES)


class PasteServiceError(Exception):
"""Raised when clipboard write or key injection fails hard."""

Expand Down Expand Up @@ -79,11 +137,15 @@ def paste(self, text: str, force_autopaste: Optional[bool] = None) -> None:
logger.debug("paste() mit leerem Text aufgerufen, uebersprungen.")
return

do_autopaste = self.autopaste if force_autopaste is None else bool(force_autopaste)
previous_clipboard = self._read_clipboard() if do_autopaste else None
self._copy_to_clipboard(text)

do_autopaste = self.autopaste if force_autopaste is None else bool(force_autopaste)
if do_autopaste:
self._ydotool_paste()
if do_autopaste and self._ydotool_paste():
# Kurze Pause, damit die Ziel-App den eingefuegten Text uebernehmen kann
time.sleep(_PASTE_DELAY)
self._restore_clipboard(previous_clipboard)
self._cleanup_copyq(text)
Comment on lines +147 to +148

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Clean CopyQ before restoring the old clipboard

When CopyQ is running and there was previous clipboard text, _restore_clipboard() writes that old value back first, which CopyQ records or moves to history index 0. _cleanup_copyq() then only reads/removes index 0, so it sees the restored value instead of the newly pasted text and leaves the transcript in CopyQ history; clean the pasted entry before restoring, or locate the matching history item after restore.

Useful? React with 👍 / 👎.


def clipboard_only(self, text: str) -> None:
"""Nur Clipboard, kein ydotool -- fuer Faelle wo Auto-Paste unterwuenscht."""
Expand All @@ -106,6 +168,81 @@ def _copy_to_clipboard(self, text: str) -> None:
"Kein nutzbares Clipboard-Backend gefunden. Installieren: sudo apt install wl-clipboard xclip"
)

def _read_clipboard(self) -> Optional[str]:
if _has_wayland_clipboard():
command = ["wl-paste", "--no-newline"]
timeout = _WL_PASTE_TIMEOUT
elif _has_x11_clipboard():
command = ["xclip", "-selection", "clipboard", "-o"]
timeout = _XCLIP_PASTE_TIMEOUT
else:
return None
try:
process = subprocess.Popen(
command,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
)
except subprocess.TimeoutExpired:
return None
except (OSError, ValueError) as exc:
logger.debug("Clipboard-Inhalt konnte nicht gelesen werden: %s", exc)
return None
try:
stdout, _ = process.communicate(timeout=timeout)
except subprocess.TimeoutExpired:
process.kill()
process.communicate()
return None
if process.returncode != 0:
return None
if isinstance(stdout, bytes):
try:
return stdout.decode("utf-8")
except UnicodeDecodeError as exc:
logger.debug("Clipboard-Inhalt konnte nicht decodiert werden: %s", exc)
return None
return stdout

def _restore_clipboard(self, previous: Optional[str]) -> None:
if previous is None:
return
try:
if _has_wayland_clipboard():
self._wl_copy(previous)
return
if _has_x11_clipboard():
self._xclip_copy(previous)
return
raise PasteServiceError("Kein Clipboard-Backend fuer Restore verfuegbar.")
except PasteServiceError:
logger.warning("Originalzwischenablage konnte nicht wiederhergestellt werden")

def _cleanup_copyq(self, text: str) -> None:
if shutil.which("copyq") is None:
return
try:
result = subprocess.run(
["copyq", "read", "0"],
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
timeout=_COPYQ_TIMEOUT,
text=True,
)
# "copyq read" haengt teils einen Zeilenumbruch an -- fuer den
# Vergleich unerheblich, das eigentliche Entfernen bezieht sich
# ohnehin auf den Index (Position 0), nicht auf den String.
if result.stdout.rstrip("\n") != text:
return
subprocess.run(
["copyq", "remove", "0"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=_COPYQ_TIMEOUT,
)
except (subprocess.TimeoutExpired, OSError, ValueError) as exc:
logger.debug("CopyQ-Cleanup uebersprungen: %s", exc)

def _wl_copy(self, text: str) -> None:
# WICHTIG: wl-copy forkt einen Hintergrund-Daemon, der die Auswahl
# "besitzt". Dieser Kindprozess erbt offene Pipes -- mit stderr=PIPE
Expand Down Expand Up @@ -147,18 +284,23 @@ def _xclip_copy(self, text: str) -> None:
except subprocess.CalledProcessError as exc:
raise PasteServiceError(f"xclip fehlgeschlagen (rc={exc.returncode})") from exc

def _ydotool_paste(self) -> None:
def _ydotool_paste(self) -> bool:
if shutil.which("ydotool") is None:
logger.warning(
"ydotool nicht gefunden -- Auto-Paste uebersprungen. "
"Installieren: sudo apt install ydotool"
)
return
# Kurze Pause damit Clipboard-Inhalt sicher verfuegbar ist
return False
# Kurze Pause, damit der neue Clipboard-Inhalt vor Ctrl+V sicher anliegt
time.sleep(_PASTE_DELAY)
keycodes = _CTRL_SHIFT_V_KEYCODES if _is_terminal_active() else _CTRL_V_KEYCODES
if keycodes is _CTRL_SHIFT_V_KEYCODES:
logger.debug("Aktives Fenster ist ein Terminal -- sende Ctrl+Shift+V via ydotool.")
else:
logger.debug("Aktives Fenster ist kein Terminal -- sende Ctrl+V via ydotool.")
try:
result = subprocess.run(
["ydotool", "key", "--key-delay", str(self.key_delay_ms), *_CTRL_V_KEYCODES],
["ydotool", "key", "--key-delay", str(self.key_delay_ms), *keycodes],
check=False,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
Expand All @@ -172,7 +314,7 @@ def _ydotool_paste(self) -> None:
"(Text liegt bereits im Clipboard).",
_YDOTOOL_TIMEOUT,
)
return
return False
if result.returncode != 0:
stderr = result.stderr.decode(errors="replace").strip() if result.stderr else ""
# Nicht fatal -- Clipboard-Inhalt ist bereits gesetzt
Expand All @@ -181,8 +323,10 @@ def _ydotool_paste(self) -> None:
"ydotoold nicht verfügbar -- Auto-Paste uebersprungen "
"(Text liegt bereits im Clipboard)."
)
return
return False
logger.warning("ydotool Ctrl+V fehlgeschlagen (rc=%d): %s", result.returncode, stderr)
return False
return True


def check_dependencies() -> list[str]:
Expand Down
Loading