211 lines
8.1 KiB
Python
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()
|