Files
zwischenablage-server/client-desktop/netclip/clipboard.py
T
2026-10-16 15:55:14 +02:00

211 lines
8.1 KiB
Python

"""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()