first commit

This commit is contained in:
duffyduck
2026-10-16 15:55:14 +02:00
commit 65e8b9440d
62 changed files with 4799 additions and 0 deletions
+331
View File
@@ -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)