From 65e8b9440d90ec30550a0076e41c743da247f3da Mon Sep 17 00:00:00 2001 From: duffyduck Date: Fri, 16 Oct 2026 15:55:14 +0200 Subject: [PATCH] first commit --- CHANGELOG.md | 22 + README.md | 177 ++++++++ client-android/app/build.gradle | 50 +++ client-android/app/proguard-rules.pro | 4 + .../app/src/main/AndroidManifest.xml | 136 ++++++ .../main/java/de/netclip/app/Activities.kt | 276 ++++++++++++ .../app/src/main/java/de/netclip/app/Api.kt | 123 +++++ .../src/main/java/de/netclip/app/Config.kt | 68 +++ .../src/main/java/de/netclip/app/Crypto.kt | 130 ++++++ .../src/main/java/de/netclip/app/Payload.kt | 146 ++++++ .../app/src/main/java/de/netclip/app/Repo.kt | 355 +++++++++++++++ .../src/main/java/de/netclip/app/Services.kt | 153 +++++++ .../app/src/main/res/drawable/ic_folder.xml | 4 + .../app/src/main/res/drawable/ic_image.xml | 4 + .../src/main/res/drawable/ic_launcher_fg.xml | 8 + .../app/src/main/res/drawable/ic_stat.xml | 4 + .../app/src/main/res/drawable/ic_text.xml | 4 + .../app/src/main/res/layout/activity_main.xml | 51 +++ .../src/main/res/layout/activity_settings.xml | 115 +++++ .../app/src/main/res/layout/item_clip.xml | 38 ++ client-android/app/src/main/res/menu/main.xml | 6 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 5 + .../app/src/main/res/values/colors.xml | 3 + .../app/src/main/res/values/strings.xml | 5 + .../app/src/main/res/values/themes.xml | 14 + .../app/src/main/res/xml/file_paths.xml | 4 + .../java/de/netclip/app/CryptoInteropTest.kt | 54 +++ client-android/build.gradle | 4 + client-android/gradle.properties | 4 + .../gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43739 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 + client-android/gradlew | 251 +++++++++++ client-android/gradlew.bat | 94 ++++ client-android/settings.gradle | 16 + client-desktop/build.py | 45 ++ client-desktop/netclip-launcher.py | 3 + client-desktop/netclip.spec | 13 + client-desktop/netclip/__init__.py | 0 client-desktop/netclip/__main__.py | 118 +++++ client-desktop/netclip/api.py | 159 +++++++ client-desktop/netclip/autostart.py | 56 +++ client-desktop/netclip/clipboard.py | 210 +++++++++ client-desktop/netclip/config.py | 96 ++++ client-desktop/netclip/connection.py | 59 +++ client-desktop/netclip/controller.py | 331 ++++++++++++++ client-desktop/netclip/crypto.py | 88 ++++ client-desktop/netclip/payload.py | 150 +++++++ client-desktop/netclip/ui.py | 420 ++++++++++++++++++ client-desktop/packaging/install-linux.sh | 33 ++ client-desktop/packaging/netclip.desktop | 9 + .../packaging/windows-installer.iss | 71 +++ client-desktop/requirements.txt | 6 + docker-compose.yml | 48 ++ docs/eigener-reverse-proxy.md | 75 ++++ nginx/40-selfsigned-cert.sh | 48 ++ nginx/Dockerfile | 5 + nginx/templates/default.conf.template | 42 ++ server/Dockerfile | 12 + server/entrypoint.sh | 9 + server/netclip_server.py | 307 +++++++++++++ server/requirements.txt | 1 + tools/interop.py | 50 +++ 62 files changed, 4799 insertions(+) create mode 100644 CHANGELOG.md create mode 100644 README.md create mode 100644 client-android/app/build.gradle create mode 100644 client-android/app/proguard-rules.pro create mode 100644 client-android/app/src/main/AndroidManifest.xml create mode 100644 client-android/app/src/main/java/de/netclip/app/Activities.kt create mode 100644 client-android/app/src/main/java/de/netclip/app/Api.kt create mode 100644 client-android/app/src/main/java/de/netclip/app/Config.kt create mode 100644 client-android/app/src/main/java/de/netclip/app/Crypto.kt create mode 100644 client-android/app/src/main/java/de/netclip/app/Payload.kt create mode 100644 client-android/app/src/main/java/de/netclip/app/Repo.kt create mode 100644 client-android/app/src/main/java/de/netclip/app/Services.kt create mode 100644 client-android/app/src/main/res/drawable/ic_folder.xml create mode 100644 client-android/app/src/main/res/drawable/ic_image.xml create mode 100644 client-android/app/src/main/res/drawable/ic_launcher_fg.xml create mode 100644 client-android/app/src/main/res/drawable/ic_stat.xml create mode 100644 client-android/app/src/main/res/drawable/ic_text.xml create mode 100644 client-android/app/src/main/res/layout/activity_main.xml create mode 100644 client-android/app/src/main/res/layout/activity_settings.xml create mode 100644 client-android/app/src/main/res/layout/item_clip.xml create mode 100644 client-android/app/src/main/res/menu/main.xml create mode 100644 client-android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 client-android/app/src/main/res/values/colors.xml create mode 100644 client-android/app/src/main/res/values/strings.xml create mode 100644 client-android/app/src/main/res/values/themes.xml create mode 100644 client-android/app/src/main/res/xml/file_paths.xml create mode 100644 client-android/app/src/test/java/de/netclip/app/CryptoInteropTest.kt create mode 100644 client-android/build.gradle create mode 100644 client-android/gradle.properties create mode 100644 client-android/gradle/wrapper/gradle-wrapper.jar create mode 100644 client-android/gradle/wrapper/gradle-wrapper.properties create mode 100755 client-android/gradlew create mode 100644 client-android/gradlew.bat create mode 100644 client-android/settings.gradle create mode 100644 client-desktop/build.py create mode 100644 client-desktop/netclip-launcher.py create mode 100644 client-desktop/netclip.spec create mode 100644 client-desktop/netclip/__init__.py create mode 100644 client-desktop/netclip/__main__.py create mode 100644 client-desktop/netclip/api.py create mode 100644 client-desktop/netclip/autostart.py create mode 100644 client-desktop/netclip/clipboard.py create mode 100644 client-desktop/netclip/config.py create mode 100644 client-desktop/netclip/connection.py create mode 100644 client-desktop/netclip/controller.py create mode 100644 client-desktop/netclip/crypto.py create mode 100644 client-desktop/netclip/payload.py create mode 100644 client-desktop/netclip/ui.py create mode 100755 client-desktop/packaging/install-linux.sh create mode 100644 client-desktop/packaging/netclip.desktop create mode 100644 client-desktop/packaging/windows-installer.iss create mode 100644 client-desktop/requirements.txt create mode 100644 docker-compose.yml create mode 100644 docs/eigener-reverse-proxy.md create mode 100644 nginx/40-selfsigned-cert.sh create mode 100644 nginx/Dockerfile create mode 100644 nginx/templates/default.conf.template create mode 100644 server/Dockerfile create mode 100644 server/entrypoint.sh create mode 100644 server/netclip_server.py create mode 100644 server/requirements.txt create mode 100644 tools/interop.py diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..27efd7f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,22 @@ +# Changelog + +Alle nennenswerten Änderungen an NetClip. Format nach [Keep a Changelog](https://keepachangelog.com/de/1.1.0/). + +## [Unreleased] + +## [1.0.0] - 2026-10-16 + +### Hinzugefügt +- Server (Docker): Relay für Ende-zu-Ende-verschlüsselte Clips, Räume pro Token, Verlauf mit + Anzahl-/Alters-/Speicherlimit, optionaler `SERVER_KEY`, WebSocket-Ereignisse inkl. Online-Geräteliste. +- Optionales nginx-Profil (`COMPOSE_PROFILES=nginx`) mit selbstsigniertem Zertifikat aus der `.env` + (CN, SAN, Gültigkeit, automatische Erneuerung) – abschaltbar bei vorhandenem Reverse-Proxy. +- Desktop-Client (Windows, Linux, macOS): Tray-Icon, Verlauf mit Suche, Text/Bilder/Dateien/Ordner, + Einfügen von Dateien und Ordnern per Strg+V im Dateimanager, Wayland-Unterstützung über wl-clipboard, + Passwort-Manager-Einträge werden ignoriert, Autostart, Einzelinstanz. +- Token erzeugen, Verbindung als `.netclip`-Datei exportieren/importieren, QR-Code für Android. +- Ausrollen per Kommandozeile, `config.default.json`, stiller Windows-Installer (Inno Setup), + Linux-Installationsskript. +- Android-Client: QR-Scanner/Import, Hintergrunddienst, automatisches Übernehmen von Text und Bildern, + Dateien speichern (Download/NetClip) oder teilen, Senden per Teilen-Menü, Textauswahl, + Schnelleinstellungs-Kachel und Benachrichtigung. diff --git a/README.md b/README.md new file mode 100644 index 0000000..146d819 --- /dev/null +++ b/README.md @@ -0,0 +1,177 @@ +# NetClip – Netzwerk-Zwischenablage + +Eine gemeinsame Zwischenablage für alle eigenen Geräte (Windows, Linux, macOS, Android) – im Stil von +CopyQ, aber über das Netzwerk. Text, **Bilder** und **Dateien/Ordner** werden übertragen: Wird auf dem +einen Rechner ein Ordner kopiert (Strg+C im Explorer/Finder/Dolphin), fügt Strg+V auf dem anderen Rechner +den Ordner in den gerade geöffneten Zielordner ein. Mit Verlauf, um Einträge später erneut einzufügen. + +``` + PC (Windows) Laptop (Linux) Mac Handy (Android) + │ │ │ │ + └──────── WebSocket + HTTPS (Ende-zu-Ende verschlüsselt) ───┘ + │ + NetClip-Server (Docker) + verbindet nur Clients mit gleichem Token +``` + +## Funktionsweise + +- Alle Geräte mit **demselben Token** bilden einen gemeinsamen „Raum“. +- Der Token verlässt das Gerät nie. Aus ihm werden per PBKDF2 eine Raum-ID (sieht der Server) und ein + AES-256-Schlüssel abgeleitet (sieht der Server nicht). Inhalte, Dateinamen, Vorschauen und Gerätenamen + sind **Ende-zu-Ende verschlüsselt** – der Server speichert und verteilt nur undurchsichtige Blobs. +- Der Server hält einen Verlauf pro Raum (Anzahl, Alter, Speicherplatz begrenzt, siehe `.env`). +- Optional schützt ein `SERVER_KEY` den Server davor, von Fremden benutzt zu werden. + +## Server (Docker) + +```bash +cp .env.example .env # Werte anpassen (CERT_CN, CERT_SAN, SERVER_KEY, ...) +docker compose up -d --build +``` + +Die `.env` ist kommentiert. Die wichtigsten Schalter: + +| Variable | Bedeutung | +|---|---| +| `COMPOSE_PROFILES=nginx` | nginx als HTTPS-Proxy mit selbstsigniertem Zertifikat mitstarten. **Leer lassen**, wenn schon ein Reverse-Proxy vorhanden ist (siehe [docs/eigener-reverse-proxy.md](docs/eigener-reverse-proxy.md)). | +| `CERT_CN`, `CERT_SAN` | Hostname bzw. weitere Namen/IPs im Zertifikat | +| `CERT_DAYS`, `CERT_RENEW_DAYS` | Gültigkeit; so viele Tage vor Ablauf wird beim Containerstart automatisch erneuert | +| `CERT_KEY_BITS`, `CERT_COUNTRY` … `CERT_OU` | weitere Zertifikatsfelder | +| `SERVER_KEY` | optionaler Zugangsschlüssel für den Server | +| `MAX_CLIP_MB` | maximale Größe eines Clips | +| `HISTORY_MAX`, `HISTORY_DAYS`, `ROOM_QUOTA_MB` | Verlaufsgrenzen pro Token | +| `BIND_ADDR`, `PORT` | direkter HTTP-Port des Servers (mit nginx am besten `BIND_ADDR=127.0.0.1`) | + +Ändert man `CERT_*`-Werte, wird das Zertifikat beim nächsten Start neu erzeugt. Eigene Zertifikate: +`fullchain.pem` + `privkey.pem` nach `./certs` legen und `./certs/.selfsigned` löschen. + +Selbstsignierte Zertifikate in den Clients: entweder „TLS-Zertifikat prüfen“ abschalten (nur im eigenen +Netz) oder im Desktop-Client unter „Zertifikat“ die `certs/fullchain.pem` des Servers hinterlegen. + +> Mit dem alten `docker-compose` (v1) funktioniert es genauso: `docker-compose up -d --build`. + +## Desktop-Client (Windows, Linux, macOS) + +Python + Qt (PySide6), eine Codebasis für alle drei Systeme. Läuft im System-Tray. + +- **Tray-Menü:** die letzten 10 Einträge zum schnellen Einfügen, Verlauf, Pausieren, Einstellungen +- **Verlaufsfenster:** Suche, Doppelklick/Enter = in die Zwischenablage, „Speichern unter …“, Löschen +- **Einstellungen:** Server, Token (**Erzeugen**), Server-Schlüssel, Zertifikat, Gerätename, was + synchronisiert wird, Größenlimits, Autostart +- **Verbindung teilen:** *QR-Code für Android* · *Exportieren* (.netclip-Datei) · *Importieren* +- Passwort-Manager-Einträge (KeePassXC, Bitwarden, 1Password …), die als „geheim“ markiert sind, werden + nicht synchronisiert. + +Empfangene Dateien/Ordner werden in den Cache entpackt +(`%LOCALAPPDATA%\NetClip\cache`, `~/Library/Caches/NetClip`, `~/.cache/netclip`) und als Datei-Referenz +in die Zwischenablage gelegt – Strg+V im Dateimanager kopiert sie dann an den gewünschten Ort. +Der Cache wird nach `keep_received_days` (Standard 7 Tage) aufgeräumt. + +### Starten aus dem Quellcode + +```bash +cd client-desktop +python -m venv .venv && . .venv/bin/activate # Windows: .venv\Scripts\activate +pip install -r requirements.txt +python -m netclip +``` + +### Pakete bauen + +PyInstaller baut immer für das System, auf dem es läuft – also einmal je Betriebssystem: + +```bash +cd client-desktop +python build.py +``` + +| System | Ergebnis | +|---|---| +| Windows | `dist/NetClip/NetClip.exe`, danach `packaging/windows-installer.iss` mit Inno Setup kompilieren → `dist/NetClip-Setup-1.0.0.exe` | +| macOS | `dist/NetClip.app` und `dist/NetClip-macos.zip` (Menüleisten-App ohne Dock-Symbol) | +| Linux | `dist/NetClip-linux-x86_64.tar.gz` mit `install-linux.sh` | + +### Ausrollen + +Kommandozeile (alle Systeme): + +```bash +NetClip --configure-only --server https://clip.example.de --token XYZ [--server-key K] [--insecure] [--autostart] +NetClip --import verbindung.netclip +NetClip --show # Verlauf der laufenden Instanz öffnen – z.B. auf ein Tastenkürzel legen +``` + +Vorgaben für alle Benutzer: eine `config.default.json` neben die Programmdatei legen, z.B. +`{"server": "https://clip.example.de", "token": "...", "verify_tls": false}`. Sie wird beim ersten +Start jedes Benutzers übernommen (Gerätename und Geräte-ID bleiben pro Gerät eindeutig). + +- **Windows (GPO/Intune/PDQ):** + `NetClip-Setup-1.0.0.exe /VERYSILENT /SUPPRESSMSGBOXES /SERVER=https://clip.example.de /TOKEN=xyz [/SERVERKEY=k] [/INSECURE=1]` + – schreibt `config.default.json` und richtet den Autostart für alle Benutzer ein. +- **Linux:** `sudo ./install-linux.sh --server https://clip.example.de --token xyz [--insecure]` +- **macOS:** `NetClip.app` nach `/Applications` kopieren, `config.default.json` nach + `NetClip.app/Contents/MacOS/` legen. + +Hinweis: Wer den Token kennt, sieht die Zwischenablage. Ein gemeinsamer Token für mehrere Personen +bedeutet eine gemeinsame Zwischenablage – pro Person/Team einen eigenen Token verwenden. + +### Plattform-Hinweise + +- **Linux/X11:** funktioniert direkt (Dolphin, Nautilus, Nemo, Thunar, Caja). +- **Linux/Wayland:** Wayland gibt Programmen im Hintergrund keinen Zugriff auf die Zwischenablage. + NetClip nutzt dann automatisch `wl-clipboard` (`apt install wl-clipboard`). Das funktioniert unter + KDE Plasma und wlroots-Desktops (Sway, Hyprland), **nicht unter GNOME** – dort eine X11-Sitzung nutzen. + Mit `NETCLIP_BACKEND=qt` lässt sich das Qt-Backend erzwingen. +- **macOS:** beim ersten Start ggf. in den Systemeinstellungen „Öffnen“ bestätigen (unsigniert). +- **Windows:** keine Besonderheiten; Dateien werden als „Kopieren“ (nicht „Ausschneiden“) eingefügt. + +## Android-Client + +Kotlin, ab Android 8. Läuft als Vordergrunddienst mit dauerhafter Benachrichtigung. + +- **Einrichten:** Einstellungen → *QR-Code vom PC scannen* (Desktop: Einstellungen → QR-Code für + Android) oder *.netclip-Datei importieren*. Ein `netclip://`-Link aus einer beliebigen QR-App + öffnet ebenfalls direkt die Einstellungen. +- **Empfangen:** Text und Bilder landen automatisch in der Zwischenablage. Dateien/Ordner kommen per + Benachrichtigung mit *Speichern* (→ `Download/NetClip/`, Ordnerstruktur bleibt erhalten) und *Teilen*. +- **Senden:** Android erlaubt Apps im Hintergrund nicht, die Zwischenablage zu lesen. Deshalb: + - Button *Zwischenablage senden* in der App oder in der Benachrichtigung + - Schnelleinstellungs-Kachel „Zwischenablage senden“ (Kacheln bearbeiten → hinzufügen) + - *Teilen → An NetClip senden* aus jeder App (Text, Bilder, mehrere Dateien) + - Text markieren → *An NetClip senden* im Auswahlmenü +- **Verlauf:** antippen = in die Zwischenablage; lange drücken = Speichern, Teilen, Löschen. + +### Bauen + +```bash +cd client-android +./gradlew assembleRelease # app/build/outputs/apk/release/app-release.apk +``` + +Das Release-APK ist zum Testen mit dem Debug-Schlüssel signiert; für die Verteilung in `app/build.gradle` +einen eigenen Keystore eintragen. + +## Tests + +```bash +# Kompatibilität der Verschlüsselung Python <-> Kotlin +python tools/interop.py gen /tmp/interop +(cd client-android && ./gradlew testDebugUnitTest -Pinterop=/tmp/interop) +python tools/interop.py check /tmp/interop +``` + +## Protokoll (für eigene Clients) + +| Methode | Pfad | Beschreibung | +|---|---|---| +| GET | `/healthz` | Status | +| GET | `/api/v1/info` | Server-Limits | +| GET | `/api/v1/rooms/{room}/clips` | Verlauf (`id`, `ts`, `device`, `size`, `meta`) | +| POST | `/api/v1/rooms/{room}/clips` | Body: 4 Byte Länge (BE) + Meta (Base64) + verschlüsselter Blob; Header `X-Device` | +| GET | `/api/v1/rooms/{room}/clips/{id}/blob` | verschlüsselten Inhalt laden | +| DELETE | `/api/v1/rooms/{room}/clips[/{id}]` | Verlauf leeren bzw. Eintrag löschen | +| WS | `/api/v1/rooms/{room}/ws?device=…` | Ereignisse `hello`, `clip`, `deleted`, `cleared`, `devices` | + +Mit `SERVER_KEY` ist bei allen `/api/`-Aufrufen `Authorization: Bearer ` nötig. +Verschlüsselungsformat: siehe Kopf von [client-desktop/netclip/crypto.py](client-desktop/netclip/crypto.py). diff --git a/client-android/app/build.gradle b/client-android/app/build.gradle new file mode 100644 index 0000000..4e8cf1a --- /dev/null +++ b/client-android/app/build.gradle @@ -0,0 +1,50 @@ +plugins { + id 'com.android.application' + id 'org.jetbrains.kotlin.android' +} + +android { + namespace 'de.netclip.app' + compileSdk 35 + + defaultConfig { + applicationId "de.netclip.app" + minSdk 26 + targetSdk 35 + versionCode 1 + versionName "1.0.0" + } + + buildTypes { + release { + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + // Zum Testen mit dem Debug-Schluessel signieren; fuer echte Verteilung eigenen Keystore eintragen + signingConfig signingConfigs.debug + } + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = '17' + } + buildFeatures { + viewBinding true + } + testOptions { + unitTests.all { systemProperty 'interop', project.findProperty('interop') ?: '' } + unitTests.returnDefaultValues = true + } +} + +dependencies { + implementation 'androidx.core:core-ktx:1.13.1' + implementation 'androidx.appcompat:appcompat:1.7.0' + implementation 'com.google.android.material:material:1.12.0' + implementation 'com.squareup.okhttp3:okhttp:4.12.0' + implementation 'com.journeyapps:zxing-android-embedded:4.3.0' + testImplementation 'junit:junit:4.13.2' + testImplementation 'org.json:json:20240303' +} diff --git a/client-android/app/proguard-rules.pro b/client-android/app/proguard-rules.pro new file mode 100644 index 0000000..efb628c --- /dev/null +++ b/client-android/app/proguard-rules.pro @@ -0,0 +1,4 @@ +-dontwarn okhttp3.internal.platform.** +-dontwarn org.conscrypt.** +-dontwarn org.bouncycastle.** +-dontwarn org.openjsse.** diff --git a/client-android/app/src/main/AndroidManifest.xml b/client-android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..9dc8772 --- /dev/null +++ b/client-android/app/src/main/AndroidManifest.xml @@ -0,0 +1,136 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client-android/app/src/main/java/de/netclip/app/Activities.kt b/client-android/app/src/main/java/de/netclip/app/Activities.kt new file mode 100644 index 0000000..d5e8d80 --- /dev/null +++ b/client-android/app/src/main/java/de/netclip/app/Activities.kt @@ -0,0 +1,276 @@ +package de.netclip.app + +import android.Manifest +import android.app.Activity +import android.content.ClipboardManager +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.view.Menu +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import android.widget.ArrayAdapter +import android.widget.ImageView +import android.widget.TextView +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.IntentCompat +import com.journeyapps.barcodescanner.ScanContract +import com.journeyapps.barcodescanner.ScanOptions +import de.netclip.app.databinding.ActivityMainBinding +import de.netclip.app.databinding.ActivitySettingsBinding + +class MainActivity : AppCompatActivity() { + private lateinit var b: ActivityMainBinding + private lateinit var adapter: ArrayAdapter + private val listener: () -> Unit = { refresh() } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + b = ActivityMainBinding.inflate(layoutInflater) + setContentView(b.root) + + adapter = object : ArrayAdapter(this, R.layout.item_clip, R.id.title) { + override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { + val v = super.getView(position, convertView, parent) + val e = getItem(position)!! + v.findViewById(R.id.title).text = e.title + v.findViewById(R.id.subtitle).text = e.subtitle + v.findViewById(R.id.icon).setImageResource(when (e.kind) { + "image" -> R.drawable.ic_image + "files" -> R.drawable.ic_folder + else -> R.drawable.ic_text + }) + return v + } + } + b.list.adapter = adapter + b.list.emptyView = b.empty + b.list.setOnItemClickListener { _, _, pos, _ -> Repo.fetch(adapter.getItem(pos)!!, Action.COPY) } + b.list.setOnItemLongClickListener { _, _, pos, _ -> showActions(adapter.getItem(pos)!!); true } + b.send.setOnClickListener { startActivity(Intent(this, SendClipboardActivity::class.java)) } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { + requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), 1) + } + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q && + checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) { + requestPermissions(arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE), 2) + } + if (!Repo.cfg.configured) startActivity(Intent(this, SettingsActivity::class.java)) + else SyncService.start(this) + handleIntent(intent) + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + handleIntent(intent) + } + + /** Aus Benachrichtigung: "Teilen" fuer einen Clip. */ + private fun handleIntent(intent: Intent?) { + val id = intent?.getStringExtra("id") ?: return + val action = intent.getStringExtra("action") ?: return + Repo.history.firstOrNull { it.id == id }?.let { Repo.fetch(it, Action.valueOf(action)) } + intent.removeExtra("id") + } + + override fun onStart() { + super.onStart() + Repo.addListener(listener) + if (Repo.cfg.configured) SyncService.start(this) + refresh() + } + + override fun onStop() { + Repo.removeListener(listener) + super.onStop() + } + + private fun refresh() { + adapter.clear() + adapter.addAll(Repo.history) + b.status.text = when { + !Repo.cfg.configured -> "Nicht eingerichtet" + Repo.connected -> if (Repo.devices.isEmpty()) "Verbunden · keine weiteren Geräte online" + else "Verbunden · ${Repo.devices.joinToString(", ")}" + else -> "Getrennt" + if (Repo.lastError.isNotEmpty()) ": ${Repo.lastError}" else " – verbinde …" + } + b.progress.visibility = if (Repo.progress != null) View.VISIBLE else View.GONE + b.progressText.visibility = b.progress.visibility + b.progressText.text = Repo.progress + } + + private fun showActions(e: Entry) { + val labels = arrayOf("In Zwischenablage", "Speichern (Download/NetClip)", "Teilen", "Löschen") + AlertDialog.Builder(this).setTitle(e.title).setItems(labels) { _, which -> + when (which) { + 0 -> Repo.fetch(e, Action.COPY) + 1 -> Repo.fetch(e, Action.SAVE) + 2 -> Repo.fetch(e, Action.SHARE) + 3 -> Repo.delete(e) + } + }.show() + } + + override fun onCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.main, menu) + return true + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.settings -> startActivity(Intent(this, SettingsActivity::class.java)) + R.id.clear -> AlertDialog.Builder(this).setMessage("Gesamten Verlauf für alle Geräte löschen?") + .setPositiveButton("Löschen") { _, _ -> Repo.clear() }.setNegativeButton("Abbrechen", null).show() + R.id.quit -> { + startService(Intent(this, SyncService::class.java).setAction(SyncService.ACTION_STOP)) + finishAffinity() + } + else -> return super.onOptionsItemSelected(item) + } + return true + } +} + +class SettingsActivity : AppCompatActivity() { + private lateinit var b: ActivitySettingsBinding + private val cfg get() = Repo.cfg + + private val scan = registerForActivityResult(ScanContract()) { result -> + result.contents?.let { importText(it) } + } + private val openFile = registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + uri?.let { importUri(it) } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + b = ActivitySettingsBinding.inflate(layoutInflater) + setContentView(b.root) + title = "Einstellungen" + load() + b.scan.setOnClickListener { + scan.launch(ScanOptions().setDesiredBarcodeFormats(ScanOptions.QR_CODE).setBeepEnabled(false) + .setOrientationLocked(false).setPrompt("QR-Code aus dem Desktop-Client scannen\n(Einstellungen → QR-Code für Android)")) + } + b.importFile.setOnClickListener { openFile.launch(arrayOf("*/*")) } + b.save.setOnClickListener { save() } + + // netclip://connect?... aus QR-Code einer anderen App oder einem Link, bzw. geoeffnete .netclip-Datei + intent?.data?.let { uri -> if (uri.scheme == "netclip") importText(uri.toString()) else importUri(uri) } + } + + private fun load() { + b.server.setText(cfg.server) + b.token.setText(cfg.token) + b.serverKey.setText(cfg.serverKey) + b.verifyTls.isChecked = cfg.verifyTls + b.deviceName.setText(cfg.deviceName) + b.autoCopy.isChecked = cfg.autoCopy + b.maxAuto.setText(cfg.maxAutoMb.toString()) + b.notifications.isChecked = cfg.notifications + } + + private fun importText(text: String) { + val tmp = Config(this) + val before = listOf(tmp.server, tmp.token, tmp.serverKey, tmp.verifyTls) + if (tmp.importConnection(text)) { + load() + Repo.toast("Verbindungsdaten übernommen") + if (before != listOf(tmp.server, tmp.token, tmp.serverKey, tmp.verifyTls)) SyncService.restart(this) + } else { + Repo.toast("Keine gültigen NetClip-Verbindungsdaten") + } + } + + private fun importUri(uri: Uri) { + try { + contentResolver.openInputStream(uri)?.use { importText(it.readBytes().toString(Charsets.UTF_8)) } + } catch (e: Exception) { + Repo.toast("Datei nicht lesbar: ${e.message}") + } + } + + private fun save() { + var server = b.server.text.toString().trim().trimEnd('/') + if (server.isNotEmpty() && !server.contains("://")) server = "https://$server" + cfg.server = server + cfg.token = b.token.text.toString().trim() + cfg.serverKey = b.serverKey.text.toString().trim() + cfg.verifyTls = b.verifyTls.isChecked + cfg.deviceName = b.deviceName.text.toString().trim().ifEmpty { cfg.deviceName } + cfg.autoCopy = b.autoCopy.isChecked + cfg.maxAutoMb = b.maxAuto.text.toString().toIntOrNull() ?: 20 + cfg.notifications = b.notifications.isChecked + if (cfg.configured) SyncService.restart(this) + finish() + } +} + +/** Empfaengt "Teilen" aus anderen Apps (Text, Bilder, Dateien) und markierten Text (PROCESS_TEXT). */ +class ShareActivity : Activity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val content: Outgoing? = when (intent.action) { + Intent.ACTION_PROCESS_TEXT -> intent.getCharSequenceExtra(Intent.EXTRA_PROCESS_TEXT)?.let { Outgoing.Text(it.toString()) } + Intent.ACTION_SEND -> { + val stream = IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java) + when { + stream != null && intent.type?.startsWith("image/") == true -> Outgoing.Image(stream) + stream != null -> Outgoing.Files(listOf(stream)) + else -> intent.getStringExtra(Intent.EXTRA_TEXT)?.let { Outgoing.Text(it) } + } + } + Intent.ACTION_SEND_MULTIPLE -> + IntentCompat.getParcelableArrayListExtra(intent, Intent.EXTRA_STREAM, Uri::class.java) + ?.takeIf { it.isNotEmpty() }?.let { Outgoing.Files(it) } + else -> null + } + if (!Repo.cfg.configured) { + Repo.toast("NetClip ist noch nicht eingerichtet") + } else if (content == null) { + Repo.toast("Nichts zum Senden gefunden") + } else { + Repo.send(content) + } + finish() + } +} + +/** + * Liest die Zwischenablage und sendet sie. Android erlaubt das nur einer App im Vordergrund, + * daher diese unsichtbare Activity (aufgerufen von Kachel, Benachrichtigung und Button). + */ +class SendClipboardActivity : Activity() { + private var done = false + + override fun onWindowFocusChanged(hasFocus: Boolean) { + super.onWindowFocusChanged(hasFocus) + if (!hasFocus || done) return + done = true + val cm = getSystemService(ClipboardManager::class.java) + val clip = cm.primaryClip + val content: Outgoing? = if (clip == null || clip.itemCount == 0) null else { + val desc = clip.description + val uris = (0 until clip.itemCount).mapNotNull { clip.getItemAt(it).uri } + when { + uris.isNotEmpty() && (0 until desc.mimeTypeCount).any { desc.getMimeType(it).startsWith("image/") } && + uris.size == 1 -> Outgoing.Image(uris[0]) + uris.isNotEmpty() -> Outgoing.Files(uris) + else -> clip.getItemAt(0).coerceToText(this)?.toString()?.takeIf { it.isNotEmpty() }?.let { Outgoing.Text(it) } + } + } + when { + !Repo.cfg.configured -> Repo.toast("NetClip ist noch nicht eingerichtet") + content == null -> Repo.toast("Zwischenablage ist leer") + else -> Repo.send(content) + } + finish() + } +} diff --git a/client-android/app/src/main/java/de/netclip/app/Api.kt b/client-android/app/src/main/java/de/netclip/app/Api.kt new file mode 100644 index 0000000..1b78ae0 --- /dev/null +++ b/client-android/app/src/main/java/de/netclip/app/Api.kt @@ -0,0 +1,123 @@ +package de.netclip.app + +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import okio.BufferedSink +import org.json.JSONArray +import org.json.JSONObject +import java.io.File +import java.io.IOException +import java.net.URLEncoder +import java.nio.ByteBuffer +import java.security.cert.X509Certificate +import java.util.concurrent.TimeUnit +import javax.net.ssl.SSLContext +import javax.net.ssl.TrustManager +import javax.net.ssl.X509TrustManager + +typealias Progress = (done: Long, total: Long) -> Unit + +class Api(private val cfg: Config, val keys: Keys) { + private val base = cfg.server.trimEnd('/') + val roomUrl = "$base/api/v1/rooms/${keys.roomId}" + + val client: OkHttpClient = OkHttpClient.Builder().apply { + connectTimeout(20, TimeUnit.SECONDS) + readTimeout(10, TimeUnit.MINUTES) + writeTimeout(10, TimeUnit.MINUTES) + pingInterval(30, TimeUnit.SECONDS) + if (!cfg.verifyTls) { + // Selbstsigniertes Zertifikat ohne Pruefung (nur im eigenen Netz verwenden!) + val trustAll = object : X509TrustManager { + override fun checkClientTrusted(chain: Array, authType: String) {} + override fun checkServerTrusted(chain: Array, authType: String) {} + override fun getAcceptedIssuers() = arrayOf() + } + val ssl = SSLContext.getInstance("TLS").apply { init(null, arrayOf(trustAll), null) } + sslSocketFactory(ssl.socketFactory, trustAll) + hostnameVerifier { _, _ -> true } + } + addInterceptor { chain -> + val req = chain.request().newBuilder() + if (cfg.serverKey.isNotEmpty()) req.header("Authorization", "Bearer ${cfg.serverKey}") + chain.proceed(req.build()) + } + }.build() + + private fun check(code: Int, message: String) { + if (code == 401) throw IOException("Server-Schluessel falsch") + if (code == 413) throw IOException("Zu gross fuer den Server") + if (code !in 200..299) throw IOException("HTTP $code $message") + } + + fun listClips(): JSONArray = + client.newCall(Request.Builder().url("$roomUrl/clips").build()).execute().use { + check(it.code, it.message) + JSONObject(it.body!!.string()).getJSONArray("clips") + } + + fun upload(meta: JSONObject, blob: File, progress: Progress?) { + val metaBytes = keys.encryptMeta(meta).toByteArray(Charsets.US_ASCII) + val total = 4L + metaBytes.size + blob.length() + val body = object : RequestBody() { + override fun contentType() = "application/octet-stream".toMediaType() + override fun contentLength() = total + override fun writeTo(sink: BufferedSink) { + sink.write(ByteBuffer.allocate(4).putInt(metaBytes.size).array()) + sink.write(metaBytes) + var done = 4L + metaBytes.size + blob.inputStream().use { input -> + val buf = ByteArray(256 * 1024) + while (true) { + val n = input.read(buf) + if (n < 0) break + sink.write(buf, 0, n) + done += n + progress?.invoke(done, total) + } + } + } + } + val req = Request.Builder().url("$roomUrl/clips").header("X-Device", cfg.deviceId).post(body).build() + client.newCall(req).execute().use { check(it.code, it.message) } + } + + fun download(id: String, dest: File, progress: Progress?) { + client.newCall(Request.Builder().url("$roomUrl/clips/$id/blob").build()).execute().use { resp -> + check(resp.code, resp.message) + val body = resp.body!! + val total = body.contentLength() + var done = 0L + body.byteStream().use { input -> + dest.outputStream().use { out -> + val buf = ByteArray(256 * 1024) + while (true) { + val n = input.read(buf) + if (n < 0) break + out.write(buf, 0, n) + done += n + progress?.invoke(done, total) + } + } + } + } + } + + fun delete(id: String) = + client.newCall(Request.Builder().url("$roomUrl/clips/$id").delete().build()).execute() + .use { check(it.code, it.message) } + + fun clear() = + client.newCall(Request.Builder().url("$roomUrl/clips").delete().build()).execute() + .use { check(it.code, it.message) } + + fun openSocket(listener: WebSocketListener): WebSocket { + val wsUrl = roomUrl.replaceFirst("https://", "wss://").replaceFirst("http://", "ws://") + + "/ws?device=" + URLEncoder.encode(cfg.deviceId, "UTF-8") + return client.newWebSocket(Request.Builder().url(wsUrl).build(), listener) + } +} diff --git a/client-android/app/src/main/java/de/netclip/app/Config.kt b/client-android/app/src/main/java/de/netclip/app/Config.kt new file mode 100644 index 0000000..44cb70a --- /dev/null +++ b/client-android/app/src/main/java/de/netclip/app/Config.kt @@ -0,0 +1,68 @@ +package de.netclip.app + +import android.content.Context +import android.net.Uri +import android.os.Build +import java.security.SecureRandom + +/** Einstellungen in SharedPreferences. */ +class Config(context: Context) { + private val prefs = context.getSharedPreferences("netclip", Context.MODE_PRIVATE) + + var server: String + get() = prefs.getString("server", "")!! + set(v) = prefs.edit().putString("server", v).apply() + var token: String + get() = prefs.getString("token", "")!! + set(v) = prefs.edit().putString("token", v).apply() + var serverKey: String + get() = prefs.getString("server_key", "")!! + set(v) = prefs.edit().putString("server_key", v).apply() + var verifyTls: Boolean + get() = prefs.getBoolean("verify_tls", true) + set(v) = prefs.edit().putBoolean("verify_tls", v).apply() + var deviceName: String + get() = prefs.getString("device_name", null) ?: "${Build.MANUFACTURER} ${Build.MODEL}" + set(v) = prefs.edit().putString("device_name", v).apply() + var autoCopy: Boolean + get() = prefs.getBoolean("auto_copy", true) + set(v) = prefs.edit().putBoolean("auto_copy", v).apply() + var maxAutoMb: Int + get() = prefs.getInt("max_auto_mb", 20) + set(v) = prefs.edit().putInt("max_auto_mb", v).apply() + var notifications: Boolean + get() = prefs.getBoolean("notifications", true) + set(v) = prefs.edit().putBoolean("notifications", v).apply() + + val deviceId: String + get() = prefs.getString("device_id", null) ?: ByteArray(8).also { SecureRandom().nextBytes(it) } + .joinToString("") { "%02x".format(it) }.also { prefs.edit().putString("device_id", it).apply() } + + val configured get() = server.isNotBlank() && token.isNotBlank() + + /** Fingerabdruck der Verbindungsdaten: aendert er sich, muss neu verbunden werden. */ + val connectionKey get() = listOf(server, token, serverKey, verifyTls, deviceName).joinToString("|") + + /** Uebernimmt netclip://connect?... (QR-Code / .netclip-Datei) oder JSON. */ + fun importConnection(text: String): Boolean { + val t = text.trim() + val values: Map = if (t.startsWith("{")) { + val o = org.json.JSONObject(t) + mapOf("server" to o.optString("server"), "token" to o.optString("token"), + "key" to o.optString("server_key"), "insecure" to if (o.optBoolean("verify_tls", true)) null else "1") + } else { + val uri = Uri.parse(t) + if (uri.scheme != "netclip") return false + mapOf("server" to uri.getQueryParameter("server"), "token" to uri.getQueryParameter("token"), + "key" to uri.getQueryParameter("key"), "insecure" to uri.getQueryParameter("insecure")) + } + val s = values["server"].orEmpty() + val tok = values["token"].orEmpty() + if (s.isBlank() || tok.isBlank()) return false + server = s + token = tok + serverKey = values["key"].orEmpty() + verifyTls = values["insecure"] != "1" + return true + } +} diff --git a/client-android/app/src/main/java/de/netclip/app/Crypto.kt b/client-android/app/src/main/java/de/netclip/app/Crypto.kt new file mode 100644 index 0000000..1b0ed01 --- /dev/null +++ b/client-android/app/src/main/java/de/netclip/app/Crypto.kt @@ -0,0 +1,130 @@ +package de.netclip.app + +import org.json.JSONObject +import java.io.DataInputStream +import java.io.EOFException +import java.io.InputStream +import java.io.OutputStream +import java.nio.ByteBuffer +import java.security.SecureRandom +import java.util.Base64 +import javax.crypto.Cipher +import javax.crypto.Mac +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.SecretKeySpec + +/** + * Ende-zu-Ende-Verschluesselung, identisch zu client-desktop/netclip/crypto.py: + * master = PBKDF2-HMAC-SHA256(token, "netclip-v1", 200000, 32) + * room_id = hex(HMAC(master, "room-id")), key = HMAC(master, "enc-key") + * Meta = base64(nonce12 || AES-GCM(json, aad="meta")) + * Blob = "NCB1" || prefix8 || { flag1 || len4 || AES-GCM(prefix||counter4, chunk, aad=flag) } + */ +class Keys(token: String) { + val roomId: String + private val key: SecretKeySpec + private val random = SecureRandom() + + init { + val master = pbkdf2(token.toByteArray(Charsets.UTF_8), "netclip-v1".toByteArray(), 200_000) + roomId = hmac(master, "room-id".toByteArray()).joinToString("") { "%02x".format(it) } + key = SecretKeySpec(hmac(master, "enc-key".toByteArray()), "AES") + } + + private fun cipher(mode: Int, nonce: ByteArray, aad: ByteArray): Cipher = + Cipher.getInstance("AES/GCM/NoPadding").apply { + init(mode, key, GCMParameterSpec(128, nonce)) + updateAAD(aad) + } + + fun encryptMeta(meta: JSONObject): String { + val nonce = ByteArray(12).also { random.nextBytes(it) } + val ct = cipher(Cipher.ENCRYPT_MODE, nonce, META_AAD).doFinal(meta.toString().toByteArray()) + return Base64.getEncoder().encodeToString(nonce + ct) + } + + fun decryptMeta(data: String): JSONObject? = try { + val raw = Base64.getDecoder().decode(data) + val plain = cipher(Cipher.DECRYPT_MODE, raw.copyOfRange(0, 12), META_AAD).doFinal(raw, 12, raw.size - 12) + JSONObject(String(plain, Charsets.UTF_8)) + } catch (e: Exception) { + null + } + + fun encryptLabel(name: String): String = encryptMeta(JSONObject().put("name", name)) + + fun encryptStream(src: InputStream, dst: OutputStream) { + val prefix = ByteArray(8).also { random.nextBytes(it) } + dst.write(MAGIC) + dst.write(prefix) + var counter = 0 + var buf = readChunk(src) + while (true) { + val next = if (buf.isNotEmpty()) readChunk(src) else ByteArray(0) + val last = next.isEmpty() + val flag = byteArrayOf(if (last) 1 else 0) + val ct = cipher(Cipher.ENCRYPT_MODE, nonce(prefix, counter), flag).doFinal(buf) + dst.write(flag) + dst.write(ByteBuffer.allocate(4).putInt(ct.size).array()) + dst.write(ct) + if (last) return + counter++ + buf = next + } + } + + fun decryptStream(src: InputStream, dst: OutputStream) { + val input = DataInputStream(src) + val magic = ByteArray(4) + input.readFully(magic) + if (!magic.contentEquals(MAGIC)) throw IllegalStateException("kein NetClip-Blob") + val prefix = ByteArray(8) + input.readFully(prefix) + var counter = 0 + while (true) { + val flag = try { input.readByte() } catch (e: EOFException) { throw IllegalStateException("Blob abgeschnitten") } + val len = input.readInt() + if (len < 16 || len > CHUNK + 16) throw IllegalStateException("ungueltiger Chunk") + val ct = ByteArray(len) + input.readFully(ct) + dst.write(cipher(Cipher.DECRYPT_MODE, nonce(prefix, counter), byteArrayOf(flag)).doFinal(ct)) + if (flag.toInt() == 1) return + counter++ + } + } + + companion object { + const val CHUNK = 1024 * 1024 + private val MAGIC = "NCB1".toByteArray() + private val META_AAD = "meta".toByteArray() + + private fun nonce(prefix: ByteArray, counter: Int) = prefix + ByteBuffer.allocate(4).putInt(counter).array() + + private fun readChunk(src: InputStream): ByteArray { + val buf = ByteArray(CHUNK) + var n = 0 + while (n < CHUNK) { + val r = src.read(buf, n, CHUNK - n) + if (r < 0) break + n += r + } + return if (n == CHUNK) buf else buf.copyOf(n) + } + + private fun hmac(key: ByteArray, data: ByteArray): ByteArray = + Mac.getInstance("HmacSHA256").run { init(SecretKeySpec(key, "HmacSHA256")); doFinal(data) } + + /** PBKDF2-HMAC-SHA256 mit genau einem Block (32 Byte), Passwort als UTF-8 wie in Python. */ + private fun pbkdf2(password: ByteArray, salt: ByteArray, iterations: Int): ByteArray { + val mac = Mac.getInstance("HmacSHA256") + mac.init(SecretKeySpec(password, "HmacSHA256")) + var u = mac.doFinal(salt + byteArrayOf(0, 0, 0, 1)) + val t = u.copyOf() + repeat(iterations - 1) { + u = mac.doFinal(u) + for (i in t.indices) t[i] = (t[i].toInt() xor u[i].toInt()).toByte() + } + return t + } + } +} diff --git a/client-android/app/src/main/java/de/netclip/app/Payload.kt b/client-android/app/src/main/java/de/netclip/app/Payload.kt new file mode 100644 index 0000000..93ec30f --- /dev/null +++ b/client-android/app/src/main/java/de/netclip/app/Payload.kt @@ -0,0 +1,146 @@ +package de.netclip.app + +import android.content.ContentValues +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.net.Uri +import android.os.Build +import android.os.Environment +import android.provider.MediaStore +import android.provider.OpenableColumns +import android.webkit.MimeTypeMap +import org.json.JSONArray +import org.json.JSONObject +import java.io.File +import java.util.zip.Deflater +import java.util.zip.ZipEntry +import java.util.zip.ZipInputStream +import java.util.zip.ZipOutputStream + +/** Ausgehender Inhalt. */ +sealed class Outgoing { + data class Text(val text: String) : Outgoing() + data class Image(val uri: Uri) : Outgoing() + data class Files(val uris: List) : Outgoing() +} + +/** Formate wie im Desktop-Client (payload.py): text=UTF-8, image=PNG, files=ZIP. */ +object Payload { + fun received(ctx: Context) = File(ctx.cacheDir, "received").apply { mkdirs() } + + private fun displayName(ctx: Context, uri: Uri): String { + ctx.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { c -> + if (c.moveToFirst() && !c.isNull(0)) return c.getString(0).replace('/', '_') + } + return uri.lastPathSegment?.substringAfterLast('/') ?: "datei" + } + + /** Schreibt die Klartext-Payload nach [out] und liefert die Meta-Daten. */ + fun build(ctx: Context, content: Outgoing, deviceName: String, out: File): JSONObject { + val meta = JSONObject().put("v", 1).put("device", deviceName) + when (content) { + is Outgoing.Text -> { + out.writeText(content.text, Charsets.UTF_8) + meta.put("kind", "text").put("preview", content.text.take(300)).put("chars", content.text.length) + } + is Outgoing.Image -> { + val mime = ctx.contentResolver.getType(content.uri) + if (mime == "image/png") { + ctx.contentResolver.openInputStream(content.uri)!!.use { i -> out.outputStream().use { i.copyTo(it) } } + } else { + val bmp = ctx.contentResolver.openInputStream(content.uri)!!.use { BitmapFactory.decodeStream(it) } + ?: throw IllegalArgumentException("Bild nicht lesbar") + out.outputStream().use { bmp.compress(Bitmap.CompressFormat.PNG, 100, it) } + bmp.recycle() + } + meta.put("kind", "image").put("mime", "image/png").put("preview", "Bild (${human(out.length())})") + } + is Outgoing.Files -> { + val names = JSONArray() + val used = mutableSetOf() + ZipOutputStream(out.outputStream().buffered()).use { zip -> + zip.setLevel(Deflater.BEST_SPEED) + for (uri in content.uris) { + var name = displayName(ctx, uri) + if (!used.add(name)) { + val base = name.substringBeforeLast('.') + val ext = name.substringAfterLast('.', "").let { if (it.isEmpty()) "" else ".$it" } + var n = 2 + while (!used.add("$base ($n)$ext")) n++ + name = "$base ($n)$ext" + } + names.put(name) + zip.putNextEntry(ZipEntry(name)) + ctx.contentResolver.openInputStream(uri)!!.use { it.copyTo(zip) } + zip.closeEntry() + } + } + meta.put("kind", "files").put("names", names).put("count", names.length()) + .put("preview", (0 until names.length()).joinToString(", ") { names.getString(it) }.take(300)) + } + } + meta.put("size", out.length()) + return meta + } + + /** Entpackt eine Dateien-Payload sicher nach [target] (kein Pfad-Traversal). */ + fun unzip(plain: File, target: File) { + val part = File(target.parentFile, target.name + ".part") + part.deleteRecursively() + part.mkdirs() + val root = part.canonicalPath + File.separator + ZipInputStream(plain.inputStream().buffered()).use { zip -> + while (true) { + val e = zip.nextEntry ?: break + val dest = File(part, e.name) + if (!dest.canonicalPath.startsWith(root)) continue + if (e.isDirectory) { + dest.mkdirs() + } else { + dest.parentFile?.mkdirs() + dest.outputStream().use { zip.copyTo(it) } + if (e.time > 0) dest.setLastModified(e.time) + } + } + } + part.renameTo(target) + } + + /** Alle Dateien unterhalb von dir (fuer Teilen/Zwischenablage). */ + fun listFiles(dir: File): List = dir.walkTopDown().filter { it.isFile }.toList() + + /** Speichert Datei/Ordner unter Download/NetClip/ (Ordnerstruktur bleibt erhalten). */ + fun saveToDownloads(ctx: Context, src: File, relDir: String = "NetClip") { + if (src.isDirectory) { + src.listFiles()?.forEach { saveToDownloads(ctx, it, "$relDir/${src.name}") } + return + } + val mime = MimeTypeMap.getSingleton().getMimeTypeFromExtension(src.extension.lowercase()) + ?: "application/octet-stream" + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val values = ContentValues().apply { + put(MediaStore.Downloads.DISPLAY_NAME, src.name) + put(MediaStore.Downloads.MIME_TYPE, mime) + put(MediaStore.Downloads.RELATIVE_PATH, "${Environment.DIRECTORY_DOWNLOADS}/$relDir") + } + val uri = ctx.contentResolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values) + ?: throw IllegalStateException("Speichern nicht moeglich") + ctx.contentResolver.openOutputStream(uri)!!.use { out -> src.inputStream().use { it.copyTo(out) } } + } else { + @Suppress("DEPRECATION") + val dir = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), relDir) + dir.mkdirs() + src.copyTo(File(dir, src.name), overwrite = true) + } + } + + fun human(n: Long): String { + var v = n.toDouble() + for (unit in listOf("B", "KB", "MB")) { + if (v < 1024) return if (unit == "B") "${n} B" else "%.1f %s".format(v, unit) + v /= 1024 + } + return "%.1f GB".format(v) + } +} diff --git a/client-android/app/src/main/java/de/netclip/app/Repo.kt b/client-android/app/src/main/java/de/netclip/app/Repo.kt new file mode 100644 index 0000000..16a1155 --- /dev/null +++ b/client-android/app/src/main/java/de/netclip/app/Repo.kt @@ -0,0 +1,355 @@ +package de.netclip.app + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Handler +import android.os.Looper +import android.widget.Toast +import androidx.core.content.FileProvider +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import org.json.JSONObject +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.concurrent.CopyOnWriteArraySet +import java.util.concurrent.Executors + +class Entry(clip: JSONObject, val meta: JSONObject, val own: Boolean) { + val id: String = clip.getString("id") + val ts: Long = clip.getLong("ts") + val size: Long = clip.getLong("size") + val kind: String = meta.optString("kind", "text") + val device: String = meta.optString("device", "?") + + val title: String + get() = when (kind) { + "text" -> meta.optString("preview").replace(Regex("\\s+"), " ").trim().take(120).ifEmpty { "(leer)" } + "files" -> { + val names = meta.optJSONArray("names") + val list = (0 until (names?.length() ?: 0)).map { names!!.getString(it) } + list.take(3).joinToString(", ") + if (list.size > 3) " (+${list.size - 3})" else "" + } + else -> meta.optString("preview") + } + + val subtitle: String + get() { + val time = SimpleDateFormat("dd.MM. HH:mm", Locale.GERMANY).format(Date(ts)) + val extra = when (kind) { + "files" -> " · ${meta.optInt("count")} Datei(en)" + "text" -> " · ${meta.optInt("chars")} Zeichen" + else -> "" + } + return "$device · $time · ${Payload.human(meta.optLong("size", size))}$extra" + } + + /** Kurze Texte stecken vollstaendig in der Vorschau. */ + val inlineText: String? + get() = if (kind == "text" && meta.optInt("chars", -1) == meta.optString("preview").length) + meta.optString("preview") else null +} + +enum class Action { COPY, SAVE, SHARE, AUTO } + +/** Zentrale Logik: Verbindung, Verlauf, Senden und Empfangen. Wird von Service und Activities genutzt. */ +object Repo { + lateinit var app: Context + private set + lateinit var cfg: Config + private set + + val history = mutableListOf() + var connected = false + private set + var devices: List = emptyList() + private set + var lastError = "" + private set + var progress: String? = null + private set + + private val listeners = CopyOnWriteArraySet<() -> Unit>() + private val main = Handler(Looper.getMainLooper()) + private val io = Executors.newFixedThreadPool(3) + private var api: Api? = null + private var apiKey = "" + private var socket: WebSocket? = null + private var active = false + private var retryDelay = 2000L + private var generation = 0 + + fun init(ctx: Context) { + app = ctx.applicationContext + cfg = Config(app) + } + + fun addListener(l: () -> Unit) = listeners.add(l) + fun removeListener(l: () -> Unit) = listeners.remove(l) + private fun changed() = main.post { listeners.forEach { it() } } + + @Synchronized + private fun api(): Api { + if (!cfg.configured) throw IllegalStateException("NetClip ist nicht eingerichtet") + if (api == null || apiKey != cfg.connectionKey) { + api = Api(cfg, Keys(cfg.token)) // Schluesselableitung dauert ~1 s + apiKey = cfg.connectionKey + } + return api!! + } + + // ------------------------------------------------------------------ Verbindung + fun connect() { + active = true + val gen = ++generation + socket?.cancel() + socket = null + if (!cfg.configured) { + lastError = "Nicht eingerichtet" + changed() + return + } + io.execute { + try { + val a = api() + if (gen == generation && active) socket = a.openSocket(Listener(gen, a)) + } catch (e: Exception) { + lastError = e.message ?: e.toString() + changed() + } + } + } + + fun disconnect() { + active = false + generation++ + socket?.close(1000, null) + socket = null + connected = false + changed() + } + + private class Listener(val gen: Int, val a: Api) : WebSocketListener() { + override fun onOpen(webSocket: WebSocket, response: Response) { + if (gen != generation) return webSocket.cancel() + webSocket.send(JSONObject().put("type", "hello").put("label", a.keys.encryptLabel(cfg.deviceName)).toString()) + retryDelay = 2000L + connected = true + lastError = "" + changed() + } + + override fun onMessage(webSocket: WebSocket, text: String) { + if (gen != generation) return + val msg = try { JSONObject(text) } catch (e: Exception) { return } + main.post { handle(msg, a) } + } + + override fun onClosed(webSocket: WebSocket, code: Int, reason: String) = lost(reason) + override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) = + lost(if (response?.code == 401) "Server-Schluessel falsch" else t.message ?: t.toString()) + + private fun lost(reason: String) { + if (gen != generation) return + connected = false + devices = emptyList() + lastError = reason + changed() + if (active) { + main.postDelayed({ if (gen == generation && active) connect() }, retryDelay) + retryDelay = (retryDelay * 2).coerceAtMost(60_000L) + } + } + } + + private fun entry(clip: JSONObject, a: Api): Entry? { + val meta = a.keys.decryptMeta(clip.getString("meta")) ?: return null + return Entry(clip, meta, clip.getString("device") == cfg.deviceId) + } + + private fun handle(msg: JSONObject, a: Api) { + when (msg.optString("type")) { + "hello" -> { + val clips = msg.getJSONArray("clips") + history.clear() + for (i in 0 until clips.length()) entry(clips.getJSONObject(i), a)?.let { history.add(it) } + } + "clip" -> { + val e = entry(msg.getJSONObject("clip"), a) ?: return + if (history.any { it.id == e.id }) return + history.add(0, e) + if (!e.own && cfg.autoCopy) { + if (e.size <= cfg.maxAutoMb * 1024L * 1024L) fetch(e, Action.AUTO) + else Notifier.clip(app, e, "Gross (${Payload.human(e.size)}) – zum Laden antippen") + } + } + "deleted" -> history.removeAll { it.id == msg.optString("id") } + "cleared" -> history.clear() + "devices" -> { + val list = msg.getJSONArray("devices") + devices = (0 until list.length()).map { list.getJSONObject(it) } + .filter { it.optString("device") != cfg.deviceId } + .map { a.keys.decryptMeta(it.optString("label"))?.optString("name") ?: it.optString("device") } + } + } + changed() + } + + // ------------------------------------------------------------------ Senden + fun send(content: Outgoing, onDone: ((String?) -> Unit)? = null) { + io.execute { + val plain = File.createTempFile("netclip", ".plain", app.cacheDir) + val enc = File.createTempFile("netclip", ".enc", app.cacheDir) + var error: String? = null + try { + setProgress("Packe …") + val a = api() + val meta = Payload.build(app, content, cfg.deviceName, plain) + plain.inputStream().buffered().use { i -> enc.outputStream().buffered().use { o -> a.keys.encryptStream(i, o) } } + plain.delete() + a.upload(meta, enc, progressCb("Sende")) + } catch (e: Exception) { + error = e.message ?: e.toString() + } finally { + plain.delete() + enc.delete() + setProgress(null) + } + main.post { + onDone?.invoke(error) + toast(if (error == null) "Gesendet" else "Senden fehlgeschlagen: $error") + } + } + } + + // ------------------------------------------------------------------ Empfangen + fun fetch(e: Entry, action: Action) { + val inline = e.inlineText + if (inline != null && (action == Action.COPY || action == Action.AUTO)) { + copyText(inline, e, action == Action.AUTO) + return + } + io.execute { + try { + val result = materialize(e) + main.post { deliver(e, result, action) } + } catch (ex: Exception) { + main.post { toast("Laden fehlgeschlagen: ${ex.message}") } + } finally { + setProgress(null) + } + } + } + + /** Laedt und entschluesselt einen Clip; Ergebnis: String (Text) oder File (Bild bzw. Ordner mit Dateien). */ + private fun materialize(e: Entry): Any { + val dir = Payload.received(app) + val target = File(dir, if (e.kind == "image") "${e.id}.png" else e.id) + if (e.kind != "text" && target.exists()) return target + val a = api() + val enc = File.createTempFile("netclip", ".enc", app.cacheDir) + val plain = File.createTempFile("netclip", ".plain", app.cacheDir) + try { + a.download(e.id, enc, progressCb("Lade von ${e.device}")) + enc.inputStream().buffered().use { i -> plain.outputStream().buffered().use { o -> a.keys.decryptStream(i, o) } } + return when (e.kind) { + "text" -> plain.readText(Charsets.UTF_8) + "image" -> { plain.renameTo(target) || plain.copyTo(target, true).exists(); target } + else -> { Payload.unzip(plain, target); target } + } + } finally { + enc.delete() + plain.delete() + } + } + + private fun deliver(e: Entry, result: Any, action: Action) { + val clipboard = app.getSystemService(ClipboardManager::class.java) + if (result is String) { + when (action) { + Action.SHARE -> startShare(Intent(Intent.ACTION_SEND).setType("text/plain").putExtra(Intent.EXTRA_TEXT, result)) + Action.SAVE -> { + val f = File(Payload.received(app), "netclip-${e.id.take(8)}.txt").apply { writeText(result) } + save(f) + } + else -> copyText(result, e, action == Action.AUTO) + } + return + } + val file = result as File + val files = if (file.isDirectory) Payload.listFiles(file) else listOf(file) + val uris = files.map { uriFor(it) } + when (action) { + Action.SAVE -> if (file.isDirectory) file.listFiles()?.forEach { save(it) } else save(file) + Action.SHARE -> { + val intent = if (uris.size == 1) Intent(Intent.ACTION_SEND).putExtra(Intent.EXTRA_STREAM, uris[0]) + else Intent(Intent.ACTION_SEND_MULTIPLE).putParcelableArrayListExtra(Intent.EXTRA_STREAM, ArrayList(uris)) + intent.setType(if (e.kind == "image") "image/png" else "*/*") + startShare(intent) + } + Action.COPY, Action.AUTO -> { + // Bilder bzw. Dateien als content://-URIs in die Zwischenablage (Einfuegen in Chats, Mail usw.) + val clip = ClipData.newUri(app.contentResolver, e.title, uris[0]) + uris.drop(1).forEach { clip.addItem(ClipData.Item(it)) } + clipboard.setPrimaryClip(clip) + if (action == Action.AUTO) Notifier.clip(app, e, if (e.kind == "image") "Bild in der Zwischenablage" else "Dateien bereit – Speichern oder Teilen") + else toast("In die Zwischenablage kopiert") + } + } + } + + private fun copyText(text: String, e: Entry, auto: Boolean) { + app.getSystemService(ClipboardManager::class.java).setPrimaryClip(ClipData.newPlainText("NetClip", text)) + if (auto) Notifier.clip(app, e, e.title) else toast("In die Zwischenablage kopiert") + } + + private fun save(f: File) { + io.execute { + try { + Payload.saveToDownloads(app, f) + main.post { toast("Gespeichert unter Download/NetClip") } + } catch (ex: Exception) { + main.post { toast("Speichern fehlgeschlagen: ${ex.message}") } + } + } + } + + fun uriFor(f: File): Uri = FileProvider.getUriForFile(app, "${app.packageName}.files", f) + + private fun startShare(intent: Intent) { + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + app.startActivity(Intent.createChooser(intent, "Teilen").addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + } + + // ------------------------------------------------------------------ Verwaltung + fun delete(e: Entry) = io.execute { runCatching { api().delete(e.id) }.onFailure { main.post { toast(it.message ?: "Fehler") } } } + fun clear() = io.execute { runCatching { api().clear() }.onFailure { main.post { toast(it.message ?: "Fehler") } } } + + fun cleanupCache(days: Int = 7) = io.execute { + val cutoff = System.currentTimeMillis() - days * 86_400_000L + Payload.received(app).listFiles()?.filter { it.lastModified() < cutoff }?.forEach { it.deleteRecursively() } + } + + private fun setProgress(text: String?) { + progress = text + changed() + } + + private fun progressCb(label: String): Progress { + var last = 0L + return { done, total -> + val now = System.currentTimeMillis() + if (now - last > 300 || done == total) { + last = now + setProgress(if (total > 0) "$label … ${Payload.human(done)} / ${Payload.human(total)}" else "$label …") + } + } + } + + fun toast(text: String) = Toast.makeText(app, text, Toast.LENGTH_SHORT).show() +} diff --git a/client-android/app/src/main/java/de/netclip/app/Services.kt b/client-android/app/src/main/java/de/netclip/app/Services.kt new file mode 100644 index 0000000..798bdce --- /dev/null +++ b/client-android/app/src/main/java/de/netclip/app/Services.kt @@ -0,0 +1,153 @@ +package de.netclip.app + +import android.app.Application +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo +import android.os.Build +import android.os.IBinder +import android.service.quicksettings.TileService +import androidx.core.app.NotificationCompat +import androidx.core.app.ServiceCompat +import androidx.core.content.ContextCompat + +class NetClipApp : Application() { + override fun onCreate() { + super.onCreate() + Repo.init(this) + Notifier.createChannels(this) + Repo.cleanupCache() + } +} + +object Notifier { + const val CH_SYNC = "sync" + const val CH_CLIPS = "clips" + const val ID_SERVICE = 1 + + fun createChannels(ctx: Context) { + val nm = ctx.getSystemService(NotificationManager::class.java) + nm.createNotificationChannel(NotificationChannel(CH_SYNC, "Verbindung", NotificationManager.IMPORTANCE_MIN) + .apply { description = "Dauerhafte Anzeige, solange NetClip verbunden ist" }) + nm.createNotificationChannel(NotificationChannel(CH_CLIPS, "Empfangene Clips", NotificationManager.IMPORTANCE_DEFAULT)) + } + + private fun openApp(ctx: Context, requestCode: Int = 0, extras: Intent.() -> Unit = {}): PendingIntent = + PendingIntent.getActivity(ctx, requestCode, Intent(ctx, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP + extras() + }, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT) + + fun service(ctx: Context): Notification { + val text = when { + Repo.connected -> if (Repo.devices.isEmpty()) "Verbunden" else "Verbunden mit ${Repo.devices.joinToString(", ")}" + else -> "Getrennt" + if (Repo.lastError.isNotEmpty()) ": ${Repo.lastError}" else "" + } + val send = PendingIntent.getActivity(ctx, 1, Intent(ctx, SendClipboardActivity::class.java) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), PendingIntent.FLAG_IMMUTABLE) + return NotificationCompat.Builder(ctx, CH_SYNC) + .setSmallIcon(R.drawable.ic_stat) + .setContentTitle("NetClip") + .setContentText(text) + .setContentIntent(openApp(ctx)) + .addAction(0, "Zwischenablage senden", send) + .setOngoing(true) + .setShowWhen(false) + .build() + } + + fun clip(ctx: Context, e: Entry, text: String) { + if (!Repo.cfg.notifications) return + val b = NotificationCompat.Builder(ctx, CH_CLIPS) + .setSmallIcon(R.drawable.ic_stat) + .setContentTitle("Von ${e.device}") + .setContentText(text) + .setAutoCancel(true) + .setContentIntent(openApp(ctx, e.id.hashCode())) + if (e.kind != "text") { + val save = PendingIntent.getBroadcast(ctx, e.id.hashCode() + 1, + Intent(ctx, ActionReceiver::class.java).putExtra("id", e.id).putExtra("action", Action.SAVE.name), + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT) + b.addAction(0, "Speichern", save) + b.addAction(0, "Teilen", openApp(ctx, e.id.hashCode() + 2) { + putExtra("id", e.id); putExtra("action", Action.SHARE.name) + }) + } + ctx.getSystemService(NotificationManager::class.java).notify(e.id.hashCode(), b.build()) + } +} + +/** Haelt die WebSocket-Verbindung im Hintergrund offen. */ +class SyncService : Service() { + private val listener: () -> Unit = { + getSystemService(NotificationManager::class.java).notify(Notifier.ID_SERVICE, Notifier.service(this)) + } + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onCreate() { + super.onCreate() + val type = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) + ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE else 0 + ServiceCompat.startForeground(this, Notifier.ID_SERVICE, Notifier.service(this), type) + Repo.addListener(listener) + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (intent?.action == ACTION_STOP) { + stopSelf() + return START_NOT_STICKY + } + if (!Repo.connected) Repo.connect() + return START_STICKY + } + + override fun onDestroy() { + Repo.removeListener(listener) + Repo.disconnect() + super.onDestroy() + } + + companion object { + const val ACTION_STOP = "stop" + fun start(ctx: Context) = ContextCompat.startForegroundService(ctx, Intent(ctx, SyncService::class.java)) + fun restart(ctx: Context) { + Repo.connect() + start(ctx) + } + } +} + +class BootReceiver : BroadcastReceiver() { + override fun onReceive(ctx: Context, intent: Intent) { + if (intent.action == Intent.ACTION_BOOT_COMPLETED && Repo.cfg.configured) SyncService.start(ctx) + } +} + +/** Benachrichtigungs-Aktion "Speichern" (ohne die App zu oeffnen). */ +class ActionReceiver : BroadcastReceiver() { + override fun onReceive(ctx: Context, intent: Intent) { + val e = Repo.history.firstOrNull { it.id == intent.getStringExtra("id") } ?: return + Repo.fetch(e, Action.valueOf(intent.getStringExtra("action") ?: return)) + } +} + +/** Schnelleinstellungs-Kachel: aktuelle Zwischenablage senden. */ +class ClipTileService : TileService() { + override fun onClick() { + val intent = Intent(this, SendClipboardActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + startActivityAndCollapse(PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_IMMUTABLE)) + } else { + @Suppress("DEPRECATION") + @android.annotation.SuppressLint("StartActivityAndCollapseDeprecated") + startActivityAndCollapse(intent) + } + } +} diff --git a/client-android/app/src/main/res/drawable/ic_folder.xml b/client-android/app/src/main/res/drawable/ic_folder.xml new file mode 100644 index 0000000..585c059 --- /dev/null +++ b/client-android/app/src/main/res/drawable/ic_folder.xml @@ -0,0 +1,4 @@ + + + diff --git a/client-android/app/src/main/res/drawable/ic_image.xml b/client-android/app/src/main/res/drawable/ic_image.xml new file mode 100644 index 0000000..c889358 --- /dev/null +++ b/client-android/app/src/main/res/drawable/ic_image.xml @@ -0,0 +1,4 @@ + + + diff --git a/client-android/app/src/main/res/drawable/ic_launcher_fg.xml b/client-android/app/src/main/res/drawable/ic_launcher_fg.xml new file mode 100644 index 0000000..399f611 --- /dev/null +++ b/client-android/app/src/main/res/drawable/ic_launcher_fg.xml @@ -0,0 +1,8 @@ + + + + + + diff --git a/client-android/app/src/main/res/drawable/ic_stat.xml b/client-android/app/src/main/res/drawable/ic_stat.xml new file mode 100644 index 0000000..5680561 --- /dev/null +++ b/client-android/app/src/main/res/drawable/ic_stat.xml @@ -0,0 +1,4 @@ + + + diff --git a/client-android/app/src/main/res/drawable/ic_text.xml b/client-android/app/src/main/res/drawable/ic_text.xml new file mode 100644 index 0000000..9ae0e1d --- /dev/null +++ b/client-android/app/src/main/res/drawable/ic_text.xml @@ -0,0 +1,4 @@ + + + diff --git a/client-android/app/src/main/res/layout/activity_main.xml b/client-android/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..cf5cac6 --- /dev/null +++ b/client-android/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + diff --git a/client-android/app/src/main/res/layout/activity_settings.xml b/client-android/app/src/main/res/layout/activity_settings.xml new file mode 100644 index 0000000..639e255 --- /dev/null +++ b/client-android/app/src/main/res/layout/activity_settings.xml @@ -0,0 +1,115 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client-android/app/src/main/res/layout/item_clip.xml b/client-android/app/src/main/res/layout/item_clip.xml new file mode 100644 index 0000000..4b2063e --- /dev/null +++ b/client-android/app/src/main/res/layout/item_clip.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + diff --git a/client-android/app/src/main/res/menu/main.xml b/client-android/app/src/main/res/menu/main.xml new file mode 100644 index 0000000..3879c0c --- /dev/null +++ b/client-android/app/src/main/res/menu/main.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/client-android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/client-android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..c3a8651 --- /dev/null +++ b/client-android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/client-android/app/src/main/res/values/colors.xml b/client-android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..d2518c5 --- /dev/null +++ b/client-android/app/src/main/res/values/colors.xml @@ -0,0 +1,3 @@ + + #3B6EA8 + diff --git a/client-android/app/src/main/res/values/strings.xml b/client-android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..85ef988 --- /dev/null +++ b/client-android/app/src/main/res/values/strings.xml @@ -0,0 +1,5 @@ + + NetClip + An NetClip senden + Zwischenablage senden + diff --git a/client-android/app/src/main/res/values/themes.xml b/client-android/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..f7c5f5b --- /dev/null +++ b/client-android/app/src/main/res/values/themes.xml @@ -0,0 +1,14 @@ + + + + diff --git a/client-android/app/src/main/res/xml/file_paths.xml b/client-android/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..6b2a20a --- /dev/null +++ b/client-android/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,4 @@ + + + + diff --git a/client-android/app/src/test/java/de/netclip/app/CryptoInteropTest.kt b/client-android/app/src/test/java/de/netclip/app/CryptoInteropTest.kt new file mode 100644 index 0000000..03244a0 --- /dev/null +++ b/client-android/app/src/test/java/de/netclip/app/CryptoInteropTest.kt @@ -0,0 +1,54 @@ +package de.netclip.app + +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assume.assumeTrue +import org.junit.Test +import java.io.File +import java.security.MessageDigest + +/** + * Prueft die Kompatibilitaet mit dem Python-Client. Testdaten erzeugt + * tools/interop.py gen ; danach: ./gradlew test -Pinterop=; tools/interop.py check + */ +class CryptoInteropTest { + private val dir = System.getProperty("interop")?.let { File(it) } + + private fun sha(f: File) = MessageDigest.getInstance("SHA-256").digest(f.readBytes()).joinToString("") { "%02x".format(it) } + + @Test + fun roundtrip() { + val keys = Keys("roundtrip") + val src = File.createTempFile("plain", ".bin").apply { writeBytes(ByteArray(3 * Keys.CHUNK + 5) { it.toByte() }) } + val enc = File.createTempFile("enc", ".bin") + val out = File.createTempFile("out", ".bin") + src.inputStream().use { i -> enc.outputStream().use { keys.encryptStream(i, it) } } + enc.inputStream().use { i -> out.outputStream().use { keys.decryptStream(i, it) } } + assertEquals(sha(src), sha(out)) + assertNull(Keys("falsch").decryptMeta(keys.encryptMeta(JSONObject().put("a", 1)))) + } + + @Test + fun pythonInterop() { + assumeTrue(dir != null) + val vectors = JSONObject(File(dir, "vectors.json").readText()) + val keys = Keys(vectors.getString("token")) + assertEquals(vectors.getString("room_id"), keys.roomId) + assertEquals("Grüße aus Python", keys.decryptMeta(vectors.getString("meta"))!!.getString("preview")) + // Blob von Python entschluesseln + val plain = File(dir, "from_python.plain") + File(dir, "from_python.enc").inputStream().use { i -> plain.outputStream().use { keys.decryptStream(i, it) } } + assertEquals(vectors.getString("sha256"), sha(plain)) + // Zip von Python entpacken + val target = File(dir, "unzipped") + target.deleteRecursively() + Payload.unzip(File(dir, "from_python.zip"), target) + assertEquals("inhalt", File(target, "Ordner/sub/datei.txt").readText()) + // Fuer Python verschluesseln + File(dir, "from_kotlin.meta").writeText(keys.encryptMeta(JSONObject().put("preview", "Hallo von Kotlin äöü"))) + File(dir, "from_python.plain").inputStream().use { i -> + File(dir, "from_kotlin.enc").outputStream().use { keys.encryptStream(i, it) } + } + } +} diff --git a/client-android/build.gradle b/client-android/build.gradle new file mode 100644 index 0000000..7a37642 --- /dev/null +++ b/client-android/build.gradle @@ -0,0 +1,4 @@ +plugins { + id 'com.android.application' version '8.7.3' apply false + id 'org.jetbrains.kotlin.android' version '2.0.21' apply false +} diff --git a/client-android/gradle.properties b/client-android/gradle.properties new file mode 100644 index 0000000..f0a2e55 --- /dev/null +++ b/client-android/gradle.properties @@ -0,0 +1,4 @@ +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +android.useAndroidX=true +kotlin.code.style=official +android.nonTransitiveRClass=true diff --git a/client-android/gradle/wrapper/gradle-wrapper.jar b/client-android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..980502d167d3610f88fa03b2f717935189d9fbcf GIT binary patch literal 43739 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 z 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 '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/client-android/gradlew.bat b/client-android/gradlew.bat new file mode 100644 index 0000000..9b42019 --- /dev/null +++ b/client-android/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/client-android/settings.gradle b/client-android/settings.gradle new file mode 100644 index 0000000..b8d0479 --- /dev/null +++ b/client-android/settings.gradle @@ -0,0 +1,16 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} +rootProject.name = "NetClip" +include ':app' diff --git a/client-desktop/build.py b/client-desktop/build.py new file mode 100644 index 0000000..af30f6a --- /dev/null +++ b/client-desktop/build.py @@ -0,0 +1,45 @@ +"""Baut den Desktop-Client fuer das aktuelle Betriebssystem (PyInstaller kann nicht cross-kompilieren). + + python build.py + +Ergebnis in dist/: + Windows dist/NetClip/NetClip.exe -> danach packaging/windows-installer.iss mit Inno Setup bauen + macOS dist/NetClip.app -> zusaetzlich dist/NetClip-macos.zip + Linux dist/NetClip/NetClip -> zusaetzlich dist/NetClip-linux-x86_64.tar.gz +""" +import platform +import shutil +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent + + +def main(): + subprocess.check_call([sys.executable, "-m", "pip", "install", "-r", "requirements.txt", "pyinstaller"], cwd=ROOT) + if platform.system() == "Windows": + make_ico() + subprocess.check_call([sys.executable, "-m", "PyInstaller", "--noconfirm", "--clean", "netclip.spec"], cwd=ROOT) + dist = ROOT / "dist" + system = platform.system() + if system == "Darwin": + shutil.make_archive(str(dist / "NetClip-macos"), "zip", dist, "NetClip.app") + elif system == "Linux": + shutil.copy(ROOT / "packaging" / "netclip.desktop", dist / "NetClip") + shutil.copy(ROOT / "packaging" / "install-linux.sh", dist / "NetClip") + shutil.make_archive(str(dist / f"NetClip-linux-{platform.machine()}"), "gztar", dist, "NetClip") + print("Fertig:", dist) + + +def make_ico(): + """Erzeugt packaging/netclip.ico aus dem gezeichneten App-Symbol.""" + from PySide6.QtWidgets import QApplication + sys.path.insert(0, str(ROOT)) + app = QApplication.instance() or QApplication([]) # noqa: F841 + from netclip.ui import app_icon + app_icon().pixmap(256, 256).save(str(ROOT / "packaging" / "netclip.ico")) + + +if __name__ == "__main__": + main() diff --git a/client-desktop/netclip-launcher.py b/client-desktop/netclip-launcher.py new file mode 100644 index 0000000..4e77a77 --- /dev/null +++ b/client-desktop/netclip-launcher.py @@ -0,0 +1,3 @@ +from netclip.__main__ import main + +main() diff --git a/client-desktop/netclip.spec b/client-desktop/netclip.spec new file mode 100644 index 0000000..8fdd085 --- /dev/null +++ b/client-desktop/netclip.spec @@ -0,0 +1,13 @@ +# PyInstaller-Spezifikation: pyinstaller netclip.spec (oder python build.py) +import sys + +a = Analysis(["netclip-launcher.py"], pathex=["."], hiddenimports=[], + excludes=["tkinter", "PySide6.QtQml", "PySide6.QtQuick", "PySide6.QtPdf"]) +pyz = PYZ(a.pure) +exe = EXE(pyz, a.scripts, [], exclude_binaries=True, name="NetClip", console=False, + icon="packaging/netclip.ico" if sys.platform == "win32" else None) +coll = COLLECT(exe, a.binaries, a.datas, name="NetClip") +if sys.platform == "darwin": + app = BUNDLE(coll, name="NetClip.app", bundle_identifier="de.netclip.client", + info_plist={"LSUIElement": True, # nur Menueleisen-Symbol, kein Dock-Icon + "CFBundleShortVersionString": "1.0.0"}) diff --git a/client-desktop/netclip/__init__.py b/client-desktop/netclip/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/client-desktop/netclip/__main__.py b/client-desktop/netclip/__main__.py new file mode 100644 index 0000000..8227f07 --- /dev/null +++ b/client-desktop/netclip/__main__.py @@ -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() diff --git a/client-desktop/netclip/api.py b/client-desktop/netclip/api.py new file mode 100644 index 0000000..1a91480 --- /dev/null +++ b/client-desktop/netclip/api.py @@ -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) diff --git a/client-desktop/netclip/autostart.py b/client-desktop/netclip/autostart.py new file mode 100644 index 0000000..7f133e6 --- /dev/null +++ b/client-desktop/netclip/autostart.py @@ -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) diff --git a/client-desktop/netclip/clipboard.py b/client-desktop/netclip/clipboard.py new file mode 100644 index 0000000..20ace71 --- /dev/null +++ b/client-desktop/netclip/clipboard.py @@ -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() diff --git a/client-desktop/netclip/config.py b/client-desktop/netclip/config.py new file mode 100644 index 0000000..fbd74c9 --- /dev/null +++ b/client-desktop/netclip/config.py @@ -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) diff --git a/client-desktop/netclip/connection.py b/client-desktop/netclip/connection.py new file mode 100644 index 0000000..d14eb3c --- /dev/null +++ b/client-desktop/netclip/connection.py @@ -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=&token=[&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 diff --git a/client-desktop/netclip/controller.py b/client-desktop/netclip/controller.py new file mode 100644 index 0000000..60276ab --- /dev/null +++ b/client-desktop/netclip/controller.py @@ -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) diff --git a/client-desktop/netclip/crypto.py b/client-desktop/netclip/crypto.py new file mode 100644 index 0000000..1f75b46 --- /dev/null +++ b/client-desktop/netclip/crypto.py @@ -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 diff --git a/client-desktop/netclip/payload.py b/client-desktop/netclip/payload.py new file mode 100644 index 0000000..6ab747f --- /dev/null +++ b/client-desktop/netclip/payload.py @@ -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 /received// 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 diff --git a/client-desktop/netclip/ui.py b/client-desktop/netclip/ui.py new file mode 100644 index 0000000..8217f73 --- /dev/null +++ b/client-desktop/netclip/ui.py @@ -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("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) diff --git a/client-desktop/packaging/install-linux.sh b/client-desktop/packaging/install-linux.sh new file mode 100755 index 0000000..85ec05f --- /dev/null +++ b/client-desktop/packaging/install-linux.sh @@ -0,0 +1,33 @@ +#!/bin/sh +# Installiert NetClip nach /opt/netclip (als root) oder ~/.local/opt/netclip (als Benutzer). +# Optional: ./install-linux.sh --server https://clip.example.de --token XYZ [--insecure] +# schreibt die Vorgaben nach config.default.json (gilt fuer alle Benutzer beim ersten Start). +set -e +SRC=$(cd "$(dirname "$0")" && pwd) +if [ "$(id -u)" = 0 ]; then + DEST=/opt/netclip; BIN=/usr/local/bin; APPS=/usr/share/applications +else + DEST=$HOME/.local/opt/netclip; BIN=$HOME/.local/bin; APPS=$HOME/.local/share/applications +fi +mkdir -p "$DEST" "$BIN" "$APPS" +cp -a "$SRC"/. "$DEST"/ +ln -sf "$DEST/NetClip" "$BIN/netclip" +cp "$SRC/netclip.desktop" "$APPS/netclip.desktop" + +SERVER=""; TOKEN=""; KEY=""; VERIFY=true +while [ $# -gt 0 ]; do + case "$1" in + --server) SERVER=$2; shift ;; + --token) TOKEN=$2; shift ;; + --server-key) KEY=$2; shift ;; + --insecure) VERIFY=false ;; + esac + shift +done +if [ -n "$SERVER" ]; then + printf '{"server": "%s", "token": "%s", "server_key": "%s", "verify_tls": %s, "autostart": true}\n' \ + "$SERVER" "$TOKEN" "$KEY" "$VERIFY" > "$DEST/config.default.json" + chmod 644 "$DEST/config.default.json" +fi +echo "NetClip installiert in $DEST, Start mit: netclip" +echo "Hinweis Wayland: Paket wl-clipboard installieren (apt install wl-clipboard)." diff --git a/client-desktop/packaging/netclip.desktop b/client-desktop/packaging/netclip.desktop new file mode 100644 index 0000000..9395d12 --- /dev/null +++ b/client-desktop/packaging/netclip.desktop @@ -0,0 +1,9 @@ +[Desktop Entry] +Type=Application +Name=NetClip +GenericName=Netzwerk-Zwischenablage +Comment=Zwischenablage zwischen Geraeten teilen +Exec=netclip +Icon=edit-paste +Categories=Utility; +StartupNotify=false diff --git a/client-desktop/packaging/windows-installer.iss b/client-desktop/packaging/windows-installer.iss new file mode 100644 index 0000000..ef5ab8e --- /dev/null +++ b/client-desktop/packaging/windows-installer.iss @@ -0,0 +1,71 @@ +; Inno Setup Skript fuer NetClip (erst "python build.py" unter Windows ausfuehren). +; Interaktiv: NetClip-Setup.exe +; Stille Verteilung (GPO, Intune, PDQ, ...): +; NetClip-Setup.exe /VERYSILENT /SUPPRESSMSGBOXES /SERVER=https://clip.example.de /TOKEN=xyz [/SERVERKEY=abc] [/INSECURE=1] +; Die Werte landen in config.default.json im Programmordner und gelten fuer jeden Benutzer beim ersten Start. +; Autostart fuer alle Benutzer wird per HKLM-Run-Key eingerichtet. + +#define AppVersion "1.0.0" + +[Setup] +AppId={{6C1E8B0B-4E39-4E0C-9C0B-6E1F2C9D5A11} +AppName=NetClip +AppVersion={#AppVersion} +AppPublisher=NetClip +DefaultDirName={autopf}\NetClip +DefaultGroupName=NetClip +OutputDir=..\dist +OutputBaseFilename=NetClip-Setup-{#AppVersion} +Compression=lzma2 +SolidCompression=yes +ArchitecturesInstallIn64BitMode=x64compatible +PrivilegesRequired=admin +SetupIconFile=netclip.ico +UninstallDisplayIcon={app}\NetClip.exe +CloseApplications=force + +[Languages] +Name: "de"; MessagesFile: "compiler:Languages\German.isl" + +[Tasks] +Name: "autostart"; Description: "NetClip beim Anmelden starten (alle Benutzer)" + +[Files] +Source: "..\dist\NetClip\*"; DestDir: "{app}"; Flags: recursesubdirs ignoreversion + +[Icons] +Name: "{group}\NetClip"; Filename: "{app}\NetClip.exe" +Name: "{group}\NetClip deinstallieren"; Filename: "{uninstallexe}" + +[Registry] +Root: HKLM; Subkey: "Software\Microsoft\Windows\CurrentVersion\Run"; ValueType: string; ValueName: "NetClip"; \ + ValueData: """{app}\NetClip.exe"" --minimized"; Flags: uninsdeletevalue; Tasks: autostart + +[Run] +Filename: "{app}\NetClip.exe"; Description: "NetClip starten"; Flags: nowait postinstall skipifsilent runasoriginaluser + +[Code] +function JsonEscape(S: String): String; +begin + StringChangeEx(S, '\', '\\', True); + StringChangeEx(S, '"', '\"', True); + Result := S; +end; + +procedure CurStepChanged(CurStep: TSetupStep); +var + Server, Verify: String; +begin + if CurStep = ssPostInstall then + begin + Server := ExpandConstant('{param:SERVER|}'); + if Server <> '' then + begin + if ExpandConstant('{param:INSECURE|0}') = '1' then Verify := 'false' else Verify := 'true'; + SaveStringToFile(ExpandConstant('{app}\config.default.json'), + '{"server": "' + JsonEscape(Server) + '", "token": "' + JsonEscape(ExpandConstant('{param:TOKEN|}')) + + '", "server_key": "' + JsonEscape(ExpandConstant('{param:SERVERKEY|}')) + '", "verify_tls": ' + Verify + '}', + False); + end; + end; +end; diff --git a/client-desktop/requirements.txt b/client-desktop/requirements.txt new file mode 100644 index 0000000..e482f32 --- /dev/null +++ b/client-desktop/requirements.txt @@ -0,0 +1,6 @@ +PySide6-Essentials>=6.6 +cryptography>=42 +requests>=2.31 +websocket-client>=1.7 +qrcode>=7.4 +pyobjc-framework-Cocoa>=10; sys_platform == "darwin" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..205987a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,48 @@ +# NetClip - Netzwerk-Zwischenablage. Daten (verschluesselte Clips) als Bind-Mount unter ./data. +services: + netclip: + build: ./server + container_name: netclip + restart: unless-stopped + environment: + TZ: ${TZ:-Europe/Berlin} + SERVER_KEY: ${SERVER_KEY:-} + MAX_CLIP_MB: ${MAX_CLIP_MB:-1024} + HISTORY_MAX: ${HISTORY_MAX:-100} + HISTORY_DAYS: ${HISTORY_DAYS:-14} + ROOM_QUOTA_MB: ${ROOM_QUOTA_MB:-5120} + LOG_LEVEL: ${LOG_LEVEL:-INFO} + volumes: + - ${DATA_DIR:-./data}:/data + ports: + - "${BIND_ADDR:-0.0.0.0}:${PORT:-8080}:8080" + + # Optional: nginx als HTTPS-Reverse-Proxy mit selbstsigniertem Zertifikat. + # Ein-/Ausschalten ueber COMPOSE_PROFILES=nginx in der .env + # (leer lassen, wenn schon ein eigener Reverse-Proxy vorhanden ist). + nginx: + profiles: ["nginx"] + build: ./nginx + container_name: netclip-nginx + restart: unless-stopped + depends_on: + - netclip + environment: + TZ: ${TZ:-Europe/Berlin} + HTTPS_PORT: ${HTTPS_PORT:-443} + NGINX_MAX_BODY: ${NGINX_MAX_BODY:-1100m} + CERT_CN: ${CERT_CN:-localhost} + CERT_SAN: ${CERT_SAN:-} + CERT_DAYS: ${CERT_DAYS:-825} + CERT_RENEW_DAYS: ${CERT_RENEW_DAYS:-30} + CERT_KEY_BITS: ${CERT_KEY_BITS:-2048} + CERT_COUNTRY: ${CERT_COUNTRY:-DE} + CERT_STATE: ${CERT_STATE:-Bayern} + CERT_CITY: ${CERT_CITY:-Muenchen} + CERT_ORG: ${CERT_ORG:-NetClip} + CERT_OU: ${CERT_OU:-IT} + volumes: + - ${CERT_DIR:-./certs}:/etc/nginx/certs + ports: + - "${HTTP_PORT:-80}:80" + - "${HTTPS_PORT:-443}:443" diff --git a/docs/eigener-reverse-proxy.md b/docs/eigener-reverse-proxy.md new file mode 100644 index 0000000..0deb883 --- /dev/null +++ b/docs/eigener-reverse-proxy.md @@ -0,0 +1,75 @@ +# NetClip hinter einem vorhandenen Reverse-Proxy + +In der `.env` `COMPOSE_PROFILES=` leer lassen – dann startet nur der NetClip-Server auf `PORT` +(Standard 8080). Läuft der Proxy auf demselben Host, zusätzlich `BIND_ADDR=127.0.0.1` setzen. + +Wichtig für jeden Proxy: +- **WebSocket-Upgrade** durchreichen (Pfad `/api/v1/rooms//ws`) +- **lange Timeouts** für die WebSocket-Verbindung (Clients pingen alle 30 s) +- **Upload-Größe** mindestens `MAX_CLIP_MB` (plus etwas Luft) +- Puffern von Uploads abschalten (sonst landet jeder Upload erst komplett im Proxy) + +## nginx + +```nginx +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} + +server { + listen 443 ssl; + http2 on; + server_name clip.example.de; + + ssl_certificate /etc/letsencrypt/live/clip.example.de/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/clip.example.de/privkey.pem; + + client_max_body_size 1100m; + proxy_request_buffering off; + proxy_buffering off; + + location / { + proxy_pass http://127.0.0.1:8080; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + } +} +``` + +## Nginx Proxy Manager + +Proxy-Host anlegen → Forward `http://:8080`, **Websockets Support** einschalten. +Unter *Advanced*: + +```nginx +client_max_body_size 1100m; +proxy_request_buffering off; +proxy_read_timeout 3600s; +``` + +## Traefik (Labels am Service `netclip`) + +```yaml +labels: + - traefik.enable=true + - traefik.http.routers.netclip.rule=Host(`clip.example.de`) + - traefik.http.routers.netclip.entrypoints=websecure + - traefik.http.routers.netclip.tls.certresolver=letsencrypt + - traefik.http.services.netclip.loadbalancer.server.port=8080 +``` + +Traefik reicht WebSockets automatisch durch und begrenzt die Upload-Größe standardmäßig nicht. + +## Caddy + +``` +clip.example.de { + reverse_proxy 127.0.0.1:8080 +} +``` diff --git a/nginx/40-selfsigned-cert.sh b/nginx/40-selfsigned-cert.sh new file mode 100644 index 0000000..607d028 --- /dev/null +++ b/nginx/40-selfsigned-cert.sh @@ -0,0 +1,48 @@ +#!/bin/sh +# Erzeugt ein selbstsigniertes Zertifikat aus den CERT_*-Werten der .env. +# Neu erzeugt wird, wenn es fehlt, die CERT_*-Werte geaendert wurden oder es +# in weniger als CERT_RENEW_DAYS Tagen ablaeuft. +# Eigene Zertifikate: fullchain.pem + privkey.pem ins Zertifikatsverzeichnis legen +# und die Datei .selfsigned dort loeschen - dann wird nichts ueberschrieben. +set -eu + +DIR=/etc/nginx/certs +CRT=$DIR/fullchain.pem +KEY=$DIR/privkey.pem +MARK=$DIR/.selfsigned + +CN=${CERT_CN:-localhost} +DAYS=${CERT_DAYS:-825} +RENEW=${CERT_RENEW_DAYS:-30} +SAN=${CERT_SAN:-DNS:$CN} +SUBJ="/C=${CERT_COUNTRY:-DE}/ST=${CERT_STATE:-Bayern}/L=${CERT_CITY:-Muenchen}/O=${CERT_ORG:-NetClip}/OU=${CERT_OU:-IT}/CN=$CN" +CONF="$SUBJ|$SAN|$DAYS|${CERT_KEY_BITS:-2048}" + +mkdir -p "$DIR" + +if [ -f "$CRT" ] && [ -f "$KEY" ] && [ ! -f "$MARK" ]; then + echo "cert: eigenes Zertifikat in $DIR gefunden, wird verwendet" + exit 0 +fi + +need=0 +if [ ! -f "$CRT" ] || [ ! -f "$KEY" ]; then + need=1 +elif [ "$(cat "$MARK")" != "$CONF" ]; then + echo "cert: CERT_*-Werte geaendert" + need=1 +elif ! openssl x509 -checkend $((RENEW * 86400)) -noout -in "$CRT" >/dev/null; then + echo "cert: Zertifikat laeuft in weniger als $RENEW Tagen ab" + need=1 +fi + +if [ "$need" = 1 ]; then + echo "cert: erzeuge selbstsigniertes Zertifikat fuer $CN ($SAN), $DAYS Tage gueltig" + openssl req -x509 -nodes -newkey "rsa:${CERT_KEY_BITS:-2048}" -sha256 \ + -days "$DAYS" -subj "$SUBJ" -addext "subjectAltName=$SAN" \ + -keyout "$KEY" -out "$CRT" + chmod 600 "$KEY" + printf '%s' "$CONF" > "$MARK" +fi + +openssl x509 -noout -subject -enddate -in "$CRT" | sed 's/^/cert: /' diff --git a/nginx/Dockerfile b/nginx/Dockerfile new file mode 100644 index 0000000..9a6612c --- /dev/null +++ b/nginx/Dockerfile @@ -0,0 +1,5 @@ +FROM nginx:alpine +RUN apk add --no-cache openssl +COPY 40-selfsigned-cert.sh /docker-entrypoint.d/ +COPY templates/ /etc/nginx/templates/ +RUN chmod +x /docker-entrypoint.d/40-selfsigned-cert.sh diff --git a/nginx/templates/default.conf.template b/nginx/templates/default.conf.template new file mode 100644 index 0000000..7f89df5 --- /dev/null +++ b/nginx/templates/default.conf.template @@ -0,0 +1,42 @@ +# Wird beim Start per envsubst nach /etc/nginx/conf.d/default.conf geschrieben. +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} + +server { + listen 80; + server_name _; + return 301 https://$host:${HTTPS_PORT}$request_uri; +} + +server { + listen 443 ssl; + http2 on; + server_name _; + + ssl_certificate /etc/nginx/certs/fullchain.pem; + ssl_certificate_key /etc/nginx/certs/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_session_cache shared:SSL:1m; + + # Muss >= MAX_CLIP_MB des Servers sein (plus etwas Luft fuer die Verschluesselung) + client_max_body_size ${NGINX_MAX_BODY}; + # Uploads/Downloads direkt durchreichen statt zwischenzuspeichern + proxy_request_buffering off; + proxy_buffering off; + + location / { + proxy_pass http://netclip:8080; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + # WebSocket-Verbindungen bleiben lange offen (Clients pingen alle 30 s) + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + } +} diff --git a/server/Dockerfile b/server/Dockerfile new file mode 100644 index 0000000..9169cf9 --- /dev/null +++ b/server/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.12-alpine +WORKDIR /app +RUN apk add --no-cache su-exec && adduser -D -u 1000 netclip +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY netclip_server.py entrypoint.sh ./ +RUN chmod +x entrypoint.sh +ENV DATA_DIR=/data PORT=8080 PYTHONUNBUFFERED=1 +EXPOSE 8080 +HEALTHCHECK --interval=30s --timeout=5s CMD wget -qO- http://127.0.0.1:8080/healthz || exit 1 +ENTRYPOINT ["./entrypoint.sh"] +CMD ["python", "netclip_server.py"] diff --git a/server/entrypoint.sh b/server/entrypoint.sh new file mode 100644 index 0000000..8b7603c --- /dev/null +++ b/server/entrypoint.sh @@ -0,0 +1,9 @@ +#!/bin/sh +# Datenverzeichnis gehoert evtl. root (von Docker angelegt) -> Rechte setzen, dann ohne root starten. +set -e +if [ "$(id -u)" = 0 ]; then + mkdir -p "$DATA_DIR" + chown -R netclip:netclip "$DATA_DIR" + exec su-exec netclip "$@" +fi +exec "$@" diff --git a/server/netclip_server.py b/server/netclip_server.py new file mode 100644 index 0000000..5e6d91d --- /dev/null +++ b/server/netclip_server.py @@ -0,0 +1,307 @@ +"""NetClip-Server: reines Relay fuer verschluesselte Zwischenablage-Eintraege. + +Der Server kennt weder Token noch Inhalte. Clients leiten aus ihrem Token eine +Raum-ID (64 Hex-Zeichen) und einen Schluessel ab; der Server sieht nur die +Raum-ID und speichert/verteilt verschluesselte Blobs an alle Clients im Raum. +""" +import asyncio +import json +import logging +import os +import re +import secrets +import shutil +import struct +import time +from pathlib import Path + +from aiohttp import WSMsgType, web + +VERSION = "1.0.0" + +DATA_DIR = Path(os.environ.get("DATA_DIR", "/data")) +PORT = int(os.environ.get("PORT", "8080")) +SERVER_KEY = os.environ.get("SERVER_KEY", "") +MAX_CLIP_MB = int(os.environ.get("MAX_CLIP_MB", "1024")) +HISTORY_MAX = int(os.environ.get("HISTORY_MAX", "100")) +HISTORY_DAYS = float(os.environ.get("HISTORY_DAYS", "14")) +ROOM_QUOTA_MB = int(os.environ.get("ROOM_QUOTA_MB", "5120")) +MAX_META_BYTES = 256 * 1024 + +ROOM_RE = re.compile(r"^[0-9a-f]{64}$") +CLIP_RE = re.compile(r"^[0-9a-f]{32}$") +DEVICE_RE = re.compile(r"^[A-Za-z0-9_-]{1,64}$") + +log = logging.getLogger("netclip") + + +class Room: + def __init__(self, room_id: str): + self.id = room_id + self.dir = DATA_DIR / "rooms" / room_id + self.index_file = self.dir / "index.json" + self.clips: list[dict] = [] # neueste zuerst + self.sockets: dict[web.WebSocketResponse, dict] = {} + self.lock = asyncio.Lock() + if self.index_file.exists(): + try: + self.clips = json.loads(self.index_file.read_text()) + except (OSError, ValueError): + log.warning("Index von Raum %s defekt, starte leer", room_id[:8]) + + def blob_path(self, clip_id: str) -> Path: + return self.dir / f"{clip_id}.bin" + + def save_index(self): + self.dir.mkdir(parents=True, exist_ok=True) + tmp = self.index_file.with_suffix(".tmp") + tmp.write_text(json.dumps(self.clips)) + os.replace(tmp, self.index_file) + + def prune(self) -> list[str]: + """Entfernt Eintraege ueber Anzahl-, Alters- und Groessenlimit. Gibt geloeschte IDs zurueck.""" + cutoff = time.time() * 1000 - HISTORY_DAYS * 86400_000 + quota = ROOM_QUOTA_MB * 1024 * 1024 + keep, removed, total = [], [], 0 + for clip in self.clips: + total += clip["size"] + if len(keep) >= HISTORY_MAX or clip["ts"] < cutoff or (keep and total > quota): + removed.append(clip["id"]) + else: + keep.append(clip) + for clip_id in removed: + self.blob_path(clip_id).unlink(missing_ok=True) + if removed: + self.clips = keep + self.save_index() + return removed + + async def broadcast(self, msg: dict): + data = json.dumps(msg) + for ws in list(self.sockets): + try: + await ws.send_str(data) + except (ConnectionError, RuntimeError): + pass + + async def broadcast_devices(self): + devices = [{"device": i["device"], "label": i["label"]} for i in self.sockets.values()] + await self.broadcast({"type": "devices", "devices": devices}) + + +rooms: dict[str, Room] = {} + + +def get_room(request: web.Request) -> Room: + room_id = request.match_info["room"] + if not ROOM_RE.match(room_id): + raise web.HTTPBadRequest(text="invalid room") + room = rooms.get(room_id) + if room is None: + room = rooms[room_id] = Room(room_id) + return room + + +def public(clip: dict) -> dict: + return {k: clip[k] for k in ("id", "ts", "device", "size", "meta")} + + +@web.middleware +async def auth_middleware(request: web.Request, handler): + if SERVER_KEY and request.path.startswith("/api/"): + auth = request.headers.get("Authorization", "") + if not secrets.compare_digest(auth.encode(), f"Bearer {SERVER_KEY}".encode()): + raise web.HTTPUnauthorized(text="invalid server key") + return await handler(request) + + +async def health(_request): + return web.json_response({"ok": True, "version": VERSION}) + + +async def info(_request): + return web.json_response({"version": VERSION, "max_clip_mb": MAX_CLIP_MB, + "history_max": HISTORY_MAX, "history_days": HISTORY_DAYS}) + + +async def list_clips(request): + room = get_room(request) + return web.json_response({"clips": [public(c) for c in room.clips]}) + + +async def upload_clip(request: web.Request): + """Body: 4 Byte Laenge (big endian) + Meta (Base64, verschluesselt) + verschluesselter Blob.""" + room = get_room(request) + device = request.headers.get("X-Device", "") + if not DEVICE_RE.match(device): + raise web.HTTPBadRequest(text="invalid device") + max_bytes = MAX_CLIP_MB * 1024 * 1024 + if request.content_length and request.content_length > max_bytes + MAX_META_BYTES: + raise web.HTTPRequestEntityTooLarge(max_size=max_bytes, actual_size=request.content_length) + + body = request.content + try: + (meta_len,) = struct.unpack(">I", await body.readexactly(4)) + if meta_len > MAX_META_BYTES: + raise web.HTTPBadRequest(text="meta too large") + meta = (await body.readexactly(meta_len)).decode("ascii") + except asyncio.IncompleteReadError: + raise web.HTTPBadRequest(text="truncated body") + + clip_id = secrets.token_hex(16) + room.dir.mkdir(parents=True, exist_ok=True) + path = room.blob_path(clip_id) + tmp = path.with_suffix(".part") + size = 0 + try: + with open(tmp, "wb") as f: + async for chunk in body.iter_chunked(1024 * 1024): + size += len(chunk) + if size > max_bytes: + raise web.HTTPRequestEntityTooLarge(max_size=max_bytes, actual_size=size) + f.write(chunk) + os.replace(tmp, path) + finally: + tmp.unlink(missing_ok=True) + + clip = {"id": clip_id, "ts": int(time.time() * 1000), "device": device, "size": size, "meta": meta} + async with room.lock: + room.clips.insert(0, clip) + room.save_index() + removed = room.prune() + log.info("Raum %s: Clip %s von %s (%d Bytes)", room.id[:8], clip_id[:8], device, size) + await room.broadcast({"type": "clip", "clip": public(clip)}) + for clip_id_removed in removed: + await room.broadcast({"type": "deleted", "id": clip_id_removed}) + return web.json_response(public(clip), status=201) + + +async def get_blob(request): + room = get_room(request) + clip_id = request.match_info["clip"] + if not CLIP_RE.match(clip_id) or not any(c["id"] == clip_id for c in room.clips): + raise web.HTTPNotFound() + path = room.blob_path(clip_id) + if not path.exists(): + raise web.HTTPNotFound() + return web.FileResponse(path, headers={"Content-Type": "application/octet-stream"}) + + +async def delete_clip(request): + room = get_room(request) + clip_id = request.match_info["clip"] + if not CLIP_RE.match(clip_id): + raise web.HTTPBadRequest() + async with room.lock: + room.clips = [c for c in room.clips if c["id"] != clip_id] + room.blob_path(clip_id).unlink(missing_ok=True) + room.save_index() + await room.broadcast({"type": "deleted", "id": clip_id}) + return web.json_response({"ok": True}) + + +async def clear_clips(request): + room = get_room(request) + async with room.lock: + for clip in room.clips: + room.blob_path(clip["id"]).unlink(missing_ok=True) + room.clips = [] + room.save_index() + await room.broadcast({"type": "cleared"}) + return web.json_response({"ok": True}) + + +async def websocket(request): + room = get_room(request) + device = request.query.get("device", "") + if not DEVICE_RE.match(device): + raise web.HTTPBadRequest(text="invalid device") + ws = web.WebSocketResponse(heartbeat=30, max_msg_size=64 * 1024) + await ws.prepare(request) + room.sockets[ws] = {"device": device, "label": ""} + log.info("Raum %s: %s verbunden (%d online)", room.id[:8], device, len(room.sockets)) + try: + await ws.send_json({"type": "hello", "version": VERSION, "max_clip_mb": MAX_CLIP_MB, + "clips": [public(c) for c in room.clips]}) + await room.broadcast_devices() + async for msg in ws: + if msg.type != WSMsgType.TEXT: + continue + try: + data = json.loads(msg.data) + except ValueError: + continue + if data.get("type") == "hello": + # label = vom Client verschluesselter Geraetename, fuer den Server undurchsichtig + room.sockets[ws]["label"] = str(data.get("label", ""))[:1024] + await room.broadcast_devices() + elif data.get("type") == "ping": + await ws.send_json({"type": "pong"}) + finally: + room.sockets.pop(ws, None) + log.info("Raum %s: %s getrennt (%d online)", room.id[:8], device, len(room.sockets)) + await room.broadcast_devices() + if not room.sockets and not room.clips: + rooms.pop(room.id, None) + return ws + + +async def cleanup_loop(_app): + while True: + await asyncio.sleep(3600) + rooms_dir = DATA_DIR / "rooms" + if not rooms_dir.exists(): + continue + for d in rooms_dir.iterdir(): + if not ROOM_RE.match(d.name): + continue + room = rooms.get(d.name) or Room(d.name) + async with room.lock: + removed = room.prune() + for clip_id in removed: + await room.broadcast({"type": "deleted", "id": clip_id}) + if not room.clips and not room.sockets: + rooms.pop(d.name, None) + shutil.rmtree(d, ignore_errors=True) + + +async def start_background(app): + app["cleanup"] = asyncio.create_task(cleanup_loop(app)) + + +async def stop_background(app): + app["cleanup"].cancel() + for room in list(rooms.values()): + for ws in list(room.sockets): + await ws.close() + + +def make_app() -> web.Application: + app = web.Application(middlewares=[auth_middleware]) + r = "/api/v1/rooms/{room}" + app.add_routes([ + web.get("/healthz", health), + web.get("/api/v1/info", info), + web.get(f"{r}/clips", list_clips), + web.post(f"{r}/clips", upload_clip), + web.delete(f"{r}/clips", clear_clips), + web.get(f"{r}/clips/{{clip}}/blob", get_blob), + web.delete(f"{r}/clips/{{clip}}", delete_clip), + web.get(f"{r}/ws", websocket), + ]) + app.on_startup.append(start_background) + app.on_shutdown.append(stop_background) + return app + + +def main(): + logging.basicConfig(level=os.environ.get("LOG_LEVEL", "INFO"), + format="%(asctime)s %(levelname)s %(message)s") + DATA_DIR.mkdir(parents=True, exist_ok=True) + log.info("NetClip-Server %s auf Port %d, Daten in %s, Server-Key %s", + VERSION, PORT, DATA_DIR, "aktiv" if SERVER_KEY else "AUS") + web.run_app(make_app(), port=PORT, access_log=None, print=None) + + +if __name__ == "__main__": + main() diff --git a/server/requirements.txt b/server/requirements.txt new file mode 100644 index 0000000..1fd1c9d --- /dev/null +++ b/server/requirements.txt @@ -0,0 +1 @@ +aiohttp==3.10.11 diff --git a/tools/interop.py b/tools/interop.py new file mode 100644 index 0000000..7e9026d --- /dev/null +++ b/tools/interop.py @@ -0,0 +1,50 @@ +"""Kompatibilitaetstest Python <-> Kotlin (Android). + + python tools/interop.py gen /tmp/interop + (cd client-android && ./gradlew testDebugUnitTest -Pinterop=/tmp/interop) + python tools/interop.py check /tmp/interop +""" +import hashlib +import io +import json +import os +import sys +import zipfile +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "client-desktop")) +from netclip.crypto import Keys # noqa: E402 + +TOKEN = "Interop-Tökén-123" + + +def gen(d: Path): + d.mkdir(parents=True, exist_ok=True) + k = Keys(TOKEN) + data = os.urandom(2 * 1024 * 1024 + 123) + (d / "plain.bin").write_bytes(data) + with open(d / "from_python.enc", "wb") as f: + k.encrypt_stream(io.BytesIO(data), f) + with zipfile.ZipFile(d / "from_python.zip", "w") as z: + z.writestr("Ordner/", "") + z.writestr("Ordner/sub/datei.txt", "inhalt") + z.writestr("../boese.txt", "x") + (d / "vectors.json").write_text(json.dumps({ + "token": TOKEN, "room_id": k.room_id, "meta": k.encrypt_meta({"preview": "Grüße aus Python"}), + "sha256": hashlib.sha256(data).hexdigest()})) + print("Testdaten in", d) + + +def check(d: Path): + k = Keys(TOKEN) + assert k.decrypt_meta((d / "from_kotlin.meta").read_text())["preview"] == "Hallo von Kotlin äöü" + out = io.BytesIO() + with open(d / "from_kotlin.enc", "rb") as f: + k.decrypt_stream(f, out) + assert out.getvalue() == (d / "plain.bin").read_bytes() + assert not (d / "boese.txt").exists() and not (d.parent / "boese.txt").exists() + print("OK: Kotlin -> Python kompatibel") + + +if __name__ == "__main__": + {"gen": gen, "check": check}[sys.argv[1]](Path(sys.argv[2]))