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