commit 65e8b9440d90ec30550a0076e41c743da247f3da Author: duffyduck Date: Fri Oct 16 15:55:14 2026 +0200 first commit 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 0000000..980502d Binary files /dev/null and b/client-android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/client-android/gradle/wrapper/gradle-wrapper.properties b/client-android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..09523c0 --- /dev/null +++ b/client-android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/client-android/gradlew b/client-android/gradlew new file mode 100755 index 0000000..faf9300 --- /dev/null +++ b/client-android/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + 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]))