"""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 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("NetClip – 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("NetClip – 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("NetClip – " + {"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)