first commit

This commit is contained in:
duffyduck
2026-10-16 15:55:14 +02:00
commit 65e8b9440d
62 changed files with 4799 additions and 0 deletions
+22
View File
@@ -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.
+177
View File
@@ -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 <key>` nötig.
Verschlüsselungsformat: siehe Kopf von [client-desktop/netclip/crypto.py](client-desktop/netclip/crypto.py).
+50
View File
@@ -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'
}
+4
View File
@@ -0,0 +1,4 @@
-dontwarn okhttp3.internal.platform.**
-dontwarn org.conscrypt.**
-dontwarn org.bouncycastle.**
-dontwarn org.openjsse.**
@@ -0,0 +1,136 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28" />
<application
android:name=".NetClipApp"
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:theme="@style/Theme.NetClip"
android:usesCleartextTraffic="true"
tools:targetApi="35">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".SettingsActivity"
android:exported="true"
android:parentActivityName=".MainActivity">
<!-- netclip://connect?... (QR-Code mit beliebiger Scanner-App) -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="netclip" />
</intent-filter>
<!-- .netclip-Datei oeffnen -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<data android:scheme="content" />
<data android:host="*" />
<data android:mimeType="application/octet-stream" />
<data android:mimeType="text/plain" />
<data android:pathPattern=".*\\.netclip" />
</intent-filter>
</activity>
<activity
android:name=".ShareActivity"
android:excludeFromRecents="true"
android:exported="true"
android:label="@string/send_label"
android:noHistory="true"
android:theme="@style/Theme.NetClip.Invisible">
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="*/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="*/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.PROCESS_TEXT" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
</activity>
<activity
android:name=".SendClipboardActivity"
android:excludeFromRecents="true"
android:exported="false"
android:noHistory="true"
android:taskAffinity=""
android:theme="@style/Theme.NetClip.Invisible" />
<!-- QR-Scanner (zxing) auch im Hochformat -->
<activity
android:name="com.journeyapps.barcodescanner.CaptureActivity"
android:screenOrientation="fullSensor"
tools:replace="screenOrientation" />
<service
android:name=".SyncService"
android:exported="false"
android:foregroundServiceType="specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="Haelt die Verbindung zum eigenen Zwischenablage-Server, um Clips sofort zu empfangen" />
</service>
<service
android:name=".ClipTileService"
android:exported="true"
android:icon="@drawable/ic_stat"
android:label="@string/tile_label"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter>
</service>
<receiver
android:name=".BootReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
<receiver
android:name=".ActionReceiver"
android:exported="false" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.files"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>
@@ -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<Entry>
private val listener: () -> Unit = { refresh() }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
b = ActivityMainBinding.inflate(layoutInflater)
setContentView(b.root)
adapter = object : ArrayAdapter<Entry>(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<TextView>(R.id.title).text = e.title
v.findViewById<TextView>(R.id.subtitle).text = e.subtitle
v.findViewById<ImageView>(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()
}
}
@@ -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<X509Certificate>, authType: String) {}
override fun checkServerTrusted(chain: Array<X509Certificate>, authType: String) {}
override fun getAcceptedIssuers() = arrayOf<X509Certificate>()
}
val ssl = SSLContext.getInstance("TLS").apply { init(null, arrayOf<TrustManager>(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)
}
}
@@ -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<String, String?> = 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
}
}
@@ -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
}
}
}
@@ -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<Uri>) : 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<String>()
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<File> = 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)
}
}
@@ -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<Entry>()
var connected = false
private set
var devices: List<String> = 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()
}
@@ -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)
}
}
}
@@ -0,0 +1,4 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FF000000" android:pathData="M10,4H4c-1.1,0 -1.99,0.9 -1.99,2L2,18c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2V8c0,-1.1 -0.9,-2 -2,-2h-8l-2,-2z" />
</vector>
@@ -0,0 +1,4 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FF000000" android:pathData="M21,19V5c0,-1.1 -0.9,-2 -2,-2H5c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2zM8.5,13.5l2.5,3.01L14.5,12l4.5,6H5l3.5,-4.5z" />
</vector>
@@ -0,0 +1,8 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp" android:height="108dp" android:viewportWidth="108" android:viewportHeight="108">
<group android:translateX="30" android:translateY="30">
<path android:fillColor="#FFFFFFFF"
android:pathData="M38,4h-8.36C28.8,1.68 26.6,0 24,0S19.2,1.68 18.36,4H10C7.8,4 6,5.8 6,8v32c0,2.2 1.8,4 4,4h28c2.2,0 4,-1.8 4,-4V8C42,5.8 40.2,4 38,4zM24,4c1.1,0 2,0.9 2,2s-0.9,2 -2,2 -2,-0.9 -2,-2 0.9,-2 2,-2zM38,40H10V8h4v6h20V8h4v32z" />
<path android:fillColor="#FFFFFFFF" android:pathData="M15,20h18v3H15zM15,26h18v3H15zM15,32h12v3H15z" />
</group>
</vector>
@@ -0,0 +1,4 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FF000000" android:pathData="M19,2h-4.18C14.4,0.84 13.3,0 12,0S9.6,0.84 9.18,2H5C3.9,2 3,2.9 3,4v16c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2V4C21,2.9 20.1,2 19,2zM12,2c0.55,0 1,0.45 1,1s-0.45,1 -1,1 -1,-0.45 -1,-1 0.45,-1 1,-1zM19,20H5V4h2v3h10V4h2v16z" />
</vector>
@@ -0,0 +1,4 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FF000000" android:pathData="M14,2H6C4.9,2 4,2.9 4,4v16c0,1.1 0.89,2 1.99,2H18c1.1,0 2,-0.9 2,-2V8l-6,-6zM16,18H8v-2h8v2zM16,14H8v-2h8v2zM13,9V3.5L18.5,9H13z" />
</vector>
@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true"
android:orientation="vertical"
android:padding="12dp">
<TextView
android:id="@+id/status"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="?android:attr/textAppearanceSmall" />
<ProgressBar
android:id="@+id/progress"
style="?android:attr/progressBarStyleHorizontal"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:indeterminate="true"
android:visibility="gone" />
<TextView
android:id="@+id/progressText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="?android:attr/textAppearanceSmall"
android:visibility="gone" />
<com.google.android.material.button.MaterialButton
android:id="@+id/send"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="Zwischenablage senden" />
<ListView
android:id="@+id/list"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:layout_marginTop="8dp" />
<TextView
android:id="@+id/empty"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:gravity="center"
android:text="Noch nichts im Verlauf.\n\nAuf dem PC etwas kopieren oder hier über „Teilen → NetClip“ senden." />
</LinearLayout>
@@ -0,0 +1,115 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<com.google.android.material.button.MaterialButton
android:id="@+id/scan"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="QR-Code vom PC scannen" />
<com.google.android.material.button.MaterialButton
android:id="@+id/importFile"
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text=".netclip-Datei importieren" />
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:hint="Server-URL (https://clip.example.de)">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/server"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textUri" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="Token"
app:endIconMode="password_toggle"
xmlns:app="http://schemas.android.com/apk/res-auto">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/token"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textPassword" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="Server-Schlüssel (optional)"
app:endIconMode="password_toggle"
xmlns:app="http://schemas.android.com/apk/res-auto">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/serverKey"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textPassword" />
</com.google.android.material.textfield.TextInputLayout>
<CheckBox
android:id="@+id/verifyTls"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="TLS-Zertifikat prüfen (aus = selbstsigniert)" />
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="Gerätename">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/deviceName"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</com.google.android.material.textfield.TextInputLayout>
<CheckBox
android:id="@+id/autoCopy"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Empfangenes automatisch in die Zwischenablage" />
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="Automatisch laden bis (MB)">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/maxAuto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="number" />
</com.google.android.material.textfield.TextInputLayout>
<CheckBox
android:id="@+id/notifications"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Benachrichtigungen bei neuen Clips" />
<com.google.android.material.button.MaterialButton
android:id="@+id/save"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="Speichern und verbinden" />
</LinearLayout>
</ScrollView>
@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingVertical="10dp">
<ImageView
android:id="@+id/icon"
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_marginEnd="12dp"
android:importantForAccessibility="no"
app:tint="?android:attr/textColorSecondary" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="2"
android:textAppearance="?android:attr/textAppearanceMedium" />
<TextView
android:id="@+id/subtitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="?android:attr/textAppearanceSmall" />
</LinearLayout>
</LinearLayout>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item android:id="@+id/settings" android:title="Einstellungen" />
<item android:id="@+id/clear" android:title="Verlauf leeren" />
<item android:id="@+id/quit" android:title="Beenden (Verbindung trennen)" />
</menu>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/launcher_bg" />
<foreground android:drawable="@drawable/ic_launcher_fg" />
</adaptive-icon>
@@ -0,0 +1,3 @@
<resources>
<color name="launcher_bg">#3B6EA8</color>
</resources>
@@ -0,0 +1,5 @@
<resources>
<string name="app_name">NetClip</string>
<string name="send_label">An NetClip senden</string>
<string name="tile_label">Zwischenablage senden</string>
</resources>
@@ -0,0 +1,14 @@
<resources>
<style name="Theme.NetClip" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
<item name="colorPrimary">#3B6EA8</item>
<item name="colorPrimaryVariant">#2C5584</item>
<item name="colorSecondary">#2FA84F</item>
</style>
<style name="Theme.NetClip.Invisible" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<item name="android:windowIsTranslucent">true</item>
<item name="android:windowBackground">@android:color/transparent</item>
<item name="android:windowNoTitle">true</item>
<item name="android:backgroundDimEnabled">false</item>
<item name="android:windowAnimationStyle">@null</item>
</style>
</resources>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<cache-path name="received" path="received/" />
</paths>
@@ -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 <dir>; danach: ./gradlew test -Pinterop=<dir>; tools/interop.py check <dir>
*/
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) }
}
}
}
+4
View File
@@ -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
}
+4
View File
@@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true
Binary file not shown.
@@ -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
Vendored Executable
+251
View File
@@ -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" "$@"
+94
View File
@@ -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
+16
View File
@@ -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'
+45
View File
@@ -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()
+3
View File
@@ -0,0 +1,3 @@
from netclip.__main__ import main
main()
+13
View File
@@ -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"})
View File
+118
View File
@@ -0,0 +1,118 @@
"""NetClip-Desktop-Client.
netclip starten (Tray-Icon)
netclip --minimized ohne Verlaufsfenster starten (Autostart)
netclip --show Verlauf der laufenden Instanz öffnen (fuer ein Tastenkuerzel)
netclip --import datei.netclip Verbindungsdaten importieren
netclip --server URL --token T [--server-key K] [--insecure] [--autostart]
Einstellungen setzen (fuer automatisches Ausrollen)
"""
import argparse
import sys
from PySide6.QtCore import QTimer
from PySide6.QtNetwork import QLocalServer, QLocalSocket
from PySide6.QtWidgets import QApplication, QMessageBox, QSystemTrayIcon
from . import autostart, config, connection
INSTANCE = "netclip-instance"
def parse_args():
p = argparse.ArgumentParser(prog="netclip", description="Netzwerk-Zwischenablage")
p.add_argument("--minimized", action="store_true")
p.add_argument("--show", action="store_true")
p.add_argument("--import", dest="import_file")
p.add_argument("--server")
p.add_argument("--token")
p.add_argument("--server-key")
p.add_argument("--device-name")
p.add_argument("--insecure", action="store_true", help="TLS-Zertifikat nicht prüfen")
p.add_argument("--autostart", action="store_true")
p.add_argument("--configure-only", action="store_true", help="nur Einstellungen schreiben, nicht starten")
return p.parse_args()
def apply_args(args) -> bool:
cfg = config.load()
changed = False
if args.import_file:
with open(args.import_file, encoding="utf-8") as f:
for k, v in connection.parse(f.read()).items():
setattr(cfg, k, v)
changed = True
for attr in ("server", "token", "server_key", "device_name"):
value = getattr(args, attr)
if value is not None:
setattr(cfg, attr, value)
changed = True
if args.insecure:
cfg.verify_tls = False
changed = True
if args.autostart:
cfg.autostart = True
autostart.set_enabled(True)
changed = True
if changed:
config.save(cfg)
return changed
def main():
args = parse_args()
changed = apply_args(args)
if args.configure_only:
print(f"Konfiguration gespeichert: {config.config_file()}")
return
app = QApplication(sys.argv)
app.setApplicationName("NetClip")
app.setQuitOnLastWindowClosed(False)
# Einzelinstanz: laeuft schon eine, dort ggf. Verlauf öffnen bzw. neu laden und beenden
sock = QLocalSocket()
sock.connectToServer(INSTANCE)
if sock.waitForConnected(300):
sock.write(b"reload\n" if changed else b"show\n")
sock.waitForBytesWritten(300)
sock.disconnectFromServer()
return
if not QSystemTrayIcon.isSystemTrayAvailable():
QMessageBox.warning(None, "NetClip", "Kein System-Tray gefunden. Der Verlauf ist über das Fenster erreichbar.")
from .controller import Controller
from .ui import HistoryWindow, Tray, app_icon
app.setWindowIcon(app_icon())
ctl = Controller()
window = HistoryWindow(ctl)
tray = Tray(ctl, window)
server = QLocalServer()
QLocalServer.removeServer(INSTANCE)
server.listen(INSTANCE)
def on_connection():
conn = server.nextPendingConnection()
def read():
cmd = bytes(conn.readAll()).decode().strip()
if cmd == "reload":
ctl.apply_config(config.load())
window.show_raise()
conn.readyRead.connect(read)
server.newConnection.connect(on_connection)
if not ctl.cfg.configured:
QTimer.singleShot(200, tray.open_settings)
elif not args.minimized:
window.show_raise()
app.aboutToQuit.connect(ctl.shutdown)
sys.exit(app.exec())
if __name__ == "__main__":
main()
+159
View File
@@ -0,0 +1,159 @@
"""HTTP- und WebSocket-Anbindung an den NetClip-Server (ohne Qt, mit Callbacks)."""
import json
import ssl
import struct
import threading
import time
from pathlib import Path
from urllib.parse import quote
import requests
import urllib3
import websocket
from .config import Config
from .crypto import Keys
class _FramedBody:
"""Datei-artiger Body: Meta-Laenge + Meta + Blob-Datei, gestreamt mit Fortschritt."""
def __init__(self, meta: str, blob: Path, progress=None):
self.head = struct.pack(">I", len(meta)) + meta.encode("ascii")
self.file = open(blob, "rb")
self.len = len(self.head) + blob.stat().st_size
self.sent = 0
self.progress = progress
def __len__(self):
return self.len
def read(self, n=-1):
if self.head:
data, self.head = self.head, b""
else:
data = self.file.read(n if n and n > 0 else 1024 * 1024)
self.sent += len(data)
if self.progress and data:
self.progress(self.sent, self.len)
return data
def close(self):
self.file.close()
class Api:
def __init__(self, cfg: Config, keys: Keys):
self.cfg = cfg
self.keys = keys
self.base = cfg.server.rstrip("/")
self.session = requests.Session()
if cfg.server_key:
self.session.headers["Authorization"] = f"Bearer {cfg.server_key}"
if not cfg.verify_tls:
self.session.verify = False
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
elif cfg.ca_file:
self.session.verify = cfg.ca_file
@property
def room_url(self) -> str:
return f"{self.base}/api/v1/rooms/{self.keys.room_id}"
def list_clips(self) -> list[dict]:
r = self.session.get(f"{self.room_url}/clips", timeout=20)
r.raise_for_status()
return r.json()["clips"]
def upload(self, meta: dict, blob: Path, progress=None) -> dict:
body = _FramedBody(self.keys.encrypt_meta(meta), blob, progress)
try:
r = self.session.post(f"{self.room_url}/clips", data=body, timeout=(20, 600),
headers={"X-Device": self.cfg.device_id,
"Content-Type": "application/octet-stream",
"Content-Length": str(len(body))})
finally:
body.close()
r.raise_for_status()
return r.json()
def download(self, clip_id: str, dest: Path, progress=None):
with self.session.get(f"{self.room_url}/clips/{clip_id}/blob", stream=True, timeout=(20, 600)) as r:
r.raise_for_status()
total = int(r.headers.get("Content-Length", 0))
done = 0
with open(dest, "wb") as f:
for chunk in r.iter_content(1024 * 1024):
f.write(chunk)
done += len(chunk)
if progress:
progress(done, total)
def delete(self, clip_id: str):
self.session.delete(f"{self.room_url}/clips/{clip_id}", timeout=20).raise_for_status()
def clear(self):
self.session.delete(f"{self.room_url}/clips", timeout=20).raise_for_status()
class Live(threading.Thread):
"""WebSocket-Verbindung mit automatischem Wiederverbinden.
on_message(dict) und on_state(connected: bool, error: str) werden im Thread aufgerufen."""
def __init__(self, api: Api, on_message, on_state):
super().__init__(daemon=True, name="netclip-ws")
self.api = api
self.on_message = on_message
self.on_state = on_state
self._halt = threading.Event()
self._ws = None
def run(self):
url = self.api.room_url.replace("https://", "wss://", 1).replace("http://", "ws://", 1)
url += f"/ws?device={quote(self.api.cfg.device_id)}"
headers = []
if self.api.cfg.server_key:
headers.append(f"Authorization: Bearer {self.api.cfg.server_key}")
sslopt = {}
if not self.api.cfg.verify_tls:
sslopt = {"cert_reqs": ssl.CERT_NONE, "check_hostname": False}
elif self.api.cfg.ca_file:
sslopt = {"ca_certs": self.api.cfg.ca_file}
delay = 2
while not self._halt.is_set():
error = ""
started = time.monotonic()
self._ws = websocket.WebSocketApp(
url, header=headers,
on_open=self._opened,
on_message=lambda _ws, msg: self._dispatch(msg),
on_error=lambda _ws, e: None if self._halt.is_set() else self.on_state(False, str(e)),
)
try:
self._ws.run_forever(sslopt=sslopt, ping_interval=30, ping_timeout=10)
except Exception as e: # noqa: BLE001 - Verbindung soll nie den Thread beenden
error = str(e)
if self._halt.is_set():
return
self.on_state(False, error)
if time.monotonic() - started > 60:
delay = 2
self._halt.wait(delay)
delay = min(delay * 2, 60)
def _opened(self, ws):
ws.send(json.dumps({"type": "hello", "label": self.api.keys.encrypt_label(self.api.cfg.device_name)}))
self.on_state(True, "")
def _dispatch(self, raw):
try:
self.on_message(json.loads(raw))
except ValueError:
pass
def stop(self, wait: float = 0):
self._halt.set()
if self._ws:
self._ws.close()
if wait and self.is_alive():
self.join(wait)
+56
View File
@@ -0,0 +1,56 @@
"""Autostart beim Anmelden (Windows: Registry Run-Key, macOS: LaunchAgent, Linux: XDG-Autostart)."""
import os
import plistlib
import shlex
import sys
from pathlib import Path
from .config import SYSTEM
LABEL = "de.netclip.client"
def command() -> list[str]:
if getattr(sys, "frozen", False):
return [sys.executable, "--minimized"]
return [sys.executable, "-m", "netclip", "--minimized"]
def _linux_file() -> Path:
base = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
return base / "autostart" / "netclip.desktop"
def _mac_file() -> Path:
return Path.home() / "Library" / "LaunchAgents" / f"{LABEL}.plist"
def set_enabled(enabled: bool):
cmd = command()
if SYSTEM == "Windows":
import winreg
key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, r"Software\Microsoft\Windows\CurrentVersion\Run",
0, winreg.KEY_SET_VALUE)
with key:
if enabled:
winreg.SetValueEx(key, "NetClip", 0, winreg.REG_SZ, " ".join(f'"{c}"' for c in cmd))
else:
try:
winreg.DeleteValue(key, "NetClip")
except FileNotFoundError:
pass
elif SYSTEM == "Darwin":
f = _mac_file()
if enabled:
f.parent.mkdir(parents=True, exist_ok=True)
f.write_bytes(plistlib.dumps({"Label": LABEL, "ProgramArguments": cmd, "RunAtLoad": True}))
else:
f.unlink(missing_ok=True)
else:
f = _linux_file()
if enabled:
f.parent.mkdir(parents=True, exist_ok=True)
f.write_text("[Desktop Entry]\nType=Application\nName=NetClip\n"
f"Exec={shlex.join(cmd)}\nIcon=edit-paste\nX-GNOME-Autostart-enabled=true\n")
else:
f.unlink(missing_ok=True)
+210
View File
@@ -0,0 +1,210 @@
"""Zugriff auf die System-Zwischenablage.
QtBackend Windows, macOS, Linux/X11. Dateien werden als Datei-URLs abgelegt
(Windows: CF_HDROP, macOS: public.file-url, X11: text/uri-list +
x-special/gnome-copied-files), d.h. Strg+V im Explorer/Finder/Dateimanager
fuegt die Dateien bzw. Ordner im aktuellen Ordner ein.
WaylandBackend Linux/Wayland über wl-clipboard (wl-paste --watch / wl-copy), da Wayland
Programmen ohne Fokus keinen Zugriff auf die Zwischenablage gibt.
Funktioniert mit KDE Plasma und wlroots (Sway, Hyprland), nicht mit GNOME.
"""
import hashlib
import os
import shutil
import subprocess
from urllib.parse import unquote, urlparse
from PySide6.QtCore import QBuffer, QByteArray, QIODevice, QMimeData, QObject, QProcess, QTimer, QUrl, Signal
from PySide6.QtGui import QClipboard, QGuiApplication, QImage
from .config import SYSTEM
from .payload import ClipContent, files_fp, text_fp
# Formate, mit denen Passwort-Manager ihre Eintraege als "nicht synchronisieren" markieren
SECRET_HINTS = ("x-kde-passwordManagerHint", "ExcludeClipboardContentFromMonitorProcessing",
"org.nspasteboard.ConcealedType", "org.nspasteboard.TransientType")
def image_fp(img: QImage) -> str:
img = img.convertToFormat(QImage.Format.Format_RGBA8888)
h = hashlib.sha256(f"{img.width()}x{img.height()}".encode())
h.update(bytes(img.constBits()))
return "image:" + h.hexdigest()
def image_to_png(img: QImage) -> bytes:
ba = QByteArray()
buf = QBuffer(ba)
buf.open(QIODevice.OpenModeFlag.WriteOnly)
img.save(buf, "PNG")
return bytes(ba.data())
def uri_list(paths: list[str]) -> bytes:
return "".join(QUrl.fromLocalFile(p).toString(QUrl.ComponentFormattingOption.FullyEncoded) + "\r\n"
for p in paths).encode()
class Backend(QObject):
changed = Signal()
def read(self) -> ClipContent | None:
raise NotImplementedError
def write(self, content: ClipContent) -> str:
"""Legt content in die Zwischenablage, gibt den Fingerabdruck zurück."""
raise NotImplementedError
class QtBackend(Backend):
def __init__(self):
super().__init__()
self.cb = QGuiApplication.clipboard()
self.cb.dataChanged.connect(self.changed)
if SYSTEM == "Darwin":
# macOS meldet fremde Aenderungen nicht zuverlaessig -> changeCount pollen
self._mac_count = -1
try:
from AppKit import NSPasteboard
self._pb = NSPasteboard.generalPasteboard()
except ImportError:
self._pb = None
self._poll = QTimer(self, interval=700, timeout=self._mac_poll)
self._poll.start()
def _mac_poll(self):
if self._pb is not None:
count = self._pb.changeCount()
if count != self._mac_count:
if self._mac_count != -1:
self.changed.emit()
self._mac_count = count
else:
md = self.cb.mimeData()
sig = (tuple(md.formats()), md.text()[:200]) if md else None
if getattr(self, "_mac_sig", sig) != sig:
self.changed.emit()
self._mac_sig = sig
def read(self):
md = self.cb.mimeData(QClipboard.Mode.Clipboard)
if md is None:
return None
formats = md.formats()
if any(hint in f for f in formats for hint in SECRET_HINTS):
return None
if md.hasUrls():
paths = [u.toLocalFile() for u in md.urls() if u.isLocalFile()]
paths = [p for p in paths if p and os.path.exists(p)]
if paths:
return ClipContent("files", paths=paths, fp=files_fp(paths))
if md.hasImage():
img = self.cb.image()
if not img.isNull():
return ClipContent("image", png=image_to_png(img), fp=image_fp(img))
if md.hasText():
text = md.text()
if text.strip():
return ClipContent("text", text=text, fp=text_fp(text))
return None
def write(self, content):
md = QMimeData()
if content.kind == "text":
md.setText(content.text)
fp = text_fp(content.text)
elif content.kind == "image":
img = QImage.fromData(content.png)
md.setImageData(img)
md.setData("image/png", content.png)
fp = image_fp(img)
else:
md.setUrls([QUrl.fromLocalFile(p) for p in content.paths])
if SYSTEM == "Linux":
# GNOME/Nautilus, Nemo, Caja, Thunar
md.setData("x-special/gnome-copied-files", b"copy\n" + uri_list(content.paths).replace(b"\r\n", b"\n").rstrip(b"\n"))
elif SYSTEM == "Windows":
# Explorer: "kopieren" statt "verschieben"
md.setData('application/x-qt-windows-mime;value="Preferred DropEffect"', b"\x01\x00\x00\x00")
fp = files_fp(content.paths)
self.cb.setMimeData(md, QClipboard.Mode.Clipboard)
return fp
class WaylandBackend(Backend):
def __init__(self):
super().__init__()
self.proc = QProcess(self)
self.proc.readyReadStandardOutput.connect(self._on_output)
self.proc.start("wl-paste", ["--watch", "echo", "x"])
def _on_output(self):
self.proc.readAllStandardOutput()
self.changed.emit()
@staticmethod
def _paste(mime: str | None = None, list_types=False) -> bytes:
args = ["wl-paste", "--no-newline"]
if list_types:
args = ["wl-paste", "--list-types"]
elif mime:
args += ["--type", mime]
r = subprocess.run(args, capture_output=True, timeout=10)
return r.stdout if r.returncode == 0 else b""
def read(self):
types = self._paste(list_types=True).decode(errors="replace").split()
if not types or any(hint in t for t in types for hint in SECRET_HINTS):
return None
if "text/uri-list" in types:
paths = []
for line in self._paste("text/uri-list").decode(errors="replace").splitlines():
u = urlparse(line.strip())
if u.scheme == "file":
p = unquote(u.path)
if os.path.exists(p):
paths.append(p)
if paths:
return ClipContent("files", paths=paths, fp=files_fp(paths))
images = [t for t in types if t.startswith("image/")]
if images:
mime = "image/png" if "image/png" in images else images[0]
data = self._paste(mime)
if mime != "image/png":
data = image_to_png(QImage.fromData(data))
if data:
return ClipContent("image", png=data, fp=ClipContent.fingerprint("image", data))
for mime in ("text/plain;charset=utf-8", "UTF8_STRING", "text/plain", "TEXT", "STRING"):
if mime in types:
text = self._paste(mime).decode("utf-8", errors="replace")
if text.strip():
return ClipContent("text", text=text, fp=text_fp(text))
break
return None
@staticmethod
def _copy(data: bytes, mime: str | None):
args = ["wl-copy"] + (["--type", mime] if mime else [])
subprocess.run(args, input=data, timeout=10)
def write(self, content):
if content.kind == "text":
self._copy(content.text.encode("utf-8"), None)
return text_fp(content.text)
if content.kind == "image":
self._copy(content.png, "image/png")
return ClipContent.fingerprint("image", content.png)
self._copy(uri_list(content.paths), "text/uri-list")
return files_fp(content.paths)
def stop(self):
self.proc.kill()
self.proc.waitForFinished(1000)
def create_backend() -> Backend:
if (SYSTEM == "Linux" and os.environ.get("WAYLAND_DISPLAY")
and shutil.which("wl-paste") and shutil.which("wl-copy")
and os.environ.get("NETCLIP_BACKEND") != "qt"):
return WaylandBackend()
return QtBackend()
+96
View File
@@ -0,0 +1,96 @@
"""Einstellungen und Pfade (plattformabhaengig, ohne Qt nutzbar)."""
import json
import os
import platform
import secrets
import socket
import sys
from dataclasses import asdict, dataclass, field, fields
from pathlib import Path
APP = "NetClip"
SYSTEM = platform.system() # Windows | Darwin | Linux
def config_dir() -> Path:
if SYSTEM == "Windows":
return Path(os.environ.get("APPDATA", Path.home())) / APP
if SYSTEM == "Darwin":
return Path.home() / "Library" / "Application Support" / APP
return Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "netclip"
def cache_dir() -> Path:
if SYSTEM == "Windows":
return Path(os.environ.get("LOCALAPPDATA", Path.home())) / APP / "cache"
if SYSTEM == "Darwin":
return Path.home() / "Library" / "Caches" / APP
return Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "netclip"
def exe_dir() -> Path:
if getattr(sys, "frozen", False):
return Path(sys.executable).resolve().parent
return Path(__file__).resolve().parent.parent
@dataclass
class Config:
server: str = "" # z.B. https://clip.example.de
token: str = "" # gemeinsamer Token aller eigenen Geraete
server_key: str = "" # optional, siehe SERVER_KEY im Server
verify_tls: bool = True # False = Zertifikat nicht pruefen (selbstsigniert, nur im LAN)
ca_file: str = "" # alternativ: Pfad zum Server-Zertifikat (fullchain.pem)
device_id: str = field(default_factory=lambda: secrets.token_hex(8))
device_name: str = field(default_factory=socket.gethostname)
send_enabled: bool = True # lokale Zwischenablage hochladen
receive_enabled: bool = True # fremde Clips automatisch in die Zwischenablage legen
send_text: bool = True
send_images: bool = True
send_files: bool = True
max_send_mb: int = 500 # groessere Dateien/Ordner werden nicht automatisch gesendet
max_auto_receive_mb: int = 200 # groessere Clips nur auf Klick im Verlauf herunterladen
keep_received_days: int = 7 # empfangene Dateien im Cache so lange aufheben
notifications: bool = True
autostart: bool = False
@property
def configured(self) -> bool:
return bool(self.server and self.token)
def config_file() -> Path:
return config_dir() / "config.json"
def load() -> Config:
"""Laedt config.json. Fuer die Verteilung: eine config.default.json neben der
Programmdatei liefert Vorgaben (Server, Token, ...), die beim ersten Start uebernommen werden."""
data = {}
default = exe_dir() / "config.default.json"
for path in (default, config_file()):
if path.exists():
try:
values = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError) as e:
print(f"Konfiguration {path} nicht lesbar: {e}", file=sys.stderr)
continue
if path == default:
values.pop("device_id", None) # muss pro Geraet eindeutig sein
values.pop("device_name", None)
data.update(values)
known = {f.name for f in fields(Config)}
cfg = Config(**{k: v for k, v in data.items() if k in known})
if not config_file().exists():
save(cfg)
return cfg
def save(cfg: Config):
path = config_file()
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(".tmp")
tmp.write_text(json.dumps(asdict(cfg), indent=2), encoding="utf-8")
os.replace(tmp, path)
if SYSTEM != "Windows":
os.chmod(path, 0o600)
+59
View File
@@ -0,0 +1,59 @@
"""Verbindungsdaten (Server, Token, ...) als Datei oder QR-Code weitergeben.
Format (Datei-Inhalt und QR-Code identisch, Android versteht dasselbe):
netclip://connect?v=1&server=<url>&token=<token>[&key=<server_key>][&insecure=1]
Eine .netclip-Datei darf alternativ JSON {"server":..,"token":..,"server_key":..,"verify_tls":..} enthalten.
"""
import json
from urllib.parse import parse_qs, urlencode, urlparse
FIELDS = ("server", "token", "server_key", "verify_tls")
def to_uri(server: str, token: str, server_key: str = "", verify_tls: bool = True) -> str:
q = {"v": "1", "server": server, "token": token}
if server_key:
q["key"] = server_key
if not verify_tls:
q["insecure"] = "1"
return "netclip://connect?" + urlencode(q)
def parse(text: str) -> dict:
"""Gibt {"server", "token", "server_key", "verify_tls"} zurück, ValueError bei ungültigem Inhalt."""
text = text.strip()
if text.startswith("{"):
data = json.loads(text)
result = {k: data[k] for k in FIELDS if k in data}
else:
u = urlparse(text)
if u.scheme != "netclip":
raise ValueError("keine NetClip-Verbindungsdaten")
q = {k: v[0] for k, v in parse_qs(u.query).items()}
result = {"server": q.get("server", ""), "token": q.get("token", ""),
"server_key": q.get("key", ""), "verify_tls": q.get("insecure") != "1"}
if not result.get("server") or not result.get("token"):
raise ValueError("Server oder Token fehlt")
return result
def qr_image(data: str):
"""QR-Code als QImage (benoetigt das Paket 'qrcode')."""
import qrcode
from PySide6.QtGui import QColor, QImage, QPainter
qr = qrcode.QRCode(error_correction=qrcode.constants.ERROR_CORRECT_M, border=4)
qr.add_data(data)
qr.make(fit=True)
matrix = qr.get_matrix()
scale = max(4, 360 // len(matrix))
size = len(matrix) * scale
img = QImage(size, size, QImage.Format.Format_RGB32)
img.fill(QColor("white"))
p = QPainter(img)
for y, row in enumerate(matrix):
for x, on in enumerate(row):
if on:
p.fillRect(x * scale, y * scale, scale, scale, QColor("black"))
p.end()
return img
+331
View File
@@ -0,0 +1,331 @@
"""Verbindet Zwischenablage, Server und Oberflaeche."""
import os
import tempfile
import time
import traceback
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from PySide6.QtCore import QObject, QTimer, Signal
from . import config, payload
from .api import Api, Live
from .clipboard import create_backend
from .crypto import CryptoError, Keys
from .payload import ClipContent, human
class Entry:
"""Ein Verlaufseintrag (Metadaten entschlüsselt, Inhalt liegt auf dem Server)."""
def __init__(self, clip: dict, meta: dict, own: bool):
self.id = clip["id"]
self.ts = clip["ts"] / 1000
self.size = clip["size"]
self.meta = meta
self.kind = meta.get("kind", "text")
self.own = own
@property
def device(self) -> str:
return self.meta.get("device", "?")
@property
def title(self) -> str:
preview = self.meta.get("preview", "")
if self.kind == "text":
return " ".join(preview.split())[:120] or "(leer)"
if self.kind == "files":
names = self.meta.get("names", [])
more = f" (+{len(names) - 3})" if len(names) > 3 else ""
return ", ".join(names[:3]) + more
return preview
@property
def subtitle(self) -> str:
t = time.strftime("%d.%m. %H:%M", time.localtime(self.ts))
extra = ""
if self.kind == "files":
extra = f" · {self.meta.get('count', 0)} Datei(en)"
elif self.kind == "text":
extra = f" · {self.meta.get('chars', 0)} Zeichen"
return f"{self.device} · {t} · {human(self.meta.get('size', self.size))}{extra}"
def inline_text(self) -> str | None:
"""Kurze Texte stecken komplett in der Vorschau -> kein Download noetig."""
if self.kind == "text" and self.meta.get("chars", -1) == len(self.meta.get("preview", "")):
return self.meta["preview"]
return None
class Controller(QObject):
history_changed = Signal()
state_changed = Signal() # Verbindung/Geräte/Pause
notify = Signal(str, str) # Titel, Text
progress = Signal(str, int) # Text, Prozent (-1 = fertig)
_ws_message = Signal(dict)
_ws_state = Signal(bool, str)
_apply_ready = Signal(object, bool) # ClipContent, automatisch?
_error = Signal(str)
def __init__(self):
super().__init__()
self.cfg = config.load()
self.keys: Keys | None = None
self.api: Api | None = None
self.live: Live | None = None
self.history: list[Entry] = []
self.devices: list[str] = []
self.connected = False
self.last_error = ""
self.paused = False
self.pool = ThreadPoolExecutor(max_workers=3)
self.last_applied_fp = ""
self.last_sent_fp = ""
self.local_generation = 0 # zaehlt lokale Kopiervorgaenge
self.backend = create_backend()
self._debounce = QTimer(self, singleShot=True, interval=350, timeout=self._check_clipboard)
self.backend.changed.connect(self._debounce.start)
self._ws_message.connect(self._on_ws_message)
self._ws_state.connect(self._on_ws_state)
self._apply_ready.connect(self._write_clipboard)
self._error.connect(lambda msg: self.notify.emit("NetClip – Fehler", msg))
self._cleanup = QTimer(self, interval=3600_000, timeout=self._cleanup_cache)
self._cleanup.start()
self._cleanup_cache()
self.connect_server()
# ---------------------------------------------------------------- Server
def connect_server(self):
if self.live:
self.live.stop()
self.live = None
self.history, self.devices, self.connected = [], [], False
self.history_changed.emit()
if not self.cfg.configured:
self.last_error = "Nicht eingerichtet"
self.state_changed.emit()
return
self.keys = Keys(self.cfg.token)
self.api = Api(self.cfg, self.keys)
self.live = Live(self.api, self._ws_message.emit, self._ws_state.emit)
self.live.start()
def apply_config(self, cfg: config.Config):
reconnect = (cfg.server, cfg.token, cfg.server_key, cfg.verify_tls, cfg.ca_file, cfg.device_name) != \
(self.cfg.server, self.cfg.token, self.cfg.server_key, self.cfg.verify_tls,
self.cfg.ca_file, self.cfg.device_name)
self.cfg = cfg
config.save(cfg)
if reconnect:
self.connect_server()
self.state_changed.emit()
def shutdown(self):
if self.live:
self.live.stop(wait=2)
self.live = None
if hasattr(self.backend, "stop"):
self.backend.stop()
self.pool.shutdown(wait=False, cancel_futures=True)
def _on_ws_state(self, connected: bool, error: str):
self.connected = connected
if error or connected:
self.last_error = error
if not connected:
self.devices = []
self.state_changed.emit()
def _entry(self, clip: dict) -> Entry | None:
try:
meta = self.keys.decrypt_meta(clip["meta"])
except CryptoError:
return None
return Entry(clip, meta, clip["device"] == self.cfg.device_id)
def _on_ws_message(self, msg: dict):
t = msg.get("type")
if t == "hello":
self.history = [e for e in map(self._entry, msg.get("clips", [])) if e]
self.history_changed.emit()
elif t == "clip":
entry = self._entry(msg["clip"])
if not entry or any(e.id == entry.id for e in self.history):
return
self.history.insert(0, entry)
self.history_changed.emit()
if not entry.own and self.cfg.receive_enabled and not self.paused:
if entry.size <= self.cfg.max_auto_receive_mb * 1024 * 1024:
self.fetch(entry, auto=True)
elif self.cfg.notifications:
self.notify.emit(f"Großer Clip von {entry.device}",
f"{entry.title} ({human(entry.size)}) – im Verlauf anklicken zum Laden")
elif t == "deleted":
self.history = [e for e in self.history if e.id != msg.get("id")]
self.history_changed.emit()
elif t == "cleared":
self.history = []
self.history_changed.emit()
elif t == "devices":
names = []
for d in msg.get("devices", []):
if d.get("device") == self.cfg.device_id:
continue
try:
names.append(self.keys.decrypt_meta(d["label"]).get("name", "?"))
except (CryptoError, KeyError):
names.append(d.get("device", "?"))
self.devices = names
self.state_changed.emit()
# ---------------------------------------------------------------- Senden
def set_paused(self, paused: bool):
self.paused = paused
self.state_changed.emit()
def _check_clipboard(self):
try:
content = self.backend.read()
except Exception as e: # noqa: BLE001
print("Zwischenablage nicht lesbar:", e)
return
if content is None or content.fp in (self.last_applied_fp, self.last_sent_fp):
return
self.local_generation += 1
cfg = self.cfg
if self.paused or not cfg.send_enabled or not self.api:
return
if not {"text": cfg.send_text, "image": cfg.send_images, "files": cfg.send_files}[content.kind]:
return
if content.kind == "files":
limit = cfg.max_send_mb * 1024 * 1024
if payload.files_size(content.paths, limit) > limit:
self.notify.emit("NetClip", f"Nicht gesendet: Dateien größer als {cfg.max_send_mb} MB")
return
self.last_sent_fp = content.fp
self.pool.submit(self._upload, content)
def send_current(self):
"""Manuell senden (Menue), auch wenn identisch zum letzten Mal."""
self.last_sent_fp = self.last_applied_fp = ""
was_paused, self.paused = self.paused, False
self._check_clipboard()
self.paused = was_paused
def _upload(self, content: ClipContent):
plain = enc = None
try:
label = {"text": "Text", "image": "Bild", "files": "Dateien"}[content.kind]
self.progress.emit(f"Packe {label} …", 0)
meta, plain = payload.build(content, self.cfg.device_name)
fd, enc = tempfile.mkstemp(prefix="netclip-", suffix=".enc")
enc = Path(enc)
with open(plain, "rb") as src, os.fdopen(fd, "wb") as dst:
self.keys.encrypt_stream(src, dst)
self.api.upload(meta, enc, self._progress_cb(f"Sende {label}"))
except Exception as e: # noqa: BLE001
traceback.print_exc()
self.last_sent_fp = ""
self._error.emit(f"Senden fehlgeschlagen: {e}")
finally:
self.progress.emit("", -1)
for p in (plain, enc):
if p:
Path(p).unlink(missing_ok=True)
def _progress_cb(self, text: str):
last = [0.0]
def cb(done, total):
now = time.monotonic()
if total and (now - last[0] > 0.2 or done >= total):
last[0] = now
self.progress.emit(f"{text} … {human(done)} / {human(total)}", int(done * 100 / total))
return cb
# ---------------------------------------------------------------- Empfangen
def fetch(self, entry: Entry, auto=False, save_to: str | None = None):
text = entry.inline_text()
if text is not None and not save_to:
self._write_clipboard(ClipContent("text", text=text), auto)
return
generation = self.local_generation
self.pool.submit(self._fetch, entry, auto, generation, save_to)
def _fetch(self, entry: Entry, auto: bool, generation: int, save_to: str | None):
enc = plain = None
try:
target = payload.received_dir() / entry.id
if entry.kind == "files" and target.exists() and not save_to:
content = ClipContent("files", paths=sorted(str(p) for p in target.iterdir()))
else:
fd, enc = tempfile.mkstemp(prefix="netclip-", suffix=".enc")
os.close(fd)
enc = Path(enc)
self.api.download(entry.id, enc, self._progress_cb(f"Lade von {entry.device}"))
fd, plain = tempfile.mkstemp(prefix="netclip-", suffix=".plain")
plain = Path(plain)
with open(enc, "rb") as src, os.fdopen(fd, "wb") as dst:
self.keys.decrypt_stream(src, dst)
enc.unlink()
enc = None
content = payload.materialize(entry.kind, plain, entry.id)
if save_to:
self._save(content, entry, Path(save_to))
self.notify.emit("NetClip", f"Gespeichert in {save_to}")
return
# Während des Downloads lokal etwas Neues kopiert? Dann nicht überschreiben.
if auto and generation != self.local_generation:
return
self._apply_ready.emit(content, auto)
if auto and self.cfg.notifications:
self.notify.emit(f"Von {entry.device}", entry.title)
except Exception as e: # noqa: BLE001
traceback.print_exc()
self._error.emit(f"Empfangen fehlgeschlagen: {e}")
finally:
self.progress.emit("", -1)
for p in (enc, plain):
if p:
Path(p).unlink(missing_ok=True)
@staticmethod
def _save(content: ClipContent, entry: Entry, folder: Path):
import shutil
folder.mkdir(parents=True, exist_ok=True)
if content.kind == "files":
for p in content.paths:
dest = folder / Path(p).name
if Path(p).is_dir():
shutil.copytree(p, dest, dirs_exist_ok=True)
else:
shutil.copy2(p, dest)
elif content.kind == "image":
(folder / f"netclip-{time.strftime('%Y%m%d-%H%M%S', time.localtime(entry.ts))}.png").write_bytes(content.png)
else:
(folder / f"netclip-{time.strftime('%Y%m%d-%H%M%S', time.localtime(entry.ts))}.txt").write_text(
content.text, encoding="utf-8")
def _write_clipboard(self, content: ClipContent, auto: bool):
try:
self.last_applied_fp = self.backend.write(content)
except Exception as e: # noqa: BLE001
self._error.emit(f"Zwischenablage nicht beschreibbar: {e}")
# ---------------------------------------------------------------- Verwaltung
def delete(self, entry: Entry):
self.pool.submit(self._call, self.api.delete, entry.id)
def clear(self):
self.pool.submit(self._call, self.api.clear)
def _call(self, fn, *args):
try:
fn(*args)
except Exception as e: # noqa: BLE001
self._error.emit(str(e))
def _cleanup_cache(self):
payload.cleanup_received(self.cfg.keep_received_days)
+88
View File
@@ -0,0 +1,88 @@
"""Ende-zu-Ende-Verschluesselung. Muss mit client-android/.../Crypto.kt uebereinstimmen.
Ableitung: master = PBKDF2-HMAC-SHA256(token, "netclip-v1", 200000, 32)
room_id = hex(HMAC-SHA256(master, "room-id")) -> sieht der Server
key = HMAC-SHA256(master, "enc-key") -> bleibt beim Client
Meta: base64( nonce[12] || AES-256-GCM(key, nonce, json, aad="meta") )
Blob: "NCB1" || prefix[8] || Chunks
Chunk = flag[1] || len[4, BE] || AES-256-GCM(key, prefix||counter[4, BE], data, aad=flag)
flag = 1 beim letzten Chunk (verhindert Abschneiden), sonst 0
"""
import base64
import hashlib
import hmac
import json
import os
import struct
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
SALT = b"netclip-v1"
ITERATIONS = 200_000
MAGIC = b"NCB1"
CHUNK = 1024 * 1024
class CryptoError(Exception):
pass
class Keys:
def __init__(self, token: str):
master = hashlib.pbkdf2_hmac("sha256", token.encode("utf-8"), SALT, ITERATIONS, 32)
self.room_id = hmac.new(master, b"room-id", hashlib.sha256).hexdigest()
self.key = hmac.new(master, b"enc-key", hashlib.sha256).digest()
self.aead = AESGCM(self.key)
def encrypt_meta(self, meta: dict) -> str:
nonce = os.urandom(12)
ct = self.aead.encrypt(nonce, json.dumps(meta).encode("utf-8"), b"meta")
return base64.b64encode(nonce + ct).decode("ascii")
def decrypt_meta(self, data: str) -> dict:
try:
raw = base64.b64decode(data)
return json.loads(self.aead.decrypt(raw[:12], raw[12:], b"meta"))
except Exception as e: # falscher Token oder manipuliert
raise CryptoError(str(e)) from e
def encrypt_label(self, text: str) -> str:
return self.encrypt_meta({"name": text})
def encrypt_stream(self, src, dst):
"""Liest Klartext aus src (Datei-Objekt), schreibt Blob nach dst."""
prefix = os.urandom(8)
dst.write(MAGIC + prefix)
counter = 0
buf = src.read(CHUNK)
while True:
nxt = src.read(CHUNK) if buf else b""
last = not nxt
flag = b"\x01" if last else b"\x00"
ct = self.aead.encrypt(prefix + struct.pack(">I", counter), buf, flag)
dst.write(flag + struct.pack(">I", len(ct)) + ct)
if last:
return
counter += 1
buf = nxt
def decrypt_stream(self, src, dst):
if src.read(4) != MAGIC:
raise CryptoError("kein NetClip-Blob")
prefix = src.read(8)
counter = 0
while True:
head = src.read(5)
if len(head) != 5:
raise CryptoError("Blob abgeschnitten")
flag, length = head[:1], struct.unpack(">I", head[1:])[0]
if length > CHUNK + 16:
raise CryptoError("Chunk zu gross")
ct = src.read(length)
try:
dst.write(self.aead.decrypt(prefix + struct.pack(">I", counter), ct, flag))
except Exception as e:
raise CryptoError("Entschluesselung fehlgeschlagen") from e
if flag == b"\x01":
return
counter += 1
+150
View File
@@ -0,0 +1,150 @@
"""Umwandlung Zwischenablage-Inhalt <-> Klartext-Payload (+ Meta).
text : UTF-8
image : PNG
files : ZIP mit den kopierten Dateien/Ordnern auf oberster Ebene
"""
import hashlib
import os
import shutil
import tempfile
import time
import zipfile
from dataclasses import dataclass, field
from pathlib import Path
from .config import cache_dir
@dataclass
class ClipContent:
kind: str # text | image | files
text: str = ""
png: bytes = b""
paths: list[str] = field(default_factory=list)
fp: str = "" # Fingerabdruck zur Schleifen-Vermeidung
@staticmethod
def fingerprint(kind: str, data: bytes) -> str:
return kind + ":" + hashlib.sha256(data).hexdigest()
def text_fp(text: str) -> str:
return ClipContent.fingerprint("text", text.encode("utf-8"))
def files_fp(paths: list[str]) -> str:
norm = sorted(os.path.normcase(os.path.abspath(p)) for p in paths)
return ClipContent.fingerprint("files", "\n".join(norm).encode("utf-8"))
def human(n: float) -> str:
for unit in ("B", "KB", "MB", "GB"):
if n < 1024 or unit == "GB":
return f"{n:.0f} {unit}" if unit == "B" else f"{n:.1f} {unit}"
n /= 1024
return f"{n:.1f} GB"
def files_size(paths: list[str], limit: int) -> int:
"""Summe der Dateigroessen; bricht ab, sobald limit ueberschritten ist."""
total = 0
for p in paths:
p = Path(p)
if p.is_file():
total += p.stat().st_size
else:
for root, _dirs, files in os.walk(p):
for name in files:
try:
total += os.path.getsize(os.path.join(root, name))
except OSError:
pass
if total > limit:
return total
if total > limit:
return total
return total
def build(content: ClipContent, device_name: str) -> tuple[dict, Path]:
"""Schreibt die Payload in eine temporaere Datei. Rueckgabe: (meta, pfad)."""
fd, tmp = tempfile.mkstemp(prefix="netclip-", suffix=".plain")
tmp = Path(tmp)
meta = {"v": 1, "kind": content.kind, "device": device_name}
with os.fdopen(fd, "wb") as f:
if content.kind == "text":
data = content.text.encode("utf-8")
f.write(data)
meta.update(preview=content.text[:300], chars=len(content.text))
elif content.kind == "image":
f.write(content.png)
meta.update(mime="image/png", preview=f"Bild ({human(len(content.png))})")
else:
names, count = [], 0
with zipfile.ZipFile(f, "w", zipfile.ZIP_DEFLATED, compresslevel=1) as z:
for p in content.paths:
p = Path(p)
names.append(p.name)
if p.is_dir():
z.write(p, p.name + "/")
for root, dirs, files in os.walk(p):
rel_root = Path(p.name) / Path(root).relative_to(p)
for d in dirs:
z.write(Path(root) / d, (rel_root / d).as_posix() + "/")
for name in files:
try:
z.write(Path(root) / name, (rel_root / name).as_posix())
count += 1
except OSError:
pass # z.B. gesperrte Datei unter Windows
elif p.is_file():
z.write(p, p.name)
count += 1
meta.update(names=names, count=count, preview=", ".join(names)[:300])
meta["size"] = tmp.stat().st_size
return meta, tmp
def received_dir() -> Path:
return cache_dir() / "received"
def materialize(kind: str, plain: Path, clip_id: str) -> ClipContent:
"""Erzeugt aus einer entschluesselten Payload den Zwischenablage-Inhalt.
Dateien werden nach <cache>/received/<clip_id>/ entpackt."""
if kind == "text":
return ClipContent("text", text=plain.read_text("utf-8"))
if kind == "image":
return ClipContent("image", png=plain.read_bytes())
target = received_dir() / clip_id
if not target.exists():
part = target.with_name(clip_id + ".part")
shutil.rmtree(part, ignore_errors=True)
part.mkdir(parents=True)
with zipfile.ZipFile(plain) as z:
for info in z.infolist():
# Pfad-Traversal verhindern
dest = (part / info.filename).resolve()
if part.resolve() not in dest.parents and dest != part.resolve():
continue
z.extract(info, part)
if not info.is_dir():
mtime = time.mktime(info.date_time + (0, 0, -1))
os.utime(dest, (mtime, mtime))
os.replace(part, target)
paths = sorted(str(p) for p in target.iterdir())
return ClipContent("files", paths=paths)
def cleanup_received(days: int):
base = received_dir()
if not base.exists():
return
cutoff = time.time() - days * 86400
for d in base.iterdir():
try:
if d.stat().st_mtime < cutoff:
shutil.rmtree(d, ignore_errors=True)
except OSError:
pass
+420
View File
@@ -0,0 +1,420 @@
"""Tray-Icon, Verlaufsfenster und Einstellungen."""
import dataclasses
import secrets
from PySide6.QtCore import QSize, Qt
from PySide6.QtGui import QAction, QColor, QIcon, QKeySequence, QPainter, QPixmap, QShortcut
from PySide6.QtWidgets import (QApplication, QCheckBox, QDialog, QDialogButtonBox, QFileDialog, QFormLayout,
QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem, QMenu,
QMessageBox, QProgressBar, QPushButton, QSpinBox, QStyle, QSystemTrayIcon,
QVBoxLayout, QWidget)
from . import autostart, config, connection
from .controller import Controller, Entry
KIND_ICON = {"text": QStyle.StandardPixmap.SP_FileDialogDetailedView,
"image": QStyle.StandardPixmap.SP_DesktopIcon,
"files": QStyle.StandardPixmap.SP_DirIcon}
def app_icon(status: str = "") -> QIcon:
"""Zeichnet ein Klemmbrett-Symbol, optional mit Statuspunkt (ok/off/pause)."""
pm = QPixmap(64, 64)
pm.fill(Qt.GlobalColor.transparent)
p = QPainter(pm)
p.setRenderHint(QPainter.RenderHint.Antialiasing)
p.setPen(Qt.PenStyle.NoPen)
p.setBrush(QColor("#3b6ea8"))
p.drawRoundedRect(10, 8, 44, 52, 7, 7)
p.setBrush(QColor("#ffffff"))
p.drawRoundedRect(16, 16, 32, 38, 3, 3)
p.setBrush(QColor("#7a8794"))
p.drawRoundedRect(22, 3, 20, 11, 3, 3)
p.setBrush(QColor("#3b6ea8"))
for y in (24, 32, 40):
p.drawRect(21, y, 22, 3)
color = {"ok": "#2fa84f", "off": "#c0392b", "pause": "#e69b00"}.get(status)
if color:
p.setBrush(QColor("#ffffff"))
p.drawEllipse(38, 38, 26, 26)
p.setBrush(QColor(color))
p.drawEllipse(41, 41, 20, 20)
p.end()
return QIcon(pm)
class SettingsDialog(QDialog):
def __init__(self, cfg: config.Config, parent=None):
super().__init__(parent)
self.setWindowTitle("NetClip – Einstellungen")
self.setWindowIcon(app_icon())
self.cfg = dataclasses.replace(cfg)
form = QFormLayout()
self.server = QLineEdit(cfg.server, placeholderText="https://clip.example.de")
form.addRow("Server-URL:", self.server)
self.token = QLineEdit(cfg.token, echoMode=QLineEdit.EchoMode.Password)
gen = QPushButton("Erzeugen")
gen.clicked.connect(self._generate)
show = QPushButton("Anzeigen", checkable=True)
show.toggled.connect(lambda on: self.token.setEchoMode(
QLineEdit.EchoMode.Normal if on else QLineEdit.EchoMode.Password))
row = QHBoxLayout()
row.addWidget(self.token, 1)
row.addWidget(show)
row.addWidget(gen)
form.addRow("Token:", row)
form.addRow("", QLabel("<small>Alle Geräte mit demselben Token teilen sich die Zwischenablage.<br>"
"Der Token verlässt das Gerät nie, er dient auch als Schlüssel.</small>"))
self.server_key = QLineEdit(cfg.server_key, echoMode=QLineEdit.EchoMode.Password,
placeholderText="nur falls SERVER_KEY gesetzt ist")
form.addRow("Server-Schlüssel:", self.server_key)
self.verify = QCheckBox("TLS-Zertifikat prüfen (aus = selbstsigniert ohne Prüfung)", checked=cfg.verify_tls)
form.addRow("", self.verify)
self.ca_file = QLineEdit(cfg.ca_file, placeholderText="optional: fullchain.pem des Servers")
browse = QPushButton("…")
browse.clicked.connect(self._browse_ca)
row = QHBoxLayout()
row.addWidget(self.ca_file, 1)
row.addWidget(browse)
form.addRow("Zertifikat:", row)
self.device_name = QLineEdit(cfg.device_name)
form.addRow("Gerätename:", self.device_name)
self.send = QCheckBox("Eigene Zwischenablage senden", checked=cfg.send_enabled)
self.receive = QCheckBox("Empfangenes automatisch in die Zwischenablage legen", checked=cfg.receive_enabled)
self.send_text = QCheckBox("Text", checked=cfg.send_text)
self.send_images = QCheckBox("Bilder", checked=cfg.send_images)
self.send_files = QCheckBox("Dateien und Ordner", checked=cfg.send_files)
types = QHBoxLayout()
for w in (self.send_text, self.send_images, self.send_files):
types.addWidget(w)
types.addStretch()
form.addRow("Synchronisieren:", self.send)
form.addRow("", types)
form.addRow("", self.receive)
self.max_send = self._spin(cfg.max_send_mb, 1, 100000, " MB")
form.addRow("Max. Größe senden:", self.max_send)
self.max_recv = self._spin(cfg.max_auto_receive_mb, 0, 100000, " MB")
form.addRow("Automatisch laden bis:", self.max_recv)
self.keep = self._spin(cfg.keep_received_days, 1, 365, " Tage")
form.addRow("Empfangene Dateien behalten:", self.keep)
self.notify = QCheckBox("Benachrichtigungen anzeigen", checked=cfg.notifications)
form.addRow("", self.notify)
self.autostart = QCheckBox("Beim Anmelden starten", checked=cfg.autostart)
form.addRow("", self.autostart)
share = QHBoxLayout()
for text, slot in (("QR-Code für Android …", self._show_qr), ("Exportieren …", self._export),
("Importieren …", self._import)):
b = QPushButton(text)
b.clicked.connect(slot)
share.addWidget(b)
share.addStretch()
form.addRow("Verbindung teilen:", share)
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Save | QDialogButtonBox.StandardButton.Cancel)
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
layout = QVBoxLayout(self)
layout.addLayout(form)
layout.addWidget(buttons)
self.resize(560, 0)
@staticmethod
def _spin(value, lo, hi, suffix):
s = QSpinBox(minimum=lo, maximum=hi, suffix=suffix)
s.setValue(value)
return s
def _generate(self):
if self.token.text() and QMessageBox.question(
self, "Neuer Token", "Neuen Token erzeugen? Andere Geräte müssen dann auch den neuen Token "
"bekommen, der alte Verlauf ist nicht mehr sichtbar.") != QMessageBox.StandardButton.Yes:
return
self.token.setText(secrets.token_urlsafe(24))
self.token.setEchoMode(QLineEdit.EchoMode.Normal)
def _browse_ca(self):
path, _ = QFileDialog.getOpenFileName(self, "Server-Zertifikat", "", "Zertifikate (*.pem *.crt *.cer);;Alle (*)")
if path:
self.ca_file.setText(path)
def _uri(self) -> str | None:
c = self.result_config()
if not c.configured:
QMessageBox.information(self, "NetClip", "Bitte zuerst Server-URL und Token eintragen.")
return None
return connection.to_uri(c.server, c.token, c.server_key, c.verify_tls)
def _show_qr(self):
uri = self._uri()
if not uri:
return
try:
img = connection.qr_image(uri)
except ImportError:
QMessageBox.warning(self, "NetClip", "Python-Paket 'qrcode' fehlt (pip install qrcode).")
return
dlg = QDialog(self)
dlg.setWindowTitle("NetClip – mit Android verbinden")
layout = QVBoxLayout(dlg)
label = QLabel()
label.setPixmap(QPixmap.fromImage(img))
label.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(label)
layout.addWidget(QLabel("In der NetClip-App auf <b>QR-Code scannen</b> tippen.<br>"
"<small>Der Code enthält den Token – nicht weitergeben oder abfotografieren lassen.</small>"))
close = QPushButton("Schließen")
close.clicked.connect(dlg.accept)
layout.addWidget(close)
dlg.exec()
def _export(self):
uri = self._uri()
if not uri:
return
path, _ = QFileDialog.getSaveFileName(self, "Verbindung exportieren", "verbindung.netclip",
"NetClip-Verbindung (*.netclip)")
if path:
with open(path, "w", encoding="utf-8") as f:
f.write(uri + "\n")
QMessageBox.information(self, "NetClip", "Exportiert. Die Datei enthält den Token im Klartext – "
"nur sicher weitergeben (z.B. USB-Stick) und danach löschen.")
def _import(self):
path, _ = QFileDialog.getOpenFileName(self, "Verbindung importieren", "",
"NetClip-Verbindung (*.netclip);;Alle (*)")
if not path:
return
try:
with open(path, encoding="utf-8") as f:
data = connection.parse(f.read())
except (OSError, ValueError) as e:
QMessageBox.warning(self, "NetClip", f"Import fehlgeschlagen: {e}")
return
self.server.setText(data["server"])
self.token.setText(data["token"])
self.server_key.setText(data.get("server_key", ""))
self.verify.setChecked(data.get("verify_tls", True))
def result_config(self) -> config.Config:
c = self.cfg
c.server = self.server.text().strip().rstrip("/")
if c.server and "://" not in c.server:
c.server = "https://" + c.server
c.token = self.token.text().strip()
c.server_key = self.server_key.text().strip()
c.verify_tls = self.verify.isChecked()
c.ca_file = self.ca_file.text().strip()
c.device_name = self.device_name.text().strip() or c.device_name
c.send_enabled = self.send.isChecked()
c.receive_enabled = self.receive.isChecked()
c.send_text = self.send_text.isChecked()
c.send_images = self.send_images.isChecked()
c.send_files = self.send_files.isChecked()
c.max_send_mb = self.max_send.value()
c.max_auto_receive_mb = self.max_recv.value()
c.keep_received_days = self.keep.value()
c.notifications = self.notify.isChecked()
c.autostart = self.autostart.isChecked()
return c
class HistoryWindow(QWidget):
def __init__(self, ctl: Controller):
super().__init__()
self.ctl = ctl
self.setWindowTitle("NetClip – Verlauf")
self.setWindowIcon(app_icon())
self.resize(560, 640)
self.search = QLineEdit(placeholderText="Suchen …", clearButtonEnabled=True)
self.search.textChanged.connect(self.refresh)
self.list = QListWidget()
self.list.setIconSize(QSize(28, 28))
self.list.setAlternatingRowColors(True)
self.list.itemActivated.connect(lambda _item: self.use_selected())
self.list.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
self.list.customContextMenuRequested.connect(self._context_menu)
self.status = QLabel()
self.progress = QProgressBar(maximum=100, visible=False, textVisible=True)
b_use = QPushButton("In Zwischenablage")
b_use.clicked.connect(self.use_selected)
b_save = QPushButton("Speichern unter …")
b_save.clicked.connect(self.save_selected)
b_del = QPushButton("Löschen")
b_del.clicked.connect(self.delete_selected)
b_clear = QPushButton("Verlauf leeren")
b_clear.clicked.connect(self.clear)
buttons = QHBoxLayout()
for b in (b_use, b_save, b_del):
buttons.addWidget(b)
buttons.addStretch()
buttons.addWidget(b_clear)
layout = QVBoxLayout(self)
layout.addWidget(self.search)
layout.addWidget(self.list, 1)
layout.addLayout(buttons)
layout.addWidget(self.progress)
layout.addWidget(self.status)
QShortcut(QKeySequence(Qt.Key.Key_Delete), self.list, self.delete_selected)
QShortcut(QKeySequence(Qt.Key.Key_Escape), self, self.hide)
QShortcut(QKeySequence.StandardKey.Find, self, self.search.setFocus)
ctl.history_changed.connect(self.refresh)
ctl.state_changed.connect(self.update_status)
ctl.progress.connect(self.on_progress)
self.refresh()
self.update_status()
def refresh(self):
selected = self.current()
query = self.search.text().lower()
self.list.clear()
style = self.style()
for e in self.ctl.history:
if query and query not in (e.meta.get("preview", "") + " " + e.device).lower():
continue
item = QListWidgetItem(style.standardIcon(KIND_ICON.get(e.kind, KIND_ICON["text"])),
f"{e.title}\n{e.subtitle}")
item.setData(Qt.ItemDataRole.UserRole, e.id)
item.setToolTip(e.meta.get("preview", "")[:1000])
self.list.addItem(item)
if selected and e.id == selected.id:
self.list.setCurrentItem(item)
if self.list.currentRow() < 0 and self.list.count():
self.list.setCurrentRow(0)
def update_status(self):
c = self.ctl
if not c.cfg.configured:
text = "Nicht eingerichtet – bitte Einstellungen öffnen"
elif c.connected:
others = ", ".join(c.devices) if c.devices else "keine weiteren Geräte online"
text = f"Verbunden · {others}"
else:
text = "Getrennt" + (f": {c.last_error}" if c.last_error else " – verbinde …")
if c.paused:
text = "PAUSIERT · " + text
self.status.setText(text)
def on_progress(self, text: str, pct: int):
self.progress.setVisible(pct >= 0)
if pct >= 0:
self.progress.setValue(pct)
self.progress.setFormat(text)
def current(self) -> Entry | None:
item = self.list.currentItem()
if not item:
return None
clip_id = item.data(Qt.ItemDataRole.UserRole)
return next((e for e in self.ctl.history if e.id == clip_id), None)
def use_selected(self):
e = self.current()
if e:
self.ctl.fetch(e)
def save_selected(self):
e = self.current()
if e:
folder = QFileDialog.getExistingDirectory(self, "Zielordner wählen")
if folder:
self.ctl.fetch(e, save_to=folder)
def delete_selected(self):
e = self.current()
if e:
self.ctl.delete(e)
def clear(self):
if QMessageBox.question(self, "Verlauf leeren",
"Den gesamten Verlauf auf dem Server für alle Geräte löschen?") \
== QMessageBox.StandardButton.Yes:
self.ctl.clear()
def _context_menu(self, pos):
if not self.list.itemAt(pos):
return
menu = QMenu(self)
menu.addAction("In Zwischenablage", self.use_selected)
menu.addAction("Speichern unter …", self.save_selected)
menu.addSeparator()
menu.addAction("Löschen", self.delete_selected)
menu.exec(self.list.mapToGlobal(pos))
def show_raise(self):
self.show()
self.setWindowState(self.windowState() & ~Qt.WindowState.WindowMinimized)
self.raise_()
self.activateWindow()
self.search.setFocus()
self.search.selectAll()
class Tray(QSystemTrayIcon):
QUICK = 10
def __init__(self, ctl: Controller, window: HistoryWindow):
super().__init__(app_icon("off"))
self.ctl = ctl
self.window = window
self.menu = QMenu()
self.setContextMenu(self.menu)
self.activated.connect(self._activated)
ctl.history_changed.connect(self.rebuild)
ctl.state_changed.connect(self.rebuild)
ctl.notify.connect(lambda title, text: self.showMessage(title, text[:200], app_icon(), 4000))
self.rebuild()
self.show()
def rebuild(self):
c = self.ctl
status = "pause" if c.paused else ("ok" if c.connected else "off")
self.setIcon(app_icon(status))
self.setToolTip("NetClip – " + {"ok": "verbunden", "off": "getrennt", "pause": "pausiert"}[status])
m = self.menu
m.clear()
for e in c.history[:self.QUICK]:
title = e.title if len(e.title) <= 50 else e.title[:49] + "…"
prefix = {"image": "🖼 ", "files": "📁 "}.get(e.kind, "")
m.addAction(prefix + title.replace("&", "&&"), lambda e=e: c.fetch(e))
if not c.history:
m.addAction("(Verlauf leer)").setEnabled(False)
m.addSeparator()
m.addAction("Verlauf öffnen …", self.window.show_raise)
m.addAction("Aktuelle Zwischenablage senden", c.send_current)
pause = QAction("Pausieren", m, checkable=True, checked=c.paused)
pause.toggled.connect(c.set_paused)
m.addAction(pause)
m.addAction("Einstellungen …", self.open_settings)
m.addSeparator()
m.addAction("Beenden", QApplication.quit)
def _activated(self, reason):
if reason in (QSystemTrayIcon.ActivationReason.Trigger, QSystemTrayIcon.ActivationReason.DoubleClick):
if self.window.isVisible() and self.window.isActiveWindow():
self.window.hide()
else:
self.window.show_raise()
def open_settings(self):
dlg = SettingsDialog(self.ctl.cfg)
if dlg.exec():
cfg = dlg.result_config()
if cfg.autostart != self.ctl.cfg.autostart or cfg.autostart:
try:
autostart.set_enabled(cfg.autostart)
except OSError as e:
QMessageBox.warning(None, "Autostart", str(e))
self.ctl.apply_config(cfg)
+33
View File
@@ -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)."
+9
View File
@@ -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
@@ -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;
+6
View File
@@ -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"
+48
View File
@@ -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"
+75
View File
@@ -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/<id>/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://<host>: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
}
```
+48
View File
@@ -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: /'
+5
View File
@@ -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
+42
View File
@@ -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;
}
}
+12
View File
@@ -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"]
+9
View File
@@ -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 "$@"
+307
View File
@@ -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()
+1
View File
@@ -0,0 +1 @@
aiohttp==3.10.11
+50
View File
@@ -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]))