first commit
This commit is contained in:
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
+251
@@ -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" "$@"
|
||||
Vendored
+94
@@ -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
|
||||
@@ -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'
|
||||
@@ -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()
|
||||
@@ -0,0 +1,3 @@
|
||||
from netclip.__main__ import main
|
||||
|
||||
main()
|
||||
@@ -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"})
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
Executable
+33
@@ -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)."
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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
|
||||
}
|
||||
```
|
||||
@@ -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: /'
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
@@ -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 "$@"
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
aiohttp==3.10.11
|
||||
@@ -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]))
|
||||
Reference in New Issue
Block a user