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
View File
+118
View File
@@ -0,0 +1,118 @@
"""NetClip-Desktop-Client.
netclip starten (Tray-Icon)
netclip --minimized ohne Verlaufsfenster starten (Autostart)
netclip --show Verlauf der laufenden Instanz öffnen (fuer ein Tastenkuerzel)
netclip --import datei.netclip Verbindungsdaten importieren
netclip --server URL --token T [--server-key K] [--insecure] [--autostart]
Einstellungen setzen (fuer automatisches Ausrollen)
"""
import argparse
import sys
from PySide6.QtCore import QTimer
from PySide6.QtNetwork import QLocalServer, QLocalSocket
from PySide6.QtWidgets import QApplication, QMessageBox, QSystemTrayIcon
from . import autostart, config, connection
INSTANCE = "netclip-instance"
def parse_args():
p = argparse.ArgumentParser(prog="netclip", description="Netzwerk-Zwischenablage")
p.add_argument("--minimized", action="store_true")
p.add_argument("--show", action="store_true")
p.add_argument("--import", dest="import_file")
p.add_argument("--server")
p.add_argument("--token")
p.add_argument("--server-key")
p.add_argument("--device-name")
p.add_argument("--insecure", action="store_true", help="TLS-Zertifikat nicht prüfen")
p.add_argument("--autostart", action="store_true")
p.add_argument("--configure-only", action="store_true", help="nur Einstellungen schreiben, nicht starten")
return p.parse_args()
def apply_args(args) -> bool:
cfg = config.load()
changed = False
if args.import_file:
with open(args.import_file, encoding="utf-8") as f:
for k, v in connection.parse(f.read()).items():
setattr(cfg, k, v)
changed = True
for attr in ("server", "token", "server_key", "device_name"):
value = getattr(args, attr)
if value is not None:
setattr(cfg, attr, value)
changed = True
if args.insecure:
cfg.verify_tls = False
changed = True
if args.autostart:
cfg.autostart = True
autostart.set_enabled(True)
changed = True
if changed:
config.save(cfg)
return changed
def main():
args = parse_args()
changed = apply_args(args)
if args.configure_only:
print(f"Konfiguration gespeichert: {config.config_file()}")
return
app = QApplication(sys.argv)
app.setApplicationName("NetClip")
app.setQuitOnLastWindowClosed(False)
# Einzelinstanz: laeuft schon eine, dort ggf. Verlauf öffnen bzw. neu laden und beenden
sock = QLocalSocket()
sock.connectToServer(INSTANCE)
if sock.waitForConnected(300):
sock.write(b"reload\n" if changed else b"show\n")
sock.waitForBytesWritten(300)
sock.disconnectFromServer()
return
if not QSystemTrayIcon.isSystemTrayAvailable():
QMessageBox.warning(None, "NetClip", "Kein System-Tray gefunden. Der Verlauf ist über das Fenster erreichbar.")
from .controller import Controller
from .ui import HistoryWindow, Tray, app_icon
app.setWindowIcon(app_icon())
ctl = Controller()
window = HistoryWindow(ctl)
tray = Tray(ctl, window)
server = QLocalServer()
QLocalServer.removeServer(INSTANCE)
server.listen(INSTANCE)
def on_connection():
conn = server.nextPendingConnection()
def read():
cmd = bytes(conn.readAll()).decode().strip()
if cmd == "reload":
ctl.apply_config(config.load())
window.show_raise()
conn.readyRead.connect(read)
server.newConnection.connect(on_connection)
if not ctl.cfg.configured:
QTimer.singleShot(200, tray.open_settings)
elif not args.minimized:
window.show_raise()
app.aboutToQuit.connect(ctl.shutdown)
sys.exit(app.exec())
if __name__ == "__main__":
main()
+159
View File
@@ -0,0 +1,159 @@
"""HTTP- und WebSocket-Anbindung an den NetClip-Server (ohne Qt, mit Callbacks)."""
import json
import ssl
import struct
import threading
import time
from pathlib import Path
from urllib.parse import quote
import requests
import urllib3
import websocket
from .config import Config
from .crypto import Keys
class _FramedBody:
"""Datei-artiger Body: Meta-Laenge + Meta + Blob-Datei, gestreamt mit Fortschritt."""
def __init__(self, meta: str, blob: Path, progress=None):
self.head = struct.pack(">I", len(meta)) + meta.encode("ascii")
self.file = open(blob, "rb")
self.len = len(self.head) + blob.stat().st_size
self.sent = 0
self.progress = progress
def __len__(self):
return self.len
def read(self, n=-1):
if self.head:
data, self.head = self.head, b""
else:
data = self.file.read(n if n and n > 0 else 1024 * 1024)
self.sent += len(data)
if self.progress and data:
self.progress(self.sent, self.len)
return data
def close(self):
self.file.close()
class Api:
def __init__(self, cfg: Config, keys: Keys):
self.cfg = cfg
self.keys = keys
self.base = cfg.server.rstrip("/")
self.session = requests.Session()
if cfg.server_key:
self.session.headers["Authorization"] = f"Bearer {cfg.server_key}"
if not cfg.verify_tls:
self.session.verify = False
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
elif cfg.ca_file:
self.session.verify = cfg.ca_file
@property
def room_url(self) -> str:
return f"{self.base}/api/v1/rooms/{self.keys.room_id}"
def list_clips(self) -> list[dict]:
r = self.session.get(f"{self.room_url}/clips", timeout=20)
r.raise_for_status()
return r.json()["clips"]
def upload(self, meta: dict, blob: Path, progress=None) -> dict:
body = _FramedBody(self.keys.encrypt_meta(meta), blob, progress)
try:
r = self.session.post(f"{self.room_url}/clips", data=body, timeout=(20, 600),
headers={"X-Device": self.cfg.device_id,
"Content-Type": "application/octet-stream",
"Content-Length": str(len(body))})
finally:
body.close()
r.raise_for_status()
return r.json()
def download(self, clip_id: str, dest: Path, progress=None):
with self.session.get(f"{self.room_url}/clips/{clip_id}/blob", stream=True, timeout=(20, 600)) as r:
r.raise_for_status()
total = int(r.headers.get("Content-Length", 0))
done = 0
with open(dest, "wb") as f:
for chunk in r.iter_content(1024 * 1024):
f.write(chunk)
done += len(chunk)
if progress:
progress(done, total)
def delete(self, clip_id: str):
self.session.delete(f"{self.room_url}/clips/{clip_id}", timeout=20).raise_for_status()
def clear(self):
self.session.delete(f"{self.room_url}/clips", timeout=20).raise_for_status()
class Live(threading.Thread):
"""WebSocket-Verbindung mit automatischem Wiederverbinden.
on_message(dict) und on_state(connected: bool, error: str) werden im Thread aufgerufen."""
def __init__(self, api: Api, on_message, on_state):
super().__init__(daemon=True, name="netclip-ws")
self.api = api
self.on_message = on_message
self.on_state = on_state
self._halt = threading.Event()
self._ws = None
def run(self):
url = self.api.room_url.replace("https://", "wss://", 1).replace("http://", "ws://", 1)
url += f"/ws?device={quote(self.api.cfg.device_id)}"
headers = []
if self.api.cfg.server_key:
headers.append(f"Authorization: Bearer {self.api.cfg.server_key}")
sslopt = {}
if not self.api.cfg.verify_tls:
sslopt = {"cert_reqs": ssl.CERT_NONE, "check_hostname": False}
elif self.api.cfg.ca_file:
sslopt = {"ca_certs": self.api.cfg.ca_file}
delay = 2
while not self._halt.is_set():
error = ""
started = time.monotonic()
self._ws = websocket.WebSocketApp(
url, header=headers,
on_open=self._opened,
on_message=lambda _ws, msg: self._dispatch(msg),
on_error=lambda _ws, e: None if self._halt.is_set() else self.on_state(False, str(e)),
)
try:
self._ws.run_forever(sslopt=sslopt, ping_interval=30, ping_timeout=10)
except Exception as e: # noqa: BLE001 - Verbindung soll nie den Thread beenden
error = str(e)
if self._halt.is_set():
return
self.on_state(False, error)
if time.monotonic() - started > 60:
delay = 2
self._halt.wait(delay)
delay = min(delay * 2, 60)
def _opened(self, ws):
ws.send(json.dumps({"type": "hello", "label": self.api.keys.encrypt_label(self.api.cfg.device_name)}))
self.on_state(True, "")
def _dispatch(self, raw):
try:
self.on_message(json.loads(raw))
except ValueError:
pass
def stop(self, wait: float = 0):
self._halt.set()
if self._ws:
self._ws.close()
if wait and self.is_alive():
self.join(wait)
+56
View File
@@ -0,0 +1,56 @@
"""Autostart beim Anmelden (Windows: Registry Run-Key, macOS: LaunchAgent, Linux: XDG-Autostart)."""
import os
import plistlib
import shlex
import sys
from pathlib import Path
from .config import SYSTEM
LABEL = "de.netclip.client"
def command() -> list[str]:
if getattr(sys, "frozen", False):
return [sys.executable, "--minimized"]
return [sys.executable, "-m", "netclip", "--minimized"]
def _linux_file() -> Path:
base = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
return base / "autostart" / "netclip.desktop"
def _mac_file() -> Path:
return Path.home() / "Library" / "LaunchAgents" / f"{LABEL}.plist"
def set_enabled(enabled: bool):
cmd = command()
if SYSTEM == "Windows":
import winreg
key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, r"Software\Microsoft\Windows\CurrentVersion\Run",
0, winreg.KEY_SET_VALUE)
with key:
if enabled:
winreg.SetValueEx(key, "NetClip", 0, winreg.REG_SZ, " ".join(f'"{c}"' for c in cmd))
else:
try:
winreg.DeleteValue(key, "NetClip")
except FileNotFoundError:
pass
elif SYSTEM == "Darwin":
f = _mac_file()
if enabled:
f.parent.mkdir(parents=True, exist_ok=True)
f.write_bytes(plistlib.dumps({"Label": LABEL, "ProgramArguments": cmd, "RunAtLoad": True}))
else:
f.unlink(missing_ok=True)
else:
f = _linux_file()
if enabled:
f.parent.mkdir(parents=True, exist_ok=True)
f.write_text("[Desktop Entry]\nType=Application\nName=NetClip\n"
f"Exec={shlex.join(cmd)}\nIcon=edit-paste\nX-GNOME-Autostart-enabled=true\n")
else:
f.unlink(missing_ok=True)
+210
View File
@@ -0,0 +1,210 @@
"""Zugriff auf die System-Zwischenablage.
QtBackend Windows, macOS, Linux/X11. Dateien werden als Datei-URLs abgelegt
(Windows: CF_HDROP, macOS: public.file-url, X11: text/uri-list +
x-special/gnome-copied-files), d.h. Strg+V im Explorer/Finder/Dateimanager
fuegt die Dateien bzw. Ordner im aktuellen Ordner ein.
WaylandBackend Linux/Wayland über wl-clipboard (wl-paste --watch / wl-copy), da Wayland
Programmen ohne Fokus keinen Zugriff auf die Zwischenablage gibt.
Funktioniert mit KDE Plasma und wlroots (Sway, Hyprland), nicht mit GNOME.
"""
import hashlib
import os
import shutil
import subprocess
from urllib.parse import unquote, urlparse
from PySide6.QtCore import QBuffer, QByteArray, QIODevice, QMimeData, QObject, QProcess, QTimer, QUrl, Signal
from PySide6.QtGui import QClipboard, QGuiApplication, QImage
from .config import SYSTEM
from .payload import ClipContent, files_fp, text_fp
# Formate, mit denen Passwort-Manager ihre Eintraege als "nicht synchronisieren" markieren
SECRET_HINTS = ("x-kde-passwordManagerHint", "ExcludeClipboardContentFromMonitorProcessing",
"org.nspasteboard.ConcealedType", "org.nspasteboard.TransientType")
def image_fp(img: QImage) -> str:
img = img.convertToFormat(QImage.Format.Format_RGBA8888)
h = hashlib.sha256(f"{img.width()}x{img.height()}".encode())
h.update(bytes(img.constBits()))
return "image:" + h.hexdigest()
def image_to_png(img: QImage) -> bytes:
ba = QByteArray()
buf = QBuffer(ba)
buf.open(QIODevice.OpenModeFlag.WriteOnly)
img.save(buf, "PNG")
return bytes(ba.data())
def uri_list(paths: list[str]) -> bytes:
return "".join(QUrl.fromLocalFile(p).toString(QUrl.ComponentFormattingOption.FullyEncoded) + "\r\n"
for p in paths).encode()
class Backend(QObject):
changed = Signal()
def read(self) -> ClipContent | None:
raise NotImplementedError
def write(self, content: ClipContent) -> str:
"""Legt content in die Zwischenablage, gibt den Fingerabdruck zurück."""
raise NotImplementedError
class QtBackend(Backend):
def __init__(self):
super().__init__()
self.cb = QGuiApplication.clipboard()
self.cb.dataChanged.connect(self.changed)
if SYSTEM == "Darwin":
# macOS meldet fremde Aenderungen nicht zuverlaessig -> changeCount pollen
self._mac_count = -1
try:
from AppKit import NSPasteboard
self._pb = NSPasteboard.generalPasteboard()
except ImportError:
self._pb = None
self._poll = QTimer(self, interval=700, timeout=self._mac_poll)
self._poll.start()
def _mac_poll(self):
if self._pb is not None:
count = self._pb.changeCount()
if count != self._mac_count:
if self._mac_count != -1:
self.changed.emit()
self._mac_count = count
else:
md = self.cb.mimeData()
sig = (tuple(md.formats()), md.text()[:200]) if md else None
if getattr(self, "_mac_sig", sig) != sig:
self.changed.emit()
self._mac_sig = sig
def read(self):
md = self.cb.mimeData(QClipboard.Mode.Clipboard)
if md is None:
return None
formats = md.formats()
if any(hint in f for f in formats for hint in SECRET_HINTS):
return None
if md.hasUrls():
paths = [u.toLocalFile() for u in md.urls() if u.isLocalFile()]
paths = [p for p in paths if p and os.path.exists(p)]
if paths:
return ClipContent("files", paths=paths, fp=files_fp(paths))
if md.hasImage():
img = self.cb.image()
if not img.isNull():
return ClipContent("image", png=image_to_png(img), fp=image_fp(img))
if md.hasText():
text = md.text()
if text.strip():
return ClipContent("text", text=text, fp=text_fp(text))
return None
def write(self, content):
md = QMimeData()
if content.kind == "text":
md.setText(content.text)
fp = text_fp(content.text)
elif content.kind == "image":
img = QImage.fromData(content.png)
md.setImageData(img)
md.setData("image/png", content.png)
fp = image_fp(img)
else:
md.setUrls([QUrl.fromLocalFile(p) for p in content.paths])
if SYSTEM == "Linux":
# GNOME/Nautilus, Nemo, Caja, Thunar
md.setData("x-special/gnome-copied-files", b"copy\n" + uri_list(content.paths).replace(b"\r\n", b"\n").rstrip(b"\n"))
elif SYSTEM == "Windows":
# Explorer: "kopieren" statt "verschieben"
md.setData('application/x-qt-windows-mime;value="Preferred DropEffect"', b"\x01\x00\x00\x00")
fp = files_fp(content.paths)
self.cb.setMimeData(md, QClipboard.Mode.Clipboard)
return fp
class WaylandBackend(Backend):
def __init__(self):
super().__init__()
self.proc = QProcess(self)
self.proc.readyReadStandardOutput.connect(self._on_output)
self.proc.start("wl-paste", ["--watch", "echo", "x"])
def _on_output(self):
self.proc.readAllStandardOutput()
self.changed.emit()
@staticmethod
def _paste(mime: str | None = None, list_types=False) -> bytes:
args = ["wl-paste", "--no-newline"]
if list_types:
args = ["wl-paste", "--list-types"]
elif mime:
args += ["--type", mime]
r = subprocess.run(args, capture_output=True, timeout=10)
return r.stdout if r.returncode == 0 else b""
def read(self):
types = self._paste(list_types=True).decode(errors="replace").split()
if not types or any(hint in t for t in types for hint in SECRET_HINTS):
return None
if "text/uri-list" in types:
paths = []
for line in self._paste("text/uri-list").decode(errors="replace").splitlines():
u = urlparse(line.strip())
if u.scheme == "file":
p = unquote(u.path)
if os.path.exists(p):
paths.append(p)
if paths:
return ClipContent("files", paths=paths, fp=files_fp(paths))
images = [t for t in types if t.startswith("image/")]
if images:
mime = "image/png" if "image/png" in images else images[0]
data = self._paste(mime)
if mime != "image/png":
data = image_to_png(QImage.fromData(data))
if data:
return ClipContent("image", png=data, fp=ClipContent.fingerprint("image", data))
for mime in ("text/plain;charset=utf-8", "UTF8_STRING", "text/plain", "TEXT", "STRING"):
if mime in types:
text = self._paste(mime).decode("utf-8", errors="replace")
if text.strip():
return ClipContent("text", text=text, fp=text_fp(text))
break
return None
@staticmethod
def _copy(data: bytes, mime: str | None):
args = ["wl-copy"] + (["--type", mime] if mime else [])
subprocess.run(args, input=data, timeout=10)
def write(self, content):
if content.kind == "text":
self._copy(content.text.encode("utf-8"), None)
return text_fp(content.text)
if content.kind == "image":
self._copy(content.png, "image/png")
return ClipContent.fingerprint("image", content.png)
self._copy(uri_list(content.paths), "text/uri-list")
return files_fp(content.paths)
def stop(self):
self.proc.kill()
self.proc.waitForFinished(1000)
def create_backend() -> Backend:
if (SYSTEM == "Linux" and os.environ.get("WAYLAND_DISPLAY")
and shutil.which("wl-paste") and shutil.which("wl-copy")
and os.environ.get("NETCLIP_BACKEND") != "qt"):
return WaylandBackend()
return QtBackend()
+96
View File
@@ -0,0 +1,96 @@
"""Einstellungen und Pfade (plattformabhaengig, ohne Qt nutzbar)."""
import json
import os
import platform
import secrets
import socket
import sys
from dataclasses import asdict, dataclass, field, fields
from pathlib import Path
APP = "NetClip"
SYSTEM = platform.system() # Windows | Darwin | Linux
def config_dir() -> Path:
if SYSTEM == "Windows":
return Path(os.environ.get("APPDATA", Path.home())) / APP
if SYSTEM == "Darwin":
return Path.home() / "Library" / "Application Support" / APP
return Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "netclip"
def cache_dir() -> Path:
if SYSTEM == "Windows":
return Path(os.environ.get("LOCALAPPDATA", Path.home())) / APP / "cache"
if SYSTEM == "Darwin":
return Path.home() / "Library" / "Caches" / APP
return Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "netclip"
def exe_dir() -> Path:
if getattr(sys, "frozen", False):
return Path(sys.executable).resolve().parent
return Path(__file__).resolve().parent.parent
@dataclass
class Config:
server: str = "" # z.B. https://clip.example.de
token: str = "" # gemeinsamer Token aller eigenen Geraete
server_key: str = "" # optional, siehe SERVER_KEY im Server
verify_tls: bool = True # False = Zertifikat nicht pruefen (selbstsigniert, nur im LAN)
ca_file: str = "" # alternativ: Pfad zum Server-Zertifikat (fullchain.pem)
device_id: str = field(default_factory=lambda: secrets.token_hex(8))
device_name: str = field(default_factory=socket.gethostname)
send_enabled: bool = True # lokale Zwischenablage hochladen
receive_enabled: bool = True # fremde Clips automatisch in die Zwischenablage legen
send_text: bool = True
send_images: bool = True
send_files: bool = True
max_send_mb: int = 500 # groessere Dateien/Ordner werden nicht automatisch gesendet
max_auto_receive_mb: int = 200 # groessere Clips nur auf Klick im Verlauf herunterladen
keep_received_days: int = 7 # empfangene Dateien im Cache so lange aufheben
notifications: bool = True
autostart: bool = False
@property
def configured(self) -> bool:
return bool(self.server and self.token)
def config_file() -> Path:
return config_dir() / "config.json"
def load() -> Config:
"""Laedt config.json. Fuer die Verteilung: eine config.default.json neben der
Programmdatei liefert Vorgaben (Server, Token, ...), die beim ersten Start uebernommen werden."""
data = {}
default = exe_dir() / "config.default.json"
for path in (default, config_file()):
if path.exists():
try:
values = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError) as e:
print(f"Konfiguration {path} nicht lesbar: {e}", file=sys.stderr)
continue
if path == default:
values.pop("device_id", None) # muss pro Geraet eindeutig sein
values.pop("device_name", None)
data.update(values)
known = {f.name for f in fields(Config)}
cfg = Config(**{k: v for k, v in data.items() if k in known})
if not config_file().exists():
save(cfg)
return cfg
def save(cfg: Config):
path = config_file()
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(".tmp")
tmp.write_text(json.dumps(asdict(cfg), indent=2), encoding="utf-8")
os.replace(tmp, path)
if SYSTEM != "Windows":
os.chmod(path, 0o600)
+59
View File
@@ -0,0 +1,59 @@
"""Verbindungsdaten (Server, Token, ...) als Datei oder QR-Code weitergeben.
Format (Datei-Inhalt und QR-Code identisch, Android versteht dasselbe):
netclip://connect?v=1&server=<url>&token=<token>[&key=<server_key>][&insecure=1]
Eine .netclip-Datei darf alternativ JSON {"server":..,"token":..,"server_key":..,"verify_tls":..} enthalten.
"""
import json
from urllib.parse import parse_qs, urlencode, urlparse
FIELDS = ("server", "token", "server_key", "verify_tls")
def to_uri(server: str, token: str, server_key: str = "", verify_tls: bool = True) -> str:
q = {"v": "1", "server": server, "token": token}
if server_key:
q["key"] = server_key
if not verify_tls:
q["insecure"] = "1"
return "netclip://connect?" + urlencode(q)
def parse(text: str) -> dict:
"""Gibt {"server", "token", "server_key", "verify_tls"} zurück, ValueError bei ungültigem Inhalt."""
text = text.strip()
if text.startswith("{"):
data = json.loads(text)
result = {k: data[k] for k in FIELDS if k in data}
else:
u = urlparse(text)
if u.scheme != "netclip":
raise ValueError("keine NetClip-Verbindungsdaten")
q = {k: v[0] for k, v in parse_qs(u.query).items()}
result = {"server": q.get("server", ""), "token": q.get("token", ""),
"server_key": q.get("key", ""), "verify_tls": q.get("insecure") != "1"}
if not result.get("server") or not result.get("token"):
raise ValueError("Server oder Token fehlt")
return result
def qr_image(data: str):
"""QR-Code als QImage (benoetigt das Paket 'qrcode')."""
import qrcode
from PySide6.QtGui import QColor, QImage, QPainter
qr = qrcode.QRCode(error_correction=qrcode.constants.ERROR_CORRECT_M, border=4)
qr.add_data(data)
qr.make(fit=True)
matrix = qr.get_matrix()
scale = max(4, 360 // len(matrix))
size = len(matrix) * scale
img = QImage(size, size, QImage.Format.Format_RGB32)
img.fill(QColor("white"))
p = QPainter(img)
for y, row in enumerate(matrix):
for x, on in enumerate(row):
if on:
p.fillRect(x * scale, y * scale, scale, scale, QColor("black"))
p.end()
return img
+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)
+88
View File
@@ -0,0 +1,88 @@
"""Ende-zu-Ende-Verschluesselung. Muss mit client-android/.../Crypto.kt uebereinstimmen.
Ableitung: master = PBKDF2-HMAC-SHA256(token, "netclip-v1", 200000, 32)
room_id = hex(HMAC-SHA256(master, "room-id")) -> sieht der Server
key = HMAC-SHA256(master, "enc-key") -> bleibt beim Client
Meta: base64( nonce[12] || AES-256-GCM(key, nonce, json, aad="meta") )
Blob: "NCB1" || prefix[8] || Chunks
Chunk = flag[1] || len[4, BE] || AES-256-GCM(key, prefix||counter[4, BE], data, aad=flag)
flag = 1 beim letzten Chunk (verhindert Abschneiden), sonst 0
"""
import base64
import hashlib
import hmac
import json
import os
import struct
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
SALT = b"netclip-v1"
ITERATIONS = 200_000
MAGIC = b"NCB1"
CHUNK = 1024 * 1024
class CryptoError(Exception):
pass
class Keys:
def __init__(self, token: str):
master = hashlib.pbkdf2_hmac("sha256", token.encode("utf-8"), SALT, ITERATIONS, 32)
self.room_id = hmac.new(master, b"room-id", hashlib.sha256).hexdigest()
self.key = hmac.new(master, b"enc-key", hashlib.sha256).digest()
self.aead = AESGCM(self.key)
def encrypt_meta(self, meta: dict) -> str:
nonce = os.urandom(12)
ct = self.aead.encrypt(nonce, json.dumps(meta).encode("utf-8"), b"meta")
return base64.b64encode(nonce + ct).decode("ascii")
def decrypt_meta(self, data: str) -> dict:
try:
raw = base64.b64decode(data)
return json.loads(self.aead.decrypt(raw[:12], raw[12:], b"meta"))
except Exception as e: # falscher Token oder manipuliert
raise CryptoError(str(e)) from e
def encrypt_label(self, text: str) -> str:
return self.encrypt_meta({"name": text})
def encrypt_stream(self, src, dst):
"""Liest Klartext aus src (Datei-Objekt), schreibt Blob nach dst."""
prefix = os.urandom(8)
dst.write(MAGIC + prefix)
counter = 0
buf = src.read(CHUNK)
while True:
nxt = src.read(CHUNK) if buf else b""
last = not nxt
flag = b"\x01" if last else b"\x00"
ct = self.aead.encrypt(prefix + struct.pack(">I", counter), buf, flag)
dst.write(flag + struct.pack(">I", len(ct)) + ct)
if last:
return
counter += 1
buf = nxt
def decrypt_stream(self, src, dst):
if src.read(4) != MAGIC:
raise CryptoError("kein NetClip-Blob")
prefix = src.read(8)
counter = 0
while True:
head = src.read(5)
if len(head) != 5:
raise CryptoError("Blob abgeschnitten")
flag, length = head[:1], struct.unpack(">I", head[1:])[0]
if length > CHUNK + 16:
raise CryptoError("Chunk zu gross")
ct = src.read(length)
try:
dst.write(self.aead.decrypt(prefix + struct.pack(">I", counter), ct, flag))
except Exception as e:
raise CryptoError("Entschluesselung fehlgeschlagen") from e
if flag == b"\x01":
return
counter += 1
+150
View File
@@ -0,0 +1,150 @@
"""Umwandlung Zwischenablage-Inhalt <-> Klartext-Payload (+ Meta).
text : UTF-8
image : PNG
files : ZIP mit den kopierten Dateien/Ordnern auf oberster Ebene
"""
import hashlib
import os
import shutil
import tempfile
import time
import zipfile
from dataclasses import dataclass, field
from pathlib import Path
from .config import cache_dir
@dataclass
class ClipContent:
kind: str # text | image | files
text: str = ""
png: bytes = b""
paths: list[str] = field(default_factory=list)
fp: str = "" # Fingerabdruck zur Schleifen-Vermeidung
@staticmethod
def fingerprint(kind: str, data: bytes) -> str:
return kind + ":" + hashlib.sha256(data).hexdigest()
def text_fp(text: str) -> str:
return ClipContent.fingerprint("text", text.encode("utf-8"))
def files_fp(paths: list[str]) -> str:
norm = sorted(os.path.normcase(os.path.abspath(p)) for p in paths)
return ClipContent.fingerprint("files", "\n".join(norm).encode("utf-8"))
def human(n: float) -> str:
for unit in ("B", "KB", "MB", "GB"):
if n < 1024 or unit == "GB":
return f"{n:.0f} {unit}" if unit == "B" else f"{n:.1f} {unit}"
n /= 1024
return f"{n:.1f} GB"
def files_size(paths: list[str], limit: int) -> int:
"""Summe der Dateigroessen; bricht ab, sobald limit ueberschritten ist."""
total = 0
for p in paths:
p = Path(p)
if p.is_file():
total += p.stat().st_size
else:
for root, _dirs, files in os.walk(p):
for name in files:
try:
total += os.path.getsize(os.path.join(root, name))
except OSError:
pass
if total > limit:
return total
if total > limit:
return total
return total
def build(content: ClipContent, device_name: str) -> tuple[dict, Path]:
"""Schreibt die Payload in eine temporaere Datei. Rueckgabe: (meta, pfad)."""
fd, tmp = tempfile.mkstemp(prefix="netclip-", suffix=".plain")
tmp = Path(tmp)
meta = {"v": 1, "kind": content.kind, "device": device_name}
with os.fdopen(fd, "wb") as f:
if content.kind == "text":
data = content.text.encode("utf-8")
f.write(data)
meta.update(preview=content.text[:300], chars=len(content.text))
elif content.kind == "image":
f.write(content.png)
meta.update(mime="image/png", preview=f"Bild ({human(len(content.png))})")
else:
names, count = [], 0
with zipfile.ZipFile(f, "w", zipfile.ZIP_DEFLATED, compresslevel=1) as z:
for p in content.paths:
p = Path(p)
names.append(p.name)
if p.is_dir():
z.write(p, p.name + "/")
for root, dirs, files in os.walk(p):
rel_root = Path(p.name) / Path(root).relative_to(p)
for d in dirs:
z.write(Path(root) / d, (rel_root / d).as_posix() + "/")
for name in files:
try:
z.write(Path(root) / name, (rel_root / name).as_posix())
count += 1
except OSError:
pass # z.B. gesperrte Datei unter Windows
elif p.is_file():
z.write(p, p.name)
count += 1
meta.update(names=names, count=count, preview=", ".join(names)[:300])
meta["size"] = tmp.stat().st_size
return meta, tmp
def received_dir() -> Path:
return cache_dir() / "received"
def materialize(kind: str, plain: Path, clip_id: str) -> ClipContent:
"""Erzeugt aus einer entschluesselten Payload den Zwischenablage-Inhalt.
Dateien werden nach <cache>/received/<clip_id>/ entpackt."""
if kind == "text":
return ClipContent("text", text=plain.read_text("utf-8"))
if kind == "image":
return ClipContent("image", png=plain.read_bytes())
target = received_dir() / clip_id
if not target.exists():
part = target.with_name(clip_id + ".part")
shutil.rmtree(part, ignore_errors=True)
part.mkdir(parents=True)
with zipfile.ZipFile(plain) as z:
for info in z.infolist():
# Pfad-Traversal verhindern
dest = (part / info.filename).resolve()
if part.resolve() not in dest.parents and dest != part.resolve():
continue
z.extract(info, part)
if not info.is_dir():
mtime = time.mktime(info.date_time + (0, 0, -1))
os.utime(dest, (mtime, mtime))
os.replace(part, target)
paths = sorted(str(p) for p in target.iterdir())
return ClipContent("files", paths=paths)
def cleanup_received(days: int):
base = received_dir()
if not base.exists():
return
cutoff = time.time() - days * 86400
for d in base.iterdir():
try:
if d.stat().st_mtime < cutoff:
shutil.rmtree(d, ignore_errors=True)
except OSError:
pass
+420
View File
@@ -0,0 +1,420 @@
"""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)