"""Zugriff auf die System-Zwischenablage. QtBackend Windows, macOS, Linux/X11. Dateien werden als Datei-URLs abgelegt (Windows: CF_HDROP, macOS: public.file-url, X11: text/uri-list + x-special/gnome-copied-files), d.h. Strg+V im Explorer/Finder/Dateimanager fuegt die Dateien bzw. Ordner im aktuellen Ordner ein. WaylandBackend Linux/Wayland über wl-clipboard (wl-paste --watch / wl-copy), da Wayland Programmen ohne Fokus keinen Zugriff auf die Zwischenablage gibt. Funktioniert mit KDE Plasma und wlroots (Sway, Hyprland), nicht mit GNOME. """ import hashlib import os import shutil import subprocess from urllib.parse import unquote, urlparse from PySide6.QtCore import QBuffer, QByteArray, QIODevice, QMimeData, QObject, QProcess, QTimer, QUrl, Signal from PySide6.QtGui import QClipboard, QGuiApplication, QImage from .config import SYSTEM from .payload import ClipContent, files_fp, text_fp # Formate, mit denen Passwort-Manager ihre Eintraege als "nicht synchronisieren" markieren SECRET_HINTS = ("x-kde-passwordManagerHint", "ExcludeClipboardContentFromMonitorProcessing", "org.nspasteboard.ConcealedType", "org.nspasteboard.TransientType") def image_fp(img: QImage) -> str: img = img.convertToFormat(QImage.Format.Format_RGBA8888) h = hashlib.sha256(f"{img.width()}x{img.height()}".encode()) h.update(bytes(img.constBits())) return "image:" + h.hexdigest() def image_to_png(img: QImage) -> bytes: ba = QByteArray() buf = QBuffer(ba) buf.open(QIODevice.OpenModeFlag.WriteOnly) img.save(buf, "PNG") return bytes(ba.data()) def uri_list(paths: list[str]) -> bytes: return "".join(QUrl.fromLocalFile(p).toString(QUrl.ComponentFormattingOption.FullyEncoded) + "\r\n" for p in paths).encode() class Backend(QObject): changed = Signal() def read(self) -> ClipContent | None: raise NotImplementedError def write(self, content: ClipContent) -> str: """Legt content in die Zwischenablage, gibt den Fingerabdruck zurück.""" raise NotImplementedError class QtBackend(Backend): def __init__(self): super().__init__() self.cb = QGuiApplication.clipboard() self.cb.dataChanged.connect(self.changed) if SYSTEM == "Darwin": # macOS meldet fremde Aenderungen nicht zuverlaessig -> changeCount pollen self._mac_count = -1 try: from AppKit import NSPasteboard self._pb = NSPasteboard.generalPasteboard() except ImportError: self._pb = None self._poll = QTimer(self, interval=700, timeout=self._mac_poll) self._poll.start() def _mac_poll(self): if self._pb is not None: count = self._pb.changeCount() if count != self._mac_count: if self._mac_count != -1: self.changed.emit() self._mac_count = count else: md = self.cb.mimeData() sig = (tuple(md.formats()), md.text()[:200]) if md else None if getattr(self, "_mac_sig", sig) != sig: self.changed.emit() self._mac_sig = sig def read(self): md = self.cb.mimeData(QClipboard.Mode.Clipboard) if md is None: return None formats = md.formats() if any(hint in f for f in formats for hint in SECRET_HINTS): return None if md.hasUrls(): paths = [u.toLocalFile() for u in md.urls() if u.isLocalFile()] paths = [p for p in paths if p and os.path.exists(p)] if paths: return ClipContent("files", paths=paths, fp=files_fp(paths)) if md.hasImage(): img = self.cb.image() if not img.isNull(): return ClipContent("image", png=image_to_png(img), fp=image_fp(img)) if md.hasText(): text = md.text() if text.strip(): return ClipContent("text", text=text, fp=text_fp(text)) return None def write(self, content): md = QMimeData() if content.kind == "text": md.setText(content.text) fp = text_fp(content.text) elif content.kind == "image": img = QImage.fromData(content.png) md.setImageData(img) md.setData("image/png", content.png) fp = image_fp(img) else: md.setUrls([QUrl.fromLocalFile(p) for p in content.paths]) if SYSTEM == "Linux": # GNOME/Nautilus, Nemo, Caja, Thunar md.setData("x-special/gnome-copied-files", b"copy\n" + uri_list(content.paths).replace(b"\r\n", b"\n").rstrip(b"\n")) elif SYSTEM == "Windows": # Explorer: "kopieren" statt "verschieben" md.setData('application/x-qt-windows-mime;value="Preferred DropEffect"', b"\x01\x00\x00\x00") fp = files_fp(content.paths) self.cb.setMimeData(md, QClipboard.Mode.Clipboard) return fp class WaylandBackend(Backend): def __init__(self): super().__init__() self.proc = QProcess(self) self.proc.readyReadStandardOutput.connect(self._on_output) self.proc.start("wl-paste", ["--watch", "echo", "x"]) def _on_output(self): self.proc.readAllStandardOutput() self.changed.emit() @staticmethod def _paste(mime: str | None = None, list_types=False) -> bytes: args = ["wl-paste", "--no-newline"] if list_types: args = ["wl-paste", "--list-types"] elif mime: args += ["--type", mime] r = subprocess.run(args, capture_output=True, timeout=10) return r.stdout if r.returncode == 0 else b"" def read(self): types = self._paste(list_types=True).decode(errors="replace").split() if not types or any(hint in t for t in types for hint in SECRET_HINTS): return None if "text/uri-list" in types: paths = [] for line in self._paste("text/uri-list").decode(errors="replace").splitlines(): u = urlparse(line.strip()) if u.scheme == "file": p = unquote(u.path) if os.path.exists(p): paths.append(p) if paths: return ClipContent("files", paths=paths, fp=files_fp(paths)) images = [t for t in types if t.startswith("image/")] if images: mime = "image/png" if "image/png" in images else images[0] data = self._paste(mime) if mime != "image/png": data = image_to_png(QImage.fromData(data)) if data: return ClipContent("image", png=data, fp=ClipContent.fingerprint("image", data)) for mime in ("text/plain;charset=utf-8", "UTF8_STRING", "text/plain", "TEXT", "STRING"): if mime in types: text = self._paste(mime).decode("utf-8", errors="replace") if text.strip(): return ClipContent("text", text=text, fp=text_fp(text)) break return None @staticmethod def _copy(data: bytes, mime: str | None): args = ["wl-copy"] + (["--type", mime] if mime else []) subprocess.run(args, input=data, timeout=10) def write(self, content): if content.kind == "text": self._copy(content.text.encode("utf-8"), None) return text_fp(content.text) if content.kind == "image": self._copy(content.png, "image/png") return ClipContent.fingerprint("image", content.png) self._copy(uri_list(content.paths), "text/uri-list") return files_fp(content.paths) def stop(self): self.proc.kill() self.proc.waitForFinished(1000) def create_backend() -> Backend: if (SYSTEM == "Linux" and os.environ.get("WAYLAND_DISPLAY") and shutil.which("wl-paste") and shutil.which("wl-copy") and os.environ.get("NETCLIP_BACKEND") != "qt"): return WaylandBackend() return QtBackend()