332 lines
13 KiB
Python
332 lines
13 KiB
Python
"""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)
|