"""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 __version__, 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(f"NetClip {__version__} – 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("Alle Geräte mit demselben Token teilen sich die Zwischenablage.
" "Der Token verlässt das Gerät nie, er dient auch als Schlüssel.
")) 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 QR-Code scannen tippen.
" "Der Code enthält den Token – nicht weitergeben oder abfotografieren lassen.")) 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(f"NetClip {__version__} – 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(f"NetClip {__version__} – " + {"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)