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

332 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)