421 lines
17 KiB
Python
421 lines
17 KiB
Python
"""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("<small>Alle Geräte mit demselben Token teilen sich die Zwischenablage.<br>"
|
||
"Der Token verlässt das Gerät nie, er dient auch als Schlüssel.</small>"))
|
||
|
||
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 <b>QR-Code scannen</b> tippen.<br>"
|
||
"<small>Der Code enthält den Token – nicht weitergeben oder abfotografieren lassen.</small>"))
|
||
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)
|