first commit
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
"""NetClip-Desktop-Client.
|
||||
|
||||
netclip starten (Tray-Icon)
|
||||
netclip --minimized ohne Verlaufsfenster starten (Autostart)
|
||||
netclip --show Verlauf der laufenden Instanz öffnen (fuer ein Tastenkuerzel)
|
||||
netclip --import datei.netclip Verbindungsdaten importieren
|
||||
netclip --server URL --token T [--server-key K] [--insecure] [--autostart]
|
||||
Einstellungen setzen (fuer automatisches Ausrollen)
|
||||
"""
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
from PySide6.QtCore import QTimer
|
||||
from PySide6.QtNetwork import QLocalServer, QLocalSocket
|
||||
from PySide6.QtWidgets import QApplication, QMessageBox, QSystemTrayIcon
|
||||
|
||||
from . import autostart, config, connection
|
||||
|
||||
INSTANCE = "netclip-instance"
|
||||
|
||||
|
||||
def parse_args():
|
||||
p = argparse.ArgumentParser(prog="netclip", description="Netzwerk-Zwischenablage")
|
||||
p.add_argument("--minimized", action="store_true")
|
||||
p.add_argument("--show", action="store_true")
|
||||
p.add_argument("--import", dest="import_file")
|
||||
p.add_argument("--server")
|
||||
p.add_argument("--token")
|
||||
p.add_argument("--server-key")
|
||||
p.add_argument("--device-name")
|
||||
p.add_argument("--insecure", action="store_true", help="TLS-Zertifikat nicht prüfen")
|
||||
p.add_argument("--autostart", action="store_true")
|
||||
p.add_argument("--configure-only", action="store_true", help="nur Einstellungen schreiben, nicht starten")
|
||||
return p.parse_args()
|
||||
|
||||
|
||||
def apply_args(args) -> bool:
|
||||
cfg = config.load()
|
||||
changed = False
|
||||
if args.import_file:
|
||||
with open(args.import_file, encoding="utf-8") as f:
|
||||
for k, v in connection.parse(f.read()).items():
|
||||
setattr(cfg, k, v)
|
||||
changed = True
|
||||
for attr in ("server", "token", "server_key", "device_name"):
|
||||
value = getattr(args, attr)
|
||||
if value is not None:
|
||||
setattr(cfg, attr, value)
|
||||
changed = True
|
||||
if args.insecure:
|
||||
cfg.verify_tls = False
|
||||
changed = True
|
||||
if args.autostart:
|
||||
cfg.autostart = True
|
||||
autostart.set_enabled(True)
|
||||
changed = True
|
||||
if changed:
|
||||
config.save(cfg)
|
||||
return changed
|
||||
|
||||
|
||||
def main():
|
||||
args = parse_args()
|
||||
changed = apply_args(args)
|
||||
if args.configure_only:
|
||||
print(f"Konfiguration gespeichert: {config.config_file()}")
|
||||
return
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
app.setApplicationName("NetClip")
|
||||
app.setQuitOnLastWindowClosed(False)
|
||||
|
||||
# Einzelinstanz: laeuft schon eine, dort ggf. Verlauf öffnen bzw. neu laden und beenden
|
||||
sock = QLocalSocket()
|
||||
sock.connectToServer(INSTANCE)
|
||||
if sock.waitForConnected(300):
|
||||
sock.write(b"reload\n" if changed else b"show\n")
|
||||
sock.waitForBytesWritten(300)
|
||||
sock.disconnectFromServer()
|
||||
return
|
||||
|
||||
if not QSystemTrayIcon.isSystemTrayAvailable():
|
||||
QMessageBox.warning(None, "NetClip", "Kein System-Tray gefunden. Der Verlauf ist über das Fenster erreichbar.")
|
||||
|
||||
from .controller import Controller
|
||||
from .ui import HistoryWindow, Tray, app_icon
|
||||
|
||||
app.setWindowIcon(app_icon())
|
||||
ctl = Controller()
|
||||
window = HistoryWindow(ctl)
|
||||
tray = Tray(ctl, window)
|
||||
|
||||
server = QLocalServer()
|
||||
QLocalServer.removeServer(INSTANCE)
|
||||
server.listen(INSTANCE)
|
||||
|
||||
def on_connection():
|
||||
conn = server.nextPendingConnection()
|
||||
|
||||
def read():
|
||||
cmd = bytes(conn.readAll()).decode().strip()
|
||||
if cmd == "reload":
|
||||
ctl.apply_config(config.load())
|
||||
window.show_raise()
|
||||
conn.readyRead.connect(read)
|
||||
server.newConnection.connect(on_connection)
|
||||
|
||||
if not ctl.cfg.configured:
|
||||
QTimer.singleShot(200, tray.open_settings)
|
||||
elif not args.minimized:
|
||||
window.show_raise()
|
||||
|
||||
app.aboutToQuit.connect(ctl.shutdown)
|
||||
sys.exit(app.exec())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,159 @@
|
||||
"""HTTP- und WebSocket-Anbindung an den NetClip-Server (ohne Qt, mit Callbacks)."""
|
||||
import json
|
||||
import ssl
|
||||
import struct
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
|
||||
import requests
|
||||
import urllib3
|
||||
import websocket
|
||||
|
||||
from .config import Config
|
||||
from .crypto import Keys
|
||||
|
||||
|
||||
class _FramedBody:
|
||||
"""Datei-artiger Body: Meta-Laenge + Meta + Blob-Datei, gestreamt mit Fortschritt."""
|
||||
|
||||
def __init__(self, meta: str, blob: Path, progress=None):
|
||||
self.head = struct.pack(">I", len(meta)) + meta.encode("ascii")
|
||||
self.file = open(blob, "rb")
|
||||
self.len = len(self.head) + blob.stat().st_size
|
||||
self.sent = 0
|
||||
self.progress = progress
|
||||
|
||||
def __len__(self):
|
||||
return self.len
|
||||
|
||||
def read(self, n=-1):
|
||||
if self.head:
|
||||
data, self.head = self.head, b""
|
||||
else:
|
||||
data = self.file.read(n if n and n > 0 else 1024 * 1024)
|
||||
self.sent += len(data)
|
||||
if self.progress and data:
|
||||
self.progress(self.sent, self.len)
|
||||
return data
|
||||
|
||||
def close(self):
|
||||
self.file.close()
|
||||
|
||||
|
||||
class Api:
|
||||
def __init__(self, cfg: Config, keys: Keys):
|
||||
self.cfg = cfg
|
||||
self.keys = keys
|
||||
self.base = cfg.server.rstrip("/")
|
||||
self.session = requests.Session()
|
||||
if cfg.server_key:
|
||||
self.session.headers["Authorization"] = f"Bearer {cfg.server_key}"
|
||||
if not cfg.verify_tls:
|
||||
self.session.verify = False
|
||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
elif cfg.ca_file:
|
||||
self.session.verify = cfg.ca_file
|
||||
|
||||
@property
|
||||
def room_url(self) -> str:
|
||||
return f"{self.base}/api/v1/rooms/{self.keys.room_id}"
|
||||
|
||||
def list_clips(self) -> list[dict]:
|
||||
r = self.session.get(f"{self.room_url}/clips", timeout=20)
|
||||
r.raise_for_status()
|
||||
return r.json()["clips"]
|
||||
|
||||
def upload(self, meta: dict, blob: Path, progress=None) -> dict:
|
||||
body = _FramedBody(self.keys.encrypt_meta(meta), blob, progress)
|
||||
try:
|
||||
r = self.session.post(f"{self.room_url}/clips", data=body, timeout=(20, 600),
|
||||
headers={"X-Device": self.cfg.device_id,
|
||||
"Content-Type": "application/octet-stream",
|
||||
"Content-Length": str(len(body))})
|
||||
finally:
|
||||
body.close()
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
def download(self, clip_id: str, dest: Path, progress=None):
|
||||
with self.session.get(f"{self.room_url}/clips/{clip_id}/blob", stream=True, timeout=(20, 600)) as r:
|
||||
r.raise_for_status()
|
||||
total = int(r.headers.get("Content-Length", 0))
|
||||
done = 0
|
||||
with open(dest, "wb") as f:
|
||||
for chunk in r.iter_content(1024 * 1024):
|
||||
f.write(chunk)
|
||||
done += len(chunk)
|
||||
if progress:
|
||||
progress(done, total)
|
||||
|
||||
def delete(self, clip_id: str):
|
||||
self.session.delete(f"{self.room_url}/clips/{clip_id}", timeout=20).raise_for_status()
|
||||
|
||||
def clear(self):
|
||||
self.session.delete(f"{self.room_url}/clips", timeout=20).raise_for_status()
|
||||
|
||||
|
||||
class Live(threading.Thread):
|
||||
"""WebSocket-Verbindung mit automatischem Wiederverbinden.
|
||||
on_message(dict) und on_state(connected: bool, error: str) werden im Thread aufgerufen."""
|
||||
|
||||
def __init__(self, api: Api, on_message, on_state):
|
||||
super().__init__(daemon=True, name="netclip-ws")
|
||||
self.api = api
|
||||
self.on_message = on_message
|
||||
self.on_state = on_state
|
||||
self._halt = threading.Event()
|
||||
self._ws = None
|
||||
|
||||
def run(self):
|
||||
url = self.api.room_url.replace("https://", "wss://", 1).replace("http://", "ws://", 1)
|
||||
url += f"/ws?device={quote(self.api.cfg.device_id)}"
|
||||
headers = []
|
||||
if self.api.cfg.server_key:
|
||||
headers.append(f"Authorization: Bearer {self.api.cfg.server_key}")
|
||||
sslopt = {}
|
||||
if not self.api.cfg.verify_tls:
|
||||
sslopt = {"cert_reqs": ssl.CERT_NONE, "check_hostname": False}
|
||||
elif self.api.cfg.ca_file:
|
||||
sslopt = {"ca_certs": self.api.cfg.ca_file}
|
||||
delay = 2
|
||||
while not self._halt.is_set():
|
||||
error = ""
|
||||
started = time.monotonic()
|
||||
self._ws = websocket.WebSocketApp(
|
||||
url, header=headers,
|
||||
on_open=self._opened,
|
||||
on_message=lambda _ws, msg: self._dispatch(msg),
|
||||
on_error=lambda _ws, e: None if self._halt.is_set() else self.on_state(False, str(e)),
|
||||
)
|
||||
try:
|
||||
self._ws.run_forever(sslopt=sslopt, ping_interval=30, ping_timeout=10)
|
||||
except Exception as e: # noqa: BLE001 - Verbindung soll nie den Thread beenden
|
||||
error = str(e)
|
||||
if self._halt.is_set():
|
||||
return
|
||||
self.on_state(False, error)
|
||||
if time.monotonic() - started > 60:
|
||||
delay = 2
|
||||
self._halt.wait(delay)
|
||||
delay = min(delay * 2, 60)
|
||||
|
||||
def _opened(self, ws):
|
||||
ws.send(json.dumps({"type": "hello", "label": self.api.keys.encrypt_label(self.api.cfg.device_name)}))
|
||||
self.on_state(True, "")
|
||||
|
||||
def _dispatch(self, raw):
|
||||
try:
|
||||
self.on_message(json.loads(raw))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
def stop(self, wait: float = 0):
|
||||
self._halt.set()
|
||||
if self._ws:
|
||||
self._ws.close()
|
||||
if wait and self.is_alive():
|
||||
self.join(wait)
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Autostart beim Anmelden (Windows: Registry Run-Key, macOS: LaunchAgent, Linux: XDG-Autostart)."""
|
||||
import os
|
||||
import plistlib
|
||||
import shlex
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from .config import SYSTEM
|
||||
|
||||
LABEL = "de.netclip.client"
|
||||
|
||||
|
||||
def command() -> list[str]:
|
||||
if getattr(sys, "frozen", False):
|
||||
return [sys.executable, "--minimized"]
|
||||
return [sys.executable, "-m", "netclip", "--minimized"]
|
||||
|
||||
|
||||
def _linux_file() -> Path:
|
||||
base = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
|
||||
return base / "autostart" / "netclip.desktop"
|
||||
|
||||
|
||||
def _mac_file() -> Path:
|
||||
return Path.home() / "Library" / "LaunchAgents" / f"{LABEL}.plist"
|
||||
|
||||
|
||||
def set_enabled(enabled: bool):
|
||||
cmd = command()
|
||||
if SYSTEM == "Windows":
|
||||
import winreg
|
||||
key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, r"Software\Microsoft\Windows\CurrentVersion\Run",
|
||||
0, winreg.KEY_SET_VALUE)
|
||||
with key:
|
||||
if enabled:
|
||||
winreg.SetValueEx(key, "NetClip", 0, winreg.REG_SZ, " ".join(f'"{c}"' for c in cmd))
|
||||
else:
|
||||
try:
|
||||
winreg.DeleteValue(key, "NetClip")
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
elif SYSTEM == "Darwin":
|
||||
f = _mac_file()
|
||||
if enabled:
|
||||
f.parent.mkdir(parents=True, exist_ok=True)
|
||||
f.write_bytes(plistlib.dumps({"Label": LABEL, "ProgramArguments": cmd, "RunAtLoad": True}))
|
||||
else:
|
||||
f.unlink(missing_ok=True)
|
||||
else:
|
||||
f = _linux_file()
|
||||
if enabled:
|
||||
f.parent.mkdir(parents=True, exist_ok=True)
|
||||
f.write_text("[Desktop Entry]\nType=Application\nName=NetClip\n"
|
||||
f"Exec={shlex.join(cmd)}\nIcon=edit-paste\nX-GNOME-Autostart-enabled=true\n")
|
||||
else:
|
||||
f.unlink(missing_ok=True)
|
||||
@@ -0,0 +1,210 @@
|
||||
"""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()
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Einstellungen und Pfade (plattformabhaengig, ohne Qt nutzbar)."""
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import secrets
|
||||
import socket
|
||||
import sys
|
||||
from dataclasses import asdict, dataclass, field, fields
|
||||
from pathlib import Path
|
||||
|
||||
APP = "NetClip"
|
||||
SYSTEM = platform.system() # Windows | Darwin | Linux
|
||||
|
||||
|
||||
def config_dir() -> Path:
|
||||
if SYSTEM == "Windows":
|
||||
return Path(os.environ.get("APPDATA", Path.home())) / APP
|
||||
if SYSTEM == "Darwin":
|
||||
return Path.home() / "Library" / "Application Support" / APP
|
||||
return Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "netclip"
|
||||
|
||||
|
||||
def cache_dir() -> Path:
|
||||
if SYSTEM == "Windows":
|
||||
return Path(os.environ.get("LOCALAPPDATA", Path.home())) / APP / "cache"
|
||||
if SYSTEM == "Darwin":
|
||||
return Path.home() / "Library" / "Caches" / APP
|
||||
return Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "netclip"
|
||||
|
||||
|
||||
def exe_dir() -> Path:
|
||||
if getattr(sys, "frozen", False):
|
||||
return Path(sys.executable).resolve().parent
|
||||
return Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
@dataclass
|
||||
class Config:
|
||||
server: str = "" # z.B. https://clip.example.de
|
||||
token: str = "" # gemeinsamer Token aller eigenen Geraete
|
||||
server_key: str = "" # optional, siehe SERVER_KEY im Server
|
||||
verify_tls: bool = True # False = Zertifikat nicht pruefen (selbstsigniert, nur im LAN)
|
||||
ca_file: str = "" # alternativ: Pfad zum Server-Zertifikat (fullchain.pem)
|
||||
device_id: str = field(default_factory=lambda: secrets.token_hex(8))
|
||||
device_name: str = field(default_factory=socket.gethostname)
|
||||
send_enabled: bool = True # lokale Zwischenablage hochladen
|
||||
receive_enabled: bool = True # fremde Clips automatisch in die Zwischenablage legen
|
||||
send_text: bool = True
|
||||
send_images: bool = True
|
||||
send_files: bool = True
|
||||
max_send_mb: int = 500 # groessere Dateien/Ordner werden nicht automatisch gesendet
|
||||
max_auto_receive_mb: int = 200 # groessere Clips nur auf Klick im Verlauf herunterladen
|
||||
keep_received_days: int = 7 # empfangene Dateien im Cache so lange aufheben
|
||||
notifications: bool = True
|
||||
autostart: bool = False
|
||||
|
||||
@property
|
||||
def configured(self) -> bool:
|
||||
return bool(self.server and self.token)
|
||||
|
||||
|
||||
def config_file() -> Path:
|
||||
return config_dir() / "config.json"
|
||||
|
||||
|
||||
def load() -> Config:
|
||||
"""Laedt config.json. Fuer die Verteilung: eine config.default.json neben der
|
||||
Programmdatei liefert Vorgaben (Server, Token, ...), die beim ersten Start uebernommen werden."""
|
||||
data = {}
|
||||
default = exe_dir() / "config.default.json"
|
||||
for path in (default, config_file()):
|
||||
if path.exists():
|
||||
try:
|
||||
values = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError) as e:
|
||||
print(f"Konfiguration {path} nicht lesbar: {e}", file=sys.stderr)
|
||||
continue
|
||||
if path == default:
|
||||
values.pop("device_id", None) # muss pro Geraet eindeutig sein
|
||||
values.pop("device_name", None)
|
||||
data.update(values)
|
||||
known = {f.name for f in fields(Config)}
|
||||
cfg = Config(**{k: v for k, v in data.items() if k in known})
|
||||
if not config_file().exists():
|
||||
save(cfg)
|
||||
return cfg
|
||||
|
||||
|
||||
def save(cfg: Config):
|
||||
path = config_file()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = path.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(asdict(cfg), indent=2), encoding="utf-8")
|
||||
os.replace(tmp, path)
|
||||
if SYSTEM != "Windows":
|
||||
os.chmod(path, 0o600)
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Verbindungsdaten (Server, Token, ...) als Datei oder QR-Code weitergeben.
|
||||
|
||||
Format (Datei-Inhalt und QR-Code identisch, Android versteht dasselbe):
|
||||
netclip://connect?v=1&server=<url>&token=<token>[&key=<server_key>][&insecure=1]
|
||||
Eine .netclip-Datei darf alternativ JSON {"server":..,"token":..,"server_key":..,"verify_tls":..} enthalten.
|
||||
"""
|
||||
import json
|
||||
from urllib.parse import parse_qs, urlencode, urlparse
|
||||
|
||||
FIELDS = ("server", "token", "server_key", "verify_tls")
|
||||
|
||||
|
||||
def to_uri(server: str, token: str, server_key: str = "", verify_tls: bool = True) -> str:
|
||||
q = {"v": "1", "server": server, "token": token}
|
||||
if server_key:
|
||||
q["key"] = server_key
|
||||
if not verify_tls:
|
||||
q["insecure"] = "1"
|
||||
return "netclip://connect?" + urlencode(q)
|
||||
|
||||
|
||||
def parse(text: str) -> dict:
|
||||
"""Gibt {"server", "token", "server_key", "verify_tls"} zurück, ValueError bei ungültigem Inhalt."""
|
||||
text = text.strip()
|
||||
if text.startswith("{"):
|
||||
data = json.loads(text)
|
||||
result = {k: data[k] for k in FIELDS if k in data}
|
||||
else:
|
||||
u = urlparse(text)
|
||||
if u.scheme != "netclip":
|
||||
raise ValueError("keine NetClip-Verbindungsdaten")
|
||||
q = {k: v[0] for k, v in parse_qs(u.query).items()}
|
||||
result = {"server": q.get("server", ""), "token": q.get("token", ""),
|
||||
"server_key": q.get("key", ""), "verify_tls": q.get("insecure") != "1"}
|
||||
if not result.get("server") or not result.get("token"):
|
||||
raise ValueError("Server oder Token fehlt")
|
||||
return result
|
||||
|
||||
|
||||
def qr_image(data: str):
|
||||
"""QR-Code als QImage (benoetigt das Paket 'qrcode')."""
|
||||
import qrcode
|
||||
from PySide6.QtGui import QColor, QImage, QPainter
|
||||
|
||||
qr = qrcode.QRCode(error_correction=qrcode.constants.ERROR_CORRECT_M, border=4)
|
||||
qr.add_data(data)
|
||||
qr.make(fit=True)
|
||||
matrix = qr.get_matrix()
|
||||
scale = max(4, 360 // len(matrix))
|
||||
size = len(matrix) * scale
|
||||
img = QImage(size, size, QImage.Format.Format_RGB32)
|
||||
img.fill(QColor("white"))
|
||||
p = QPainter(img)
|
||||
for y, row in enumerate(matrix):
|
||||
for x, on in enumerate(row):
|
||||
if on:
|
||||
p.fillRect(x * scale, y * scale, scale, scale, QColor("black"))
|
||||
p.end()
|
||||
return img
|
||||
@@ -0,0 +1,331 @@
|
||||
"""Verbindet Zwischenablage, Server und Oberflaeche."""
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
import traceback
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtCore import QObject, QTimer, Signal
|
||||
|
||||
from . import config, payload
|
||||
from .api import Api, Live
|
||||
from .clipboard import create_backend
|
||||
from .crypto import CryptoError, Keys
|
||||
from .payload import ClipContent, human
|
||||
|
||||
|
||||
class Entry:
|
||||
"""Ein Verlaufseintrag (Metadaten entschlüsselt, Inhalt liegt auf dem Server)."""
|
||||
|
||||
def __init__(self, clip: dict, meta: dict, own: bool):
|
||||
self.id = clip["id"]
|
||||
self.ts = clip["ts"] / 1000
|
||||
self.size = clip["size"]
|
||||
self.meta = meta
|
||||
self.kind = meta.get("kind", "text")
|
||||
self.own = own
|
||||
|
||||
@property
|
||||
def device(self) -> str:
|
||||
return self.meta.get("device", "?")
|
||||
|
||||
@property
|
||||
def title(self) -> str:
|
||||
preview = self.meta.get("preview", "")
|
||||
if self.kind == "text":
|
||||
return " ".join(preview.split())[:120] or "(leer)"
|
||||
if self.kind == "files":
|
||||
names = self.meta.get("names", [])
|
||||
more = f" (+{len(names) - 3})" if len(names) > 3 else ""
|
||||
return ", ".join(names[:3]) + more
|
||||
return preview
|
||||
|
||||
@property
|
||||
def subtitle(self) -> str:
|
||||
t = time.strftime("%d.%m. %H:%M", time.localtime(self.ts))
|
||||
extra = ""
|
||||
if self.kind == "files":
|
||||
extra = f" · {self.meta.get('count', 0)} Datei(en)"
|
||||
elif self.kind == "text":
|
||||
extra = f" · {self.meta.get('chars', 0)} Zeichen"
|
||||
return f"{self.device} · {t} · {human(self.meta.get('size', self.size))}{extra}"
|
||||
|
||||
def inline_text(self) -> str | None:
|
||||
"""Kurze Texte stecken komplett in der Vorschau -> kein Download noetig."""
|
||||
if self.kind == "text" and self.meta.get("chars", -1) == len(self.meta.get("preview", "")):
|
||||
return self.meta["preview"]
|
||||
return None
|
||||
|
||||
|
||||
class Controller(QObject):
|
||||
history_changed = Signal()
|
||||
state_changed = Signal() # Verbindung/Geräte/Pause
|
||||
notify = Signal(str, str) # Titel, Text
|
||||
progress = Signal(str, int) # Text, Prozent (-1 = fertig)
|
||||
_ws_message = Signal(dict)
|
||||
_ws_state = Signal(bool, str)
|
||||
_apply_ready = Signal(object, bool) # ClipContent, automatisch?
|
||||
_error = Signal(str)
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.cfg = config.load()
|
||||
self.keys: Keys | None = None
|
||||
self.api: Api | None = None
|
||||
self.live: Live | None = None
|
||||
self.history: list[Entry] = []
|
||||
self.devices: list[str] = []
|
||||
self.connected = False
|
||||
self.last_error = ""
|
||||
self.paused = False
|
||||
self.pool = ThreadPoolExecutor(max_workers=3)
|
||||
self.last_applied_fp = ""
|
||||
self.last_sent_fp = ""
|
||||
self.local_generation = 0 # zaehlt lokale Kopiervorgaenge
|
||||
|
||||
self.backend = create_backend()
|
||||
self._debounce = QTimer(self, singleShot=True, interval=350, timeout=self._check_clipboard)
|
||||
self.backend.changed.connect(self._debounce.start)
|
||||
self._ws_message.connect(self._on_ws_message)
|
||||
self._ws_state.connect(self._on_ws_state)
|
||||
self._apply_ready.connect(self._write_clipboard)
|
||||
self._error.connect(lambda msg: self.notify.emit("NetClip – Fehler", msg))
|
||||
self._cleanup = QTimer(self, interval=3600_000, timeout=self._cleanup_cache)
|
||||
self._cleanup.start()
|
||||
self._cleanup_cache()
|
||||
self.connect_server()
|
||||
|
||||
# ---------------------------------------------------------------- Server
|
||||
def connect_server(self):
|
||||
if self.live:
|
||||
self.live.stop()
|
||||
self.live = None
|
||||
self.history, self.devices, self.connected = [], [], False
|
||||
self.history_changed.emit()
|
||||
if not self.cfg.configured:
|
||||
self.last_error = "Nicht eingerichtet"
|
||||
self.state_changed.emit()
|
||||
return
|
||||
self.keys = Keys(self.cfg.token)
|
||||
self.api = Api(self.cfg, self.keys)
|
||||
self.live = Live(self.api, self._ws_message.emit, self._ws_state.emit)
|
||||
self.live.start()
|
||||
|
||||
def apply_config(self, cfg: config.Config):
|
||||
reconnect = (cfg.server, cfg.token, cfg.server_key, cfg.verify_tls, cfg.ca_file, cfg.device_name) != \
|
||||
(self.cfg.server, self.cfg.token, self.cfg.server_key, self.cfg.verify_tls,
|
||||
self.cfg.ca_file, self.cfg.device_name)
|
||||
self.cfg = cfg
|
||||
config.save(cfg)
|
||||
if reconnect:
|
||||
self.connect_server()
|
||||
self.state_changed.emit()
|
||||
|
||||
def shutdown(self):
|
||||
if self.live:
|
||||
self.live.stop(wait=2)
|
||||
self.live = None
|
||||
if hasattr(self.backend, "stop"):
|
||||
self.backend.stop()
|
||||
self.pool.shutdown(wait=False, cancel_futures=True)
|
||||
|
||||
def _on_ws_state(self, connected: bool, error: str):
|
||||
self.connected = connected
|
||||
if error or connected:
|
||||
self.last_error = error
|
||||
if not connected:
|
||||
self.devices = []
|
||||
self.state_changed.emit()
|
||||
|
||||
def _entry(self, clip: dict) -> Entry | None:
|
||||
try:
|
||||
meta = self.keys.decrypt_meta(clip["meta"])
|
||||
except CryptoError:
|
||||
return None
|
||||
return Entry(clip, meta, clip["device"] == self.cfg.device_id)
|
||||
|
||||
def _on_ws_message(self, msg: dict):
|
||||
t = msg.get("type")
|
||||
if t == "hello":
|
||||
self.history = [e for e in map(self._entry, msg.get("clips", [])) if e]
|
||||
self.history_changed.emit()
|
||||
elif t == "clip":
|
||||
entry = self._entry(msg["clip"])
|
||||
if not entry or any(e.id == entry.id for e in self.history):
|
||||
return
|
||||
self.history.insert(0, entry)
|
||||
self.history_changed.emit()
|
||||
if not entry.own and self.cfg.receive_enabled and not self.paused:
|
||||
if entry.size <= self.cfg.max_auto_receive_mb * 1024 * 1024:
|
||||
self.fetch(entry, auto=True)
|
||||
elif self.cfg.notifications:
|
||||
self.notify.emit(f"Großer Clip von {entry.device}",
|
||||
f"{entry.title} ({human(entry.size)}) – im Verlauf anklicken zum Laden")
|
||||
elif t == "deleted":
|
||||
self.history = [e for e in self.history if e.id != msg.get("id")]
|
||||
self.history_changed.emit()
|
||||
elif t == "cleared":
|
||||
self.history = []
|
||||
self.history_changed.emit()
|
||||
elif t == "devices":
|
||||
names = []
|
||||
for d in msg.get("devices", []):
|
||||
if d.get("device") == self.cfg.device_id:
|
||||
continue
|
||||
try:
|
||||
names.append(self.keys.decrypt_meta(d["label"]).get("name", "?"))
|
||||
except (CryptoError, KeyError):
|
||||
names.append(d.get("device", "?"))
|
||||
self.devices = names
|
||||
self.state_changed.emit()
|
||||
|
||||
# ---------------------------------------------------------------- Senden
|
||||
def set_paused(self, paused: bool):
|
||||
self.paused = paused
|
||||
self.state_changed.emit()
|
||||
|
||||
def _check_clipboard(self):
|
||||
try:
|
||||
content = self.backend.read()
|
||||
except Exception as e: # noqa: BLE001
|
||||
print("Zwischenablage nicht lesbar:", e)
|
||||
return
|
||||
if content is None or content.fp in (self.last_applied_fp, self.last_sent_fp):
|
||||
return
|
||||
self.local_generation += 1
|
||||
cfg = self.cfg
|
||||
if self.paused or not cfg.send_enabled or not self.api:
|
||||
return
|
||||
if not {"text": cfg.send_text, "image": cfg.send_images, "files": cfg.send_files}[content.kind]:
|
||||
return
|
||||
if content.kind == "files":
|
||||
limit = cfg.max_send_mb * 1024 * 1024
|
||||
if payload.files_size(content.paths, limit) > limit:
|
||||
self.notify.emit("NetClip", f"Nicht gesendet: Dateien größer als {cfg.max_send_mb} MB")
|
||||
return
|
||||
self.last_sent_fp = content.fp
|
||||
self.pool.submit(self._upload, content)
|
||||
|
||||
def send_current(self):
|
||||
"""Manuell senden (Menue), auch wenn identisch zum letzten Mal."""
|
||||
self.last_sent_fp = self.last_applied_fp = ""
|
||||
was_paused, self.paused = self.paused, False
|
||||
self._check_clipboard()
|
||||
self.paused = was_paused
|
||||
|
||||
def _upload(self, content: ClipContent):
|
||||
plain = enc = None
|
||||
try:
|
||||
label = {"text": "Text", "image": "Bild", "files": "Dateien"}[content.kind]
|
||||
self.progress.emit(f"Packe {label} …", 0)
|
||||
meta, plain = payload.build(content, self.cfg.device_name)
|
||||
fd, enc = tempfile.mkstemp(prefix="netclip-", suffix=".enc")
|
||||
enc = Path(enc)
|
||||
with open(plain, "rb") as src, os.fdopen(fd, "wb") as dst:
|
||||
self.keys.encrypt_stream(src, dst)
|
||||
self.api.upload(meta, enc, self._progress_cb(f"Sende {label}"))
|
||||
except Exception as e: # noqa: BLE001
|
||||
traceback.print_exc()
|
||||
self.last_sent_fp = ""
|
||||
self._error.emit(f"Senden fehlgeschlagen: {e}")
|
||||
finally:
|
||||
self.progress.emit("", -1)
|
||||
for p in (plain, enc):
|
||||
if p:
|
||||
Path(p).unlink(missing_ok=True)
|
||||
|
||||
def _progress_cb(self, text: str):
|
||||
last = [0.0]
|
||||
|
||||
def cb(done, total):
|
||||
now = time.monotonic()
|
||||
if total and (now - last[0] > 0.2 or done >= total):
|
||||
last[0] = now
|
||||
self.progress.emit(f"{text} … {human(done)} / {human(total)}", int(done * 100 / total))
|
||||
return cb
|
||||
|
||||
# ---------------------------------------------------------------- Empfangen
|
||||
def fetch(self, entry: Entry, auto=False, save_to: str | None = None):
|
||||
text = entry.inline_text()
|
||||
if text is not None and not save_to:
|
||||
self._write_clipboard(ClipContent("text", text=text), auto)
|
||||
return
|
||||
generation = self.local_generation
|
||||
self.pool.submit(self._fetch, entry, auto, generation, save_to)
|
||||
|
||||
def _fetch(self, entry: Entry, auto: bool, generation: int, save_to: str | None):
|
||||
enc = plain = None
|
||||
try:
|
||||
target = payload.received_dir() / entry.id
|
||||
if entry.kind == "files" and target.exists() and not save_to:
|
||||
content = ClipContent("files", paths=sorted(str(p) for p in target.iterdir()))
|
||||
else:
|
||||
fd, enc = tempfile.mkstemp(prefix="netclip-", suffix=".enc")
|
||||
os.close(fd)
|
||||
enc = Path(enc)
|
||||
self.api.download(entry.id, enc, self._progress_cb(f"Lade von {entry.device}"))
|
||||
fd, plain = tempfile.mkstemp(prefix="netclip-", suffix=".plain")
|
||||
plain = Path(plain)
|
||||
with open(enc, "rb") as src, os.fdopen(fd, "wb") as dst:
|
||||
self.keys.decrypt_stream(src, dst)
|
||||
enc.unlink()
|
||||
enc = None
|
||||
content = payload.materialize(entry.kind, plain, entry.id)
|
||||
if save_to:
|
||||
self._save(content, entry, Path(save_to))
|
||||
self.notify.emit("NetClip", f"Gespeichert in {save_to}")
|
||||
return
|
||||
# Während des Downloads lokal etwas Neues kopiert? Dann nicht überschreiben.
|
||||
if auto and generation != self.local_generation:
|
||||
return
|
||||
self._apply_ready.emit(content, auto)
|
||||
if auto and self.cfg.notifications:
|
||||
self.notify.emit(f"Von {entry.device}", entry.title)
|
||||
except Exception as e: # noqa: BLE001
|
||||
traceback.print_exc()
|
||||
self._error.emit(f"Empfangen fehlgeschlagen: {e}")
|
||||
finally:
|
||||
self.progress.emit("", -1)
|
||||
for p in (enc, plain):
|
||||
if p:
|
||||
Path(p).unlink(missing_ok=True)
|
||||
|
||||
@staticmethod
|
||||
def _save(content: ClipContent, entry: Entry, folder: Path):
|
||||
import shutil
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
if content.kind == "files":
|
||||
for p in content.paths:
|
||||
dest = folder / Path(p).name
|
||||
if Path(p).is_dir():
|
||||
shutil.copytree(p, dest, dirs_exist_ok=True)
|
||||
else:
|
||||
shutil.copy2(p, dest)
|
||||
elif content.kind == "image":
|
||||
(folder / f"netclip-{time.strftime('%Y%m%d-%H%M%S', time.localtime(entry.ts))}.png").write_bytes(content.png)
|
||||
else:
|
||||
(folder / f"netclip-{time.strftime('%Y%m%d-%H%M%S', time.localtime(entry.ts))}.txt").write_text(
|
||||
content.text, encoding="utf-8")
|
||||
|
||||
def _write_clipboard(self, content: ClipContent, auto: bool):
|
||||
try:
|
||||
self.last_applied_fp = self.backend.write(content)
|
||||
except Exception as e: # noqa: BLE001
|
||||
self._error.emit(f"Zwischenablage nicht beschreibbar: {e}")
|
||||
|
||||
# ---------------------------------------------------------------- Verwaltung
|
||||
def delete(self, entry: Entry):
|
||||
self.pool.submit(self._call, self.api.delete, entry.id)
|
||||
|
||||
def clear(self):
|
||||
self.pool.submit(self._call, self.api.clear)
|
||||
|
||||
def _call(self, fn, *args):
|
||||
try:
|
||||
fn(*args)
|
||||
except Exception as e: # noqa: BLE001
|
||||
self._error.emit(str(e))
|
||||
|
||||
def _cleanup_cache(self):
|
||||
payload.cleanup_received(self.cfg.keep_received_days)
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Ende-zu-Ende-Verschluesselung. Muss mit client-android/.../Crypto.kt uebereinstimmen.
|
||||
|
||||
Ableitung: master = PBKDF2-HMAC-SHA256(token, "netclip-v1", 200000, 32)
|
||||
room_id = hex(HMAC-SHA256(master, "room-id")) -> sieht der Server
|
||||
key = HMAC-SHA256(master, "enc-key") -> bleibt beim Client
|
||||
Meta: base64( nonce[12] || AES-256-GCM(key, nonce, json, aad="meta") )
|
||||
Blob: "NCB1" || prefix[8] || Chunks
|
||||
Chunk = flag[1] || len[4, BE] || AES-256-GCM(key, prefix||counter[4, BE], data, aad=flag)
|
||||
flag = 1 beim letzten Chunk (verhindert Abschneiden), sonst 0
|
||||
"""
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
|
||||
SALT = b"netclip-v1"
|
||||
ITERATIONS = 200_000
|
||||
MAGIC = b"NCB1"
|
||||
CHUNK = 1024 * 1024
|
||||
|
||||
|
||||
class CryptoError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class Keys:
|
||||
def __init__(self, token: str):
|
||||
master = hashlib.pbkdf2_hmac("sha256", token.encode("utf-8"), SALT, ITERATIONS, 32)
|
||||
self.room_id = hmac.new(master, b"room-id", hashlib.sha256).hexdigest()
|
||||
self.key = hmac.new(master, b"enc-key", hashlib.sha256).digest()
|
||||
self.aead = AESGCM(self.key)
|
||||
|
||||
def encrypt_meta(self, meta: dict) -> str:
|
||||
nonce = os.urandom(12)
|
||||
ct = self.aead.encrypt(nonce, json.dumps(meta).encode("utf-8"), b"meta")
|
||||
return base64.b64encode(nonce + ct).decode("ascii")
|
||||
|
||||
def decrypt_meta(self, data: str) -> dict:
|
||||
try:
|
||||
raw = base64.b64decode(data)
|
||||
return json.loads(self.aead.decrypt(raw[:12], raw[12:], b"meta"))
|
||||
except Exception as e: # falscher Token oder manipuliert
|
||||
raise CryptoError(str(e)) from e
|
||||
|
||||
def encrypt_label(self, text: str) -> str:
|
||||
return self.encrypt_meta({"name": text})
|
||||
|
||||
def encrypt_stream(self, src, dst):
|
||||
"""Liest Klartext aus src (Datei-Objekt), schreibt Blob nach dst."""
|
||||
prefix = os.urandom(8)
|
||||
dst.write(MAGIC + prefix)
|
||||
counter = 0
|
||||
buf = src.read(CHUNK)
|
||||
while True:
|
||||
nxt = src.read(CHUNK) if buf else b""
|
||||
last = not nxt
|
||||
flag = b"\x01" if last else b"\x00"
|
||||
ct = self.aead.encrypt(prefix + struct.pack(">I", counter), buf, flag)
|
||||
dst.write(flag + struct.pack(">I", len(ct)) + ct)
|
||||
if last:
|
||||
return
|
||||
counter += 1
|
||||
buf = nxt
|
||||
|
||||
def decrypt_stream(self, src, dst):
|
||||
if src.read(4) != MAGIC:
|
||||
raise CryptoError("kein NetClip-Blob")
|
||||
prefix = src.read(8)
|
||||
counter = 0
|
||||
while True:
|
||||
head = src.read(5)
|
||||
if len(head) != 5:
|
||||
raise CryptoError("Blob abgeschnitten")
|
||||
flag, length = head[:1], struct.unpack(">I", head[1:])[0]
|
||||
if length > CHUNK + 16:
|
||||
raise CryptoError("Chunk zu gross")
|
||||
ct = src.read(length)
|
||||
try:
|
||||
dst.write(self.aead.decrypt(prefix + struct.pack(">I", counter), ct, flag))
|
||||
except Exception as e:
|
||||
raise CryptoError("Entschluesselung fehlgeschlagen") from e
|
||||
if flag == b"\x01":
|
||||
return
|
||||
counter += 1
|
||||
@@ -0,0 +1,150 @@
|
||||
"""Umwandlung Zwischenablage-Inhalt <-> Klartext-Payload (+ Meta).
|
||||
|
||||
text : UTF-8
|
||||
image : PNG
|
||||
files : ZIP mit den kopierten Dateien/Ordnern auf oberster Ebene
|
||||
"""
|
||||
import hashlib
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import time
|
||||
import zipfile
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from .config import cache_dir
|
||||
|
||||
|
||||
@dataclass
|
||||
class ClipContent:
|
||||
kind: str # text | image | files
|
||||
text: str = ""
|
||||
png: bytes = b""
|
||||
paths: list[str] = field(default_factory=list)
|
||||
fp: str = "" # Fingerabdruck zur Schleifen-Vermeidung
|
||||
|
||||
@staticmethod
|
||||
def fingerprint(kind: str, data: bytes) -> str:
|
||||
return kind + ":" + hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def text_fp(text: str) -> str:
|
||||
return ClipContent.fingerprint("text", text.encode("utf-8"))
|
||||
|
||||
|
||||
def files_fp(paths: list[str]) -> str:
|
||||
norm = sorted(os.path.normcase(os.path.abspath(p)) for p in paths)
|
||||
return ClipContent.fingerprint("files", "\n".join(norm).encode("utf-8"))
|
||||
|
||||
|
||||
def human(n: float) -> str:
|
||||
for unit in ("B", "KB", "MB", "GB"):
|
||||
if n < 1024 or unit == "GB":
|
||||
return f"{n:.0f} {unit}" if unit == "B" else f"{n:.1f} {unit}"
|
||||
n /= 1024
|
||||
return f"{n:.1f} GB"
|
||||
|
||||
|
||||
def files_size(paths: list[str], limit: int) -> int:
|
||||
"""Summe der Dateigroessen; bricht ab, sobald limit ueberschritten ist."""
|
||||
total = 0
|
||||
for p in paths:
|
||||
p = Path(p)
|
||||
if p.is_file():
|
||||
total += p.stat().st_size
|
||||
else:
|
||||
for root, _dirs, files in os.walk(p):
|
||||
for name in files:
|
||||
try:
|
||||
total += os.path.getsize(os.path.join(root, name))
|
||||
except OSError:
|
||||
pass
|
||||
if total > limit:
|
||||
return total
|
||||
if total > limit:
|
||||
return total
|
||||
return total
|
||||
|
||||
|
||||
def build(content: ClipContent, device_name: str) -> tuple[dict, Path]:
|
||||
"""Schreibt die Payload in eine temporaere Datei. Rueckgabe: (meta, pfad)."""
|
||||
fd, tmp = tempfile.mkstemp(prefix="netclip-", suffix=".plain")
|
||||
tmp = Path(tmp)
|
||||
meta = {"v": 1, "kind": content.kind, "device": device_name}
|
||||
with os.fdopen(fd, "wb") as f:
|
||||
if content.kind == "text":
|
||||
data = content.text.encode("utf-8")
|
||||
f.write(data)
|
||||
meta.update(preview=content.text[:300], chars=len(content.text))
|
||||
elif content.kind == "image":
|
||||
f.write(content.png)
|
||||
meta.update(mime="image/png", preview=f"Bild ({human(len(content.png))})")
|
||||
else:
|
||||
names, count = [], 0
|
||||
with zipfile.ZipFile(f, "w", zipfile.ZIP_DEFLATED, compresslevel=1) as z:
|
||||
for p in content.paths:
|
||||
p = Path(p)
|
||||
names.append(p.name)
|
||||
if p.is_dir():
|
||||
z.write(p, p.name + "/")
|
||||
for root, dirs, files in os.walk(p):
|
||||
rel_root = Path(p.name) / Path(root).relative_to(p)
|
||||
for d in dirs:
|
||||
z.write(Path(root) / d, (rel_root / d).as_posix() + "/")
|
||||
for name in files:
|
||||
try:
|
||||
z.write(Path(root) / name, (rel_root / name).as_posix())
|
||||
count += 1
|
||||
except OSError:
|
||||
pass # z.B. gesperrte Datei unter Windows
|
||||
elif p.is_file():
|
||||
z.write(p, p.name)
|
||||
count += 1
|
||||
meta.update(names=names, count=count, preview=", ".join(names)[:300])
|
||||
meta["size"] = tmp.stat().st_size
|
||||
return meta, tmp
|
||||
|
||||
|
||||
def received_dir() -> Path:
|
||||
return cache_dir() / "received"
|
||||
|
||||
|
||||
def materialize(kind: str, plain: Path, clip_id: str) -> ClipContent:
|
||||
"""Erzeugt aus einer entschluesselten Payload den Zwischenablage-Inhalt.
|
||||
Dateien werden nach <cache>/received/<clip_id>/ entpackt."""
|
||||
if kind == "text":
|
||||
return ClipContent("text", text=plain.read_text("utf-8"))
|
||||
if kind == "image":
|
||||
return ClipContent("image", png=plain.read_bytes())
|
||||
target = received_dir() / clip_id
|
||||
if not target.exists():
|
||||
part = target.with_name(clip_id + ".part")
|
||||
shutil.rmtree(part, ignore_errors=True)
|
||||
part.mkdir(parents=True)
|
||||
with zipfile.ZipFile(plain) as z:
|
||||
for info in z.infolist():
|
||||
# Pfad-Traversal verhindern
|
||||
dest = (part / info.filename).resolve()
|
||||
if part.resolve() not in dest.parents and dest != part.resolve():
|
||||
continue
|
||||
z.extract(info, part)
|
||||
if not info.is_dir():
|
||||
mtime = time.mktime(info.date_time + (0, 0, -1))
|
||||
os.utime(dest, (mtime, mtime))
|
||||
os.replace(part, target)
|
||||
paths = sorted(str(p) for p in target.iterdir())
|
||||
return ClipContent("files", paths=paths)
|
||||
|
||||
|
||||
def cleanup_received(days: int):
|
||||
base = received_dir()
|
||||
if not base.exists():
|
||||
return
|
||||
cutoff = time.time() - days * 86400
|
||||
for d in base.iterdir():
|
||||
try:
|
||||
if d.stat().st_mtime < cutoff:
|
||||
shutil.rmtree(d, ignore_errors=True)
|
||||
except OSError:
|
||||
pass
|
||||
@@ -0,0 +1,420 @@
|
||||
"""Tray-Icon, Verlaufsfenster und Einstellungen."""
|
||||
import dataclasses
|
||||
import secrets
|
||||
|
||||
from PySide6.QtCore import QSize, Qt
|
||||
from PySide6.QtGui import QAction, QColor, QIcon, QKeySequence, QPainter, QPixmap, QShortcut
|
||||
from PySide6.QtWidgets import (QApplication, QCheckBox, QDialog, QDialogButtonBox, QFileDialog, QFormLayout,
|
||||
QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem, QMenu,
|
||||
QMessageBox, QProgressBar, QPushButton, QSpinBox, QStyle, QSystemTrayIcon,
|
||||
QVBoxLayout, QWidget)
|
||||
|
||||
from . import autostart, config, connection
|
||||
from .controller import Controller, Entry
|
||||
|
||||
KIND_ICON = {"text": QStyle.StandardPixmap.SP_FileDialogDetailedView,
|
||||
"image": QStyle.StandardPixmap.SP_DesktopIcon,
|
||||
"files": QStyle.StandardPixmap.SP_DirIcon}
|
||||
|
||||
|
||||
def app_icon(status: str = "") -> QIcon:
|
||||
"""Zeichnet ein Klemmbrett-Symbol, optional mit Statuspunkt (ok/off/pause)."""
|
||||
pm = QPixmap(64, 64)
|
||||
pm.fill(Qt.GlobalColor.transparent)
|
||||
p = QPainter(pm)
|
||||
p.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
p.setPen(Qt.PenStyle.NoPen)
|
||||
p.setBrush(QColor("#3b6ea8"))
|
||||
p.drawRoundedRect(10, 8, 44, 52, 7, 7)
|
||||
p.setBrush(QColor("#ffffff"))
|
||||
p.drawRoundedRect(16, 16, 32, 38, 3, 3)
|
||||
p.setBrush(QColor("#7a8794"))
|
||||
p.drawRoundedRect(22, 3, 20, 11, 3, 3)
|
||||
p.setBrush(QColor("#3b6ea8"))
|
||||
for y in (24, 32, 40):
|
||||
p.drawRect(21, y, 22, 3)
|
||||
color = {"ok": "#2fa84f", "off": "#c0392b", "pause": "#e69b00"}.get(status)
|
||||
if color:
|
||||
p.setBrush(QColor("#ffffff"))
|
||||
p.drawEllipse(38, 38, 26, 26)
|
||||
p.setBrush(QColor(color))
|
||||
p.drawEllipse(41, 41, 20, 20)
|
||||
p.end()
|
||||
return QIcon(pm)
|
||||
|
||||
|
||||
class SettingsDialog(QDialog):
|
||||
def __init__(self, cfg: config.Config, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("NetClip – Einstellungen")
|
||||
self.setWindowIcon(app_icon())
|
||||
self.cfg = dataclasses.replace(cfg)
|
||||
form = QFormLayout()
|
||||
|
||||
self.server = QLineEdit(cfg.server, placeholderText="https://clip.example.de")
|
||||
form.addRow("Server-URL:", self.server)
|
||||
|
||||
self.token = QLineEdit(cfg.token, echoMode=QLineEdit.EchoMode.Password)
|
||||
gen = QPushButton("Erzeugen")
|
||||
gen.clicked.connect(self._generate)
|
||||
show = QPushButton("Anzeigen", checkable=True)
|
||||
show.toggled.connect(lambda on: self.token.setEchoMode(
|
||||
QLineEdit.EchoMode.Normal if on else QLineEdit.EchoMode.Password))
|
||||
row = QHBoxLayout()
|
||||
row.addWidget(self.token, 1)
|
||||
row.addWidget(show)
|
||||
row.addWidget(gen)
|
||||
form.addRow("Token:", row)
|
||||
form.addRow("", QLabel("<small>Alle Geräte mit demselben Token teilen sich die Zwischenablage.<br>"
|
||||
"Der Token verlässt das Gerät nie, er dient auch als Schlüssel.</small>"))
|
||||
|
||||
self.server_key = QLineEdit(cfg.server_key, echoMode=QLineEdit.EchoMode.Password,
|
||||
placeholderText="nur falls SERVER_KEY gesetzt ist")
|
||||
form.addRow("Server-Schlüssel:", self.server_key)
|
||||
self.verify = QCheckBox("TLS-Zertifikat prüfen (aus = selbstsigniert ohne Prüfung)", checked=cfg.verify_tls)
|
||||
form.addRow("", self.verify)
|
||||
self.ca_file = QLineEdit(cfg.ca_file, placeholderText="optional: fullchain.pem des Servers")
|
||||
browse = QPushButton("…")
|
||||
browse.clicked.connect(self._browse_ca)
|
||||
row = QHBoxLayout()
|
||||
row.addWidget(self.ca_file, 1)
|
||||
row.addWidget(browse)
|
||||
form.addRow("Zertifikat:", row)
|
||||
|
||||
self.device_name = QLineEdit(cfg.device_name)
|
||||
form.addRow("Gerätename:", self.device_name)
|
||||
|
||||
self.send = QCheckBox("Eigene Zwischenablage senden", checked=cfg.send_enabled)
|
||||
self.receive = QCheckBox("Empfangenes automatisch in die Zwischenablage legen", checked=cfg.receive_enabled)
|
||||
self.send_text = QCheckBox("Text", checked=cfg.send_text)
|
||||
self.send_images = QCheckBox("Bilder", checked=cfg.send_images)
|
||||
self.send_files = QCheckBox("Dateien und Ordner", checked=cfg.send_files)
|
||||
types = QHBoxLayout()
|
||||
for w in (self.send_text, self.send_images, self.send_files):
|
||||
types.addWidget(w)
|
||||
types.addStretch()
|
||||
form.addRow("Synchronisieren:", self.send)
|
||||
form.addRow("", types)
|
||||
form.addRow("", self.receive)
|
||||
|
||||
self.max_send = self._spin(cfg.max_send_mb, 1, 100000, " MB")
|
||||
form.addRow("Max. Größe senden:", self.max_send)
|
||||
self.max_recv = self._spin(cfg.max_auto_receive_mb, 0, 100000, " MB")
|
||||
form.addRow("Automatisch laden bis:", self.max_recv)
|
||||
self.keep = self._spin(cfg.keep_received_days, 1, 365, " Tage")
|
||||
form.addRow("Empfangene Dateien behalten:", self.keep)
|
||||
self.notify = QCheckBox("Benachrichtigungen anzeigen", checked=cfg.notifications)
|
||||
form.addRow("", self.notify)
|
||||
self.autostart = QCheckBox("Beim Anmelden starten", checked=cfg.autostart)
|
||||
form.addRow("", self.autostart)
|
||||
|
||||
share = QHBoxLayout()
|
||||
for text, slot in (("QR-Code für Android …", self._show_qr), ("Exportieren …", self._export),
|
||||
("Importieren …", self._import)):
|
||||
b = QPushButton(text)
|
||||
b.clicked.connect(slot)
|
||||
share.addWidget(b)
|
||||
share.addStretch()
|
||||
form.addRow("Verbindung teilen:", share)
|
||||
|
||||
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Save | QDialogButtonBox.StandardButton.Cancel)
|
||||
buttons.accepted.connect(self.accept)
|
||||
buttons.rejected.connect(self.reject)
|
||||
layout = QVBoxLayout(self)
|
||||
layout.addLayout(form)
|
||||
layout.addWidget(buttons)
|
||||
self.resize(560, 0)
|
||||
|
||||
@staticmethod
|
||||
def _spin(value, lo, hi, suffix):
|
||||
s = QSpinBox(minimum=lo, maximum=hi, suffix=suffix)
|
||||
s.setValue(value)
|
||||
return s
|
||||
|
||||
def _generate(self):
|
||||
if self.token.text() and QMessageBox.question(
|
||||
self, "Neuer Token", "Neuen Token erzeugen? Andere Geräte müssen dann auch den neuen Token "
|
||||
"bekommen, der alte Verlauf ist nicht mehr sichtbar.") != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
self.token.setText(secrets.token_urlsafe(24))
|
||||
self.token.setEchoMode(QLineEdit.EchoMode.Normal)
|
||||
|
||||
def _browse_ca(self):
|
||||
path, _ = QFileDialog.getOpenFileName(self, "Server-Zertifikat", "", "Zertifikate (*.pem *.crt *.cer);;Alle (*)")
|
||||
if path:
|
||||
self.ca_file.setText(path)
|
||||
|
||||
def _uri(self) -> str | None:
|
||||
c = self.result_config()
|
||||
if not c.configured:
|
||||
QMessageBox.information(self, "NetClip", "Bitte zuerst Server-URL und Token eintragen.")
|
||||
return None
|
||||
return connection.to_uri(c.server, c.token, c.server_key, c.verify_tls)
|
||||
|
||||
def _show_qr(self):
|
||||
uri = self._uri()
|
||||
if not uri:
|
||||
return
|
||||
try:
|
||||
img = connection.qr_image(uri)
|
||||
except ImportError:
|
||||
QMessageBox.warning(self, "NetClip", "Python-Paket 'qrcode' fehlt (pip install qrcode).")
|
||||
return
|
||||
dlg = QDialog(self)
|
||||
dlg.setWindowTitle("NetClip – mit Android verbinden")
|
||||
layout = QVBoxLayout(dlg)
|
||||
label = QLabel()
|
||||
label.setPixmap(QPixmap.fromImage(img))
|
||||
label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
layout.addWidget(label)
|
||||
layout.addWidget(QLabel("In der NetClip-App auf <b>QR-Code scannen</b> tippen.<br>"
|
||||
"<small>Der Code enthält den Token – nicht weitergeben oder abfotografieren lassen.</small>"))
|
||||
close = QPushButton("Schließen")
|
||||
close.clicked.connect(dlg.accept)
|
||||
layout.addWidget(close)
|
||||
dlg.exec()
|
||||
|
||||
def _export(self):
|
||||
uri = self._uri()
|
||||
if not uri:
|
||||
return
|
||||
path, _ = QFileDialog.getSaveFileName(self, "Verbindung exportieren", "verbindung.netclip",
|
||||
"NetClip-Verbindung (*.netclip)")
|
||||
if path:
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write(uri + "\n")
|
||||
QMessageBox.information(self, "NetClip", "Exportiert. Die Datei enthält den Token im Klartext – "
|
||||
"nur sicher weitergeben (z.B. USB-Stick) und danach löschen.")
|
||||
|
||||
def _import(self):
|
||||
path, _ = QFileDialog.getOpenFileName(self, "Verbindung importieren", "",
|
||||
"NetClip-Verbindung (*.netclip);;Alle (*)")
|
||||
if not path:
|
||||
return
|
||||
try:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
data = connection.parse(f.read())
|
||||
except (OSError, ValueError) as e:
|
||||
QMessageBox.warning(self, "NetClip", f"Import fehlgeschlagen: {e}")
|
||||
return
|
||||
self.server.setText(data["server"])
|
||||
self.token.setText(data["token"])
|
||||
self.server_key.setText(data.get("server_key", ""))
|
||||
self.verify.setChecked(data.get("verify_tls", True))
|
||||
|
||||
def result_config(self) -> config.Config:
|
||||
c = self.cfg
|
||||
c.server = self.server.text().strip().rstrip("/")
|
||||
if c.server and "://" not in c.server:
|
||||
c.server = "https://" + c.server
|
||||
c.token = self.token.text().strip()
|
||||
c.server_key = self.server_key.text().strip()
|
||||
c.verify_tls = self.verify.isChecked()
|
||||
c.ca_file = self.ca_file.text().strip()
|
||||
c.device_name = self.device_name.text().strip() or c.device_name
|
||||
c.send_enabled = self.send.isChecked()
|
||||
c.receive_enabled = self.receive.isChecked()
|
||||
c.send_text = self.send_text.isChecked()
|
||||
c.send_images = self.send_images.isChecked()
|
||||
c.send_files = self.send_files.isChecked()
|
||||
c.max_send_mb = self.max_send.value()
|
||||
c.max_auto_receive_mb = self.max_recv.value()
|
||||
c.keep_received_days = self.keep.value()
|
||||
c.notifications = self.notify.isChecked()
|
||||
c.autostart = self.autostart.isChecked()
|
||||
return c
|
||||
|
||||
|
||||
class HistoryWindow(QWidget):
|
||||
def __init__(self, ctl: Controller):
|
||||
super().__init__()
|
||||
self.ctl = ctl
|
||||
self.setWindowTitle("NetClip – Verlauf")
|
||||
self.setWindowIcon(app_icon())
|
||||
self.resize(560, 640)
|
||||
|
||||
self.search = QLineEdit(placeholderText="Suchen …", clearButtonEnabled=True)
|
||||
self.search.textChanged.connect(self.refresh)
|
||||
self.list = QListWidget()
|
||||
self.list.setIconSize(QSize(28, 28))
|
||||
self.list.setAlternatingRowColors(True)
|
||||
self.list.itemActivated.connect(lambda _item: self.use_selected())
|
||||
self.list.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
||||
self.list.customContextMenuRequested.connect(self._context_menu)
|
||||
|
||||
self.status = QLabel()
|
||||
self.progress = QProgressBar(maximum=100, visible=False, textVisible=True)
|
||||
|
||||
b_use = QPushButton("In Zwischenablage")
|
||||
b_use.clicked.connect(self.use_selected)
|
||||
b_save = QPushButton("Speichern unter …")
|
||||
b_save.clicked.connect(self.save_selected)
|
||||
b_del = QPushButton("Löschen")
|
||||
b_del.clicked.connect(self.delete_selected)
|
||||
b_clear = QPushButton("Verlauf leeren")
|
||||
b_clear.clicked.connect(self.clear)
|
||||
buttons = QHBoxLayout()
|
||||
for b in (b_use, b_save, b_del):
|
||||
buttons.addWidget(b)
|
||||
buttons.addStretch()
|
||||
buttons.addWidget(b_clear)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.addWidget(self.search)
|
||||
layout.addWidget(self.list, 1)
|
||||
layout.addLayout(buttons)
|
||||
layout.addWidget(self.progress)
|
||||
layout.addWidget(self.status)
|
||||
|
||||
QShortcut(QKeySequence(Qt.Key.Key_Delete), self.list, self.delete_selected)
|
||||
QShortcut(QKeySequence(Qt.Key.Key_Escape), self, self.hide)
|
||||
QShortcut(QKeySequence.StandardKey.Find, self, self.search.setFocus)
|
||||
|
||||
ctl.history_changed.connect(self.refresh)
|
||||
ctl.state_changed.connect(self.update_status)
|
||||
ctl.progress.connect(self.on_progress)
|
||||
self.refresh()
|
||||
self.update_status()
|
||||
|
||||
def refresh(self):
|
||||
selected = self.current()
|
||||
query = self.search.text().lower()
|
||||
self.list.clear()
|
||||
style = self.style()
|
||||
for e in self.ctl.history:
|
||||
if query and query not in (e.meta.get("preview", "") + " " + e.device).lower():
|
||||
continue
|
||||
item = QListWidgetItem(style.standardIcon(KIND_ICON.get(e.kind, KIND_ICON["text"])),
|
||||
f"{e.title}\n{e.subtitle}")
|
||||
item.setData(Qt.ItemDataRole.UserRole, e.id)
|
||||
item.setToolTip(e.meta.get("preview", "")[:1000])
|
||||
self.list.addItem(item)
|
||||
if selected and e.id == selected.id:
|
||||
self.list.setCurrentItem(item)
|
||||
if self.list.currentRow() < 0 and self.list.count():
|
||||
self.list.setCurrentRow(0)
|
||||
|
||||
def update_status(self):
|
||||
c = self.ctl
|
||||
if not c.cfg.configured:
|
||||
text = "Nicht eingerichtet – bitte Einstellungen öffnen"
|
||||
elif c.connected:
|
||||
others = ", ".join(c.devices) if c.devices else "keine weiteren Geräte online"
|
||||
text = f"Verbunden · {others}"
|
||||
else:
|
||||
text = "Getrennt" + (f": {c.last_error}" if c.last_error else " – verbinde …")
|
||||
if c.paused:
|
||||
text = "PAUSIERT · " + text
|
||||
self.status.setText(text)
|
||||
|
||||
def on_progress(self, text: str, pct: int):
|
||||
self.progress.setVisible(pct >= 0)
|
||||
if pct >= 0:
|
||||
self.progress.setValue(pct)
|
||||
self.progress.setFormat(text)
|
||||
|
||||
def current(self) -> Entry | None:
|
||||
item = self.list.currentItem()
|
||||
if not item:
|
||||
return None
|
||||
clip_id = item.data(Qt.ItemDataRole.UserRole)
|
||||
return next((e for e in self.ctl.history if e.id == clip_id), None)
|
||||
|
||||
def use_selected(self):
|
||||
e = self.current()
|
||||
if e:
|
||||
self.ctl.fetch(e)
|
||||
|
||||
def save_selected(self):
|
||||
e = self.current()
|
||||
if e:
|
||||
folder = QFileDialog.getExistingDirectory(self, "Zielordner wählen")
|
||||
if folder:
|
||||
self.ctl.fetch(e, save_to=folder)
|
||||
|
||||
def delete_selected(self):
|
||||
e = self.current()
|
||||
if e:
|
||||
self.ctl.delete(e)
|
||||
|
||||
def clear(self):
|
||||
if QMessageBox.question(self, "Verlauf leeren",
|
||||
"Den gesamten Verlauf auf dem Server für alle Geräte löschen?") \
|
||||
== QMessageBox.StandardButton.Yes:
|
||||
self.ctl.clear()
|
||||
|
||||
def _context_menu(self, pos):
|
||||
if not self.list.itemAt(pos):
|
||||
return
|
||||
menu = QMenu(self)
|
||||
menu.addAction("In Zwischenablage", self.use_selected)
|
||||
menu.addAction("Speichern unter …", self.save_selected)
|
||||
menu.addSeparator()
|
||||
menu.addAction("Löschen", self.delete_selected)
|
||||
menu.exec(self.list.mapToGlobal(pos))
|
||||
|
||||
def show_raise(self):
|
||||
self.show()
|
||||
self.setWindowState(self.windowState() & ~Qt.WindowState.WindowMinimized)
|
||||
self.raise_()
|
||||
self.activateWindow()
|
||||
self.search.setFocus()
|
||||
self.search.selectAll()
|
||||
|
||||
|
||||
class Tray(QSystemTrayIcon):
|
||||
QUICK = 10
|
||||
|
||||
def __init__(self, ctl: Controller, window: HistoryWindow):
|
||||
super().__init__(app_icon("off"))
|
||||
self.ctl = ctl
|
||||
self.window = window
|
||||
self.menu = QMenu()
|
||||
self.setContextMenu(self.menu)
|
||||
self.activated.connect(self._activated)
|
||||
ctl.history_changed.connect(self.rebuild)
|
||||
ctl.state_changed.connect(self.rebuild)
|
||||
ctl.notify.connect(lambda title, text: self.showMessage(title, text[:200], app_icon(), 4000))
|
||||
self.rebuild()
|
||||
self.show()
|
||||
|
||||
def rebuild(self):
|
||||
c = self.ctl
|
||||
status = "pause" if c.paused else ("ok" if c.connected else "off")
|
||||
self.setIcon(app_icon(status))
|
||||
self.setToolTip("NetClip – " + {"ok": "verbunden", "off": "getrennt", "pause": "pausiert"}[status])
|
||||
m = self.menu
|
||||
m.clear()
|
||||
for e in c.history[:self.QUICK]:
|
||||
title = e.title if len(e.title) <= 50 else e.title[:49] + "…"
|
||||
prefix = {"image": "🖼 ", "files": "📁 "}.get(e.kind, "")
|
||||
m.addAction(prefix + title.replace("&", "&&"), lambda e=e: c.fetch(e))
|
||||
if not c.history:
|
||||
m.addAction("(Verlauf leer)").setEnabled(False)
|
||||
m.addSeparator()
|
||||
m.addAction("Verlauf öffnen …", self.window.show_raise)
|
||||
m.addAction("Aktuelle Zwischenablage senden", c.send_current)
|
||||
pause = QAction("Pausieren", m, checkable=True, checked=c.paused)
|
||||
pause.toggled.connect(c.set_paused)
|
||||
m.addAction(pause)
|
||||
m.addAction("Einstellungen …", self.open_settings)
|
||||
m.addSeparator()
|
||||
m.addAction("Beenden", QApplication.quit)
|
||||
|
||||
def _activated(self, reason):
|
||||
if reason in (QSystemTrayIcon.ActivationReason.Trigger, QSystemTrayIcon.ActivationReason.DoubleClick):
|
||||
if self.window.isVisible() and self.window.isActiveWindow():
|
||||
self.window.hide()
|
||||
else:
|
||||
self.window.show_raise()
|
||||
|
||||
def open_settings(self):
|
||||
dlg = SettingsDialog(self.ctl.cfg)
|
||||
if dlg.exec():
|
||||
cfg = dlg.result_config()
|
||||
if cfg.autostart != self.ctl.cfg.autostart or cfg.autostart:
|
||||
try:
|
||||
autostart.set_enabled(cfg.autostart)
|
||||
except OSError as e:
|
||||
QMessageBox.warning(None, "Autostart", str(e))
|
||||
self.ctl.apply_config(cfg)
|
||||
Reference in New Issue
Block a user