feat(desktop, network): Chat-Funktion hinzugefügt und P2P-Sync verbessert

Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
This commit is contained in:
2026-05-11 13:57:49 +02:00
parent 1a4753cd73
commit e389fe9bce
17 changed files with 514 additions and 426 deletions
+12 -13
View File
@@ -1,25 +1,20 @@
# =============================================================================
# Conveyor Configuration for Meldestelle Desktop App
# =============================================================================
include required("/stdlib/jdk/21/openjdk.conf")
include required("/stdlib/jdk/21/amazon.conf")
include required("https://raw.githubusercontent.com/hydraulic-software/conveyor/master/configs/jvm/extract-native-libraries.conf")
app {
display-name = "Meldestelle"
rdns-name = "at.mocode.meldestelle"
vendor = "mo-code.at"
contact-email = "support@mo-code.at"
version = "1.0.0"
description = "ÖTO-konforme Turnier-Meldestelle Desktop App"
version = "1.0.1"
description = "ÖTO-konforme Turnier-Meldestelle Profi Desktop App"
# Ziel-Plattformen: Windows und Linux (AMD/Intel 64-bit)
# Ziel-Plattformen: Windows und Linux
machines = [ windows.amd64, linux.amd64.glibc ]
site.base-url = "localhost"
# Wir geben nur den Ordner an, Conveyor findet die icon.png darin automatisch
icons = "frontend/shells/meldestelle-desktop/src/jvmMain/resources"
# Icons werden im Ordner gesucht
icons = "frontend/shells/meldestelle-desktop/src/jvmMain/resources/icon.png"
jvm {
gui {
@@ -27,8 +22,8 @@ app {
}
jvm-options = [
"-Xms128m",
"-Xmx512m",
"-Xms256m",
"-Xmx1024m",
"-Dfile.encoding=UTF-8",
"--enable-native-access=ALL-UNNAMED"
]
@@ -42,6 +37,10 @@ app {
menu-group = "Meldestelle"
desktop-shortcut = true
}
linux {
debian.control.depends = "libasound2, libgl1-mesa-glx, libx11-6"
}
}
conveyor.compatibility-level = 22
+60 -73
View File
@@ -1,98 +1,85 @@
# 📦 Guide: Desktop App Packaging (Linux, Windows, macOS)
# 📦 Guide: Desktop App Packaging (Conveyor & Gradle)
Dieses Dokument beschreibt, wie die Meldestelle Desktop App für verschiedene Betriebssysteme paketiert wird. Wir nutzen einen hybriden Ansatz aus **Gradle (Compose-Desktop)** für lokale Linux-Builds und **Conveyor** für das Cross-Packaging (Windows/macOS) von Linux aus.
Dieses Dokument beschreibt den professionellen Packaging-Prozess für die Meldestelle Desktop App. Wir nutzen **Conveyor** als primäres Werkzeug für das Cross-Platform Packaging (Windows, Linux, macOS), da es stabile Installer inklusive signierter Updates und gebündelter JREs erzeugt.
---
## 1. Voraussetzungen
## 1. Strategie: Conveyor vs. Gradle
### Linux (Entwicklungsrechner / Fedora)
Um native Pakete bauen zu können, müssen folgende Werkzeuge auf dem System vorhanden sein:
| Feature | Conveyor (Empfohlen) | Gradle (Compose Plugin) |
| :--- | :--- | :--- |
| **Zielgruppe** | Endanwender (Produktion) | Entwickler (Lokaler Test) |
| **Plattformen** | Windows (.msix), Linux (.deb), macOS | Nur Host-OS (Linux auf Linux) |
| **Updates** | Automatisch integriert | Manuell |
| **JRE** | Amazon Corretto (isoliert) | System JRE oder Toolchain |
```bash
# Für RPM-Pakete (Fedora)
sudo dnf install rpm-build
---
# Für DEB-Pakete (Ubuntu/Debian)
sudo apt install dpkg-dev fakeroot
```
## 2. Cross-Packaging mit Conveyor
### Conveyor (Cross-Packaging Tool)
Conveyor wird benötigt, um von Linux aus Windows-Installer (.msi) oder macOS-Pakete zu erzeugen.
Conveyor ist so konfiguriert, dass es von Linux aus Pakete für alle Zielsysteme schnüren kann.
**Installation auf Fedora/Linux:**
Da automatisierte Skripte manchmal unzuverlässig sind, hier der direkte Weg über das Binär-Paket:
1. **Tarball herunterladen:**
Besuchen Sie [https://downloads.hydraulic.dev/conveyor/download.html](https://downloads.hydraulic.dev/conveyor/download.html) und laden Sie die neueste `linux-amd64.tar.gz` Datei herunter.
2. **Manuelle Installation:**
### Voraussetzungen
1. **JAR-Dateien:** Die App muss kompiliert sein:
```bash
# Entpacken
tar -xvf hydraulic-conveyor-*-linux-amd64.tar.gz
# In den Pfad verschieben
sudo mv conveyor /usr/local/bin/
./gradlew :frontend:shells:meldestelle-desktop:jvmJar
```
2. **Icons:** Das System sucht nach `icon.png` in `frontend/shells/meldestelle-desktop/src/jvmMain/resources/`.
3. **Verifizieren:**
```bash
conveyor --version
```
### Pakete bauen
Führen Sie Conveyor im Projekt-Root aus:
---
## 2. Lokale Linux-Builds (Gradle)
Die schnellste Methode, um während der Entwicklung ein installierbares Paket für das eigene System zu erstellen.
### RPM-Paket (Fedora)
```bash
./gradlew :frontend:shells:meldestelle-desktop:packageRpm
```
*Ausgabe: `frontend/shells:meldestelle-desktop/build/compose/binaries/main/rpm/`*
### DEB-Paket (Ubuntu/Debian)
```bash
./gradlew :frontend:shells:meldestelle-desktop:packageDeb
```
*Ausgabe: `frontend/shells:meldestelle-desktop/build/compose/binaries/main/deb/`*
### Portable Version (Ohne Installation)
```bash
./gradlew :frontend:shells:meldestelle-desktop:createDistributable
```
*Ausgabe: `frontend/shells:meldestelle-desktop/build/compose/binaries/main/app/`*
---
## 3. Cross-Packaging mit Conveyor
Conveyor nutzt die kompilierte JAR-Datei und schnürt daraus Pakete für alle Zielplattformen.
### Schritt 1: JAR erstellen
```bash
./gradlew :frontend:shells:meldestelle-desktop:jvmJar
```
### Schritt 2: Pakete bauen
```bash
# Erstellt den Windows-Installer und die HTML-Downloadseite
# Komplette Release-Site (Windows & Linux)
conveyor make site
# Nur ein spezifisches Paket (schneller für Tests)
conveyor make debian-package # Linux .deb
conveyor make windows-msix # Windows .msix
```
### Schritt 3: Ergebnisse
Die fertigen Installer (z.B. `.msi` für Windows) befinden sich im neu erstellten Ordner `output/`.
Die Ergebnisse liegen im Ordner `output/`.
---
## 4. Problembehandlung & Optimierung
## 3. Konfiguration (`conveyor.conf`)
### Native Access Warnungen
Die App benötigt Zugriff auf native Bibliotheken (Netty/SQLite). Der notwendige Parameter `--enable-native-access=ALL-UNNAMED` ist bereits fest hinterlegt.
Wichtige Parameter der aktuellen Konfiguration (v1.0.1):
* **JDK:** Nutzt `Amazon Corretto 21` für maximale Cross-Platform Stabilität.
* **Heap-Size:** Erhöht auf `-Xmx1024m`, um auch große Stammdaten-Importe zu bewältigen.
* **Linux-Deps:** Automatische Installation von `libasound2`, `libgl1-mesa-glx` und `libx11-6`.
* **Native Access:** `--enable-native-access=ALL-UNNAMED` ist für Netty/SQLite aktiviert.
### Firewall-Konfiguration
Für Netzwerk-Tests (Discovery/Chat) müssen die Ports 8090, 8080 und 5353 (UDP) geöffnet sein.
Nutzen Sie dafür das bereitgestellte Skript:
---
## 4. Netzwerk & Sicherheit (WICHTIG)
Damit die P2P-Funktionen (Chat, Discovery, Sync) nach der Installation funktionieren, müssen folgende Ports auf dem Host-System offen sein:
| Port | Protokoll | Funktion |
| :--- | :--- | :--- |
| **8080** | TCP | P2P Sync & Datenabgleich |
| **8090** | TCP | Veranstaltungs-Chat (WebSocket) |
| **5353** | UDP | mDNS Discovery (Geräte finden) |
### Firewall-Einrichtung (Linux)
Nutzen Sie das optimierte Setup-Script:
```bash
sudo ./setup-firewall-linux.sh
```
### Windows-Besonderheit
Beim ersten Start der `.msix` App wird Windows fragen, ob der Netzwerkzugriff erlaubt werden soll. **Wichtig:** Sowohl "Private" als auch "Öffentliche" Netzwerke anhaken, falls auf Turnieren oft Gast-WLANs oder Hotspots genutzt werden.
---
## 5. Troubleshooting
### Problem: "No main class specified"
**Lösung:** Stellen Sie sicher, dass in der `Main.kt` eine saubere Top-Level `fun main()` existiert und in der `conveyor.conf` auf `at.mocode.frontend.shell.desktop.MainKt` verwiesen wird.
### Problem: SQLite / Native Libs laden nicht
**Lösung:** Prüfen Sie, ob `extract-native-libraries.conf` in der `conveyor.conf` inkludiert ist.
### Problem: JmDNS findet keine Teilnehmer
**Lösung:** Prüfen Sie die Ports via `ss -tulpn`. Auf Linux blockieren oft Docker-Interfaces (`br-*`) den Broadcast. Die App filtert diese nun automatisch, aber ein aktives `setup-firewall-linux.sh` ist zwingend erforderlich.
+14 -16
View File
@@ -2,27 +2,25 @@
**Status:** 🏗️ In Arbeit
**SCS:** Desktop App / Infrastructure
**Branch:** `feature/desktop-network-chat` (neuer Branch, erstellt ausgehend von `feature/turnier-anlage-wizard`)
**Branch:** `feature/desktop-network-chat`
## 🎯 Aktuelles Ziel
1. **Netzwerk-Kommunikation (Chat POC):** Implementierung einer simplen Chat-Funktion für die Desktop-App, die im lokalen Netzwerk funktioniert (Verbindungstest).
2. **Multi-Node Architektur:** Host-Client-Modell (1..n Hosts, 1..n Clients) vorbereiten. Hosts und Clients müssen in einem lokalen Netzwerk (LAN/WLAN) plattformunabhängig (Windows, Mac, Linux) stabil kommunizieren können.
3. **Conveyor Build (Pausiert):** Lauffähiger Build der Desktop-App via Conveyor für Windows (.msi/.exe) und Linux. Bereitstellung über Web-App. Wird nach dem Netzwerk-Proof-of-Concept in Angriff genommen.
1. **Stabile Netzwerk-Kommunikation:** Implementierung einer robusten P2P-Kommunikation mit Reconnection-Logik und Heartbeats.
2. **Multi-Node Architektur:** Host-Client-Modell stabilisiert.
3. **Professional Packaging:** Vorbereitung für echte Installer (.msi, .deb) via Conveyor.
## 🛠️ Letzte Änderungen
- Fokus auf Netzwerk- & Offline-Fähigkeiten gelegt. Turnier-Anlage-Wizard pausiert.
- Neuer Branch `feature/desktop-network-chat` für die anstehenden Arbeiten.
- **Hardening P2P:** `JvmP2pSyncService` komplett refactored. Jetzt mit automatischem Reconnect (3s Intervall) und Ktor Heartbeats (Ping/Pong alle 5s).
- **Conveyor:** Konfiguration (`conveyor.conf`) für v1.0.1 vorbereitet (größere JVM Heaps, Linux Abhängigkeiten).
- **Firewall Script:** Verbessert und um Kommentare/mDNS erweitert.
## 📍 Fokus-Bereiche
- Lokale Netzwerk-Discovery (z.B. Ktor, UDP Broadcast, mDNS).
- P2P oder Client-Server Chat-Kommunikation im lokalen Netzwerk für den Verbindungs-Check.
- KMP Desktop-Modul.
## 🚧 Offene Punkte / Blocker
- [ ] Konzept für Host/Client-Discovery im lokalen Netz umsetzen.
- [ ] Implementierung eines lokalen Chat-Moduls in der Desktop-App (Linux/Desktop-Test).
- [ ] Erfolgreicher Conveyor Build für Windows & Linux (Später).
- [x] Robuste Reconnection-Logik im P2P Service.
- [x] Heartbeats zur Erkennung toter Verbindungen.
- [ ] In-App Feedback bei Firewall-Blockaden.
- [ ] Multi-Node Test mit > 2 Teilnehmern.
## 🔄 Nächste Schritte
- [ ] Architektur-Entscheidung (ADR) für lokale Netzwerk-Discovery und Kommunikation treffen (Ktor Sockets, UDP, etc.).
- [ ] Erste Implementierung des Discovery-Mechanismus.
- [ ] Multi-Node Stabilitätstest (Simulierte Netzwerk-Drops).
- [ ] Integration von Firewall-Checks im Connectivity-Wizard.
- [ ] Erster Test-Build via Conveyor auf lokaler Maschine.
@@ -9,79 +9,79 @@ import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
class FileBackupService(private val deviceName: String) : BackupService {
private val json = Json { prettyPrint = true }
private val json = Json { prettyPrint = true }
override fun exportDelta(data: String, targetPath: String, sharedKey: String): Result<String> {
return try {
val timestamp = System.currentTimeMillis()
val checksum = calculateChecksum(data)
val payload = BackupPayload(timestamp, deviceName, data, checksum)
val jsonContent = json.encodeToString(payload)
override fun exportDelta(data: String, targetPath: String, sharedKey: String): Result<String> {
return try {
val timestamp = System.currentTimeMillis()
val checksum = calculateChecksum(data)
val payload = BackupPayload(timestamp, deviceName, data, checksum)
val jsonContent = json.encodeToString(payload)
val encryptedData = encrypt(jsonContent, sharedKey)
val encryptedData = encrypt(jsonContent, sharedKey)
val dir = File(targetPath)
if (!dir.exists()) dir.mkdirs()
val dir = File(targetPath)
if (!dir.exists()) dir.mkdirs()
val fileName = "delta_${timestamp}_${deviceName}.msbackup"
val file = File(dir, fileName)
file.writeText(encryptedData)
val fileName = "delta_${timestamp}_${deviceName}.msbackup"
val file = File(dir, fileName)
file.writeText(encryptedData)
println("[Plan-USB] Export erfolgreich: ${file.absolutePath}")
Result.success(file.absoluteName)
} catch (e: Exception) {
println("[Plan-USB] Export fehlgeschlagen: ${e.message}")
Result.failure(e)
}
println("[Plan-USB] Export erfolgreich: ${file.absolutePath}")
Result.success(file.absoluteName)
} catch (e: Exception) {
println("[Plan-USB] Export fehlgeschlagen: ${e.message}")
Result.failure(e)
}
}
override fun importDelta(filePath: String, sharedKey: String): Result<String> {
return try {
val file = File(filePath)
val encryptedData = file.readText()
val jsonContent = decrypt(encryptedData, sharedKey)
val payload = json.decodeFromString<BackupPayload>(jsonContent)
override fun importDelta(filePath: String, sharedKey: String): Result<String> {
return try {
val file = File(filePath)
val encryptedData = file.readText()
val jsonContent = decrypt(encryptedData, sharedKey)
val payload = json.decodeFromString<BackupPayload>(jsonContent)
if (calculateChecksum(payload.data) != payload.checksum) {
throw Exception("Checksummenfehler: Daten wurden möglicherweise manipuliert.")
}
if (calculateChecksum(payload.data) != payload.checksum) {
throw Exception("Checksummenfehler: Daten wurden möglicherweise manipuliert.")
}
println("[Plan-USB] Import erfolgreich von ${payload.deviceName}")
Result.success(payload.data)
} catch (e: Exception) {
println("[Plan-USB] Import fehlgeschlagen: ${e.message}")
Result.failure(e)
}
println("[Plan-USB] Import erfolgreich von ${payload.deviceName}")
Result.success(payload.data)
} catch (e: Exception) {
println("[Plan-USB] Import fehlgeschlagen: ${e.message}")
Result.failure(e)
}
}
private fun calculateChecksum(data: String): String {
val bytes = MessageDigest.getInstance("SHA-256").digest(data.toByteArray())
return bytes.joinToString("") { "%02x".format(it) }
}
private fun calculateChecksum(data: String): String {
val bytes = MessageDigest.getInstance("SHA-256").digest(data.toByteArray())
return bytes.joinToString("") { "%02x".format(it) }
}
private fun encrypt(data: String, key: String): String {
val secretKey = generateKey(key)
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
val iv = IvParameterSpec(ByteArray(16)) // Vereinfacht für PoC
cipher.init(Cipher.ENCRYPT_MODE, secretKey, iv)
val encrypted = cipher.doFinal(data.toByteArray())
return Base64.getEncoder().encodeToString(encrypted)
}
private fun encrypt(data: String, key: String): String {
val secretKey = generateKey(key)
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
val iv = IvParameterSpec(ByteArray(16)) // Vereinfacht für PoC
cipher.init(Cipher.ENCRYPT_MODE, secretKey, iv)
val encrypted = cipher.doFinal(data.toByteArray())
return Base64.getEncoder().encodeToString(encrypted)
}
private fun decrypt(encrypted: String, key: String): String {
val secretKey = generateKey(key)
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
val iv = IvParameterSpec(ByteArray(16))
cipher.init(Cipher.DECRYPT_MODE, secretKey, iv)
val decrypted = cipher.doFinal(Base64.getDecoder().decode(encrypted))
return String(decrypted)
}
private fun decrypt(encrypted: String, key: String): String {
val secretKey = generateKey(key)
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
val iv = IvParameterSpec(ByteArray(16))
cipher.init(Cipher.DECRYPT_MODE, secretKey, iv)
val decrypted = cipher.doFinal(Base64.getDecoder().decode(encrypted))
return String(decrypted)
}
private fun generateKey(key: String): SecretKeySpec {
val sha = MessageDigest.getInstance("SHA-256")
val keyBytes = sha.digest(key.toByteArray()).copyOf(16) // AES-128 für Kompatibilität
return SecretKeySpec(keyBytes, "AES")
}
private fun generateKey(key: String): SecretKeySpec {
val sha = MessageDigest.getInstance("SHA-256")
val keyBytes = sha.digest(key.toByteArray()).copyOf(16) // AES-128 für Kompatibilität
return SecretKeySpec(keyBytes, "AES")
}
}
private val File.absoluteName: String get() = this.name
@@ -9,6 +9,6 @@ import org.koin.dsl.module
* JVM-spezifische Implementierung des DiscoveryModules.
*/
actual val discoveryModule: Module = module {
single<NetworkDiscoveryService> { JmDnsDiscoveryService() }
single<BackupService> { (deviceName: String) -> FileBackupService(deviceName) }
single<NetworkDiscoveryService> { JmDnsDiscoveryService() }
single<BackupService> { (deviceName: String) -> FileBackupService(deviceName) }
}
@@ -22,8 +22,10 @@ class JmDnsDiscoveryService : NetworkDiscoveryService {
private val registeredSet = ConcurrentHashMap.newKeySet<String>() // key: "${name}@${addr.hostAddress}:$port"
// Debounce/Guards
@Volatile private var lastStartRequestedAt: Long = 0L
@Volatile private var lastStartIp: String? = null
@Volatile
private var lastStartRequestedAt: Long = 0L
@Volatile
private var lastStartIp: String? = null
private val _discoveredServices = MutableStateFlow<List<DiscoveredService>>(emptyList())
override val discoveredServices: StateFlow<List<DiscoveredService>> = _discoveredServices.asStateFlow()
@@ -149,7 +151,10 @@ class JmDnsDiscoveryService : NetworkDiscoveryService {
val name = iface.name.lowercase()
// Filtere Docker/Bridged/VETH/VM-Schnittstellen heraus
if (iface.isLoopback || !iface.isUp || iface.isVirtual) continue
if (name.startsWith("br-") || name.startsWith("docker") || name.startsWith("veth") || name.contains("vmnet") || name.contains("virbr")) continue
if (name.startsWith("br-") || name.startsWith("docker") || name.startsWith("veth") || name.contains("vmnet") || name.contains(
"virbr"
)
) continue
val inetAddresses = iface.inetAddresses
while (inetAddresses.hasMoreElements()) {
@@ -172,7 +177,8 @@ class JmDnsDiscoveryService : NetworkDiscoveryService {
// Bevorzuge private LAN IPv4 (192.168.x.x, 10.x.x.x, 172.16-31.x.x)
fun isPrivateIPv4(a: InetAddress): Boolean {
val h = a.hostAddress
return h.startsWith("192.168.") || h.startsWith("10.") || (h.startsWith("172.") && h.split('.').getOrNull(1)?.toIntOrNull() in 16..31)
return h.startsWith("192.168.") || h.startsWith("10.") || (h.startsWith("172.") && h.split('.').getOrNull(1)
?.toIntOrNull() in 16..31)
}
return addresses.sortedWith(compareByDescending<InetAddress> { isPrivateIPv4(it) }
.thenBy { it.hostAddress })
@@ -2,145 +2,181 @@ package at.mocode.frontend.core.network.sync
import io.ktor.client.*
import io.ktor.client.plugins.websocket.*
import io.ktor.client.plugins.websocket.WebSockets
import io.ktor.server.application.*
import io.ktor.server.engine.*
import io.ktor.server.netty.*
import io.ktor.server.routing.*
import io.ktor.server.websocket.*
import io.ktor.websocket.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import java.util.*
import java.util.concurrent.ConcurrentHashMap
import kotlin.time.Duration.Companion.milliseconds
/**
* JVM-spezifische Implementierung des P2pSyncService mit Fokus auf Stabilität.
* Beinhaltet Reconnection-Logik, Heartbeats und robustes Session-Management.
*/
class JvmP2pSyncService : P2pSyncService {
companion object {
// Prozessweiter, portbasierter Guard gegen Mehrfachstart
private val startedPorts: MutableSet<Int> = ConcurrentHashMap.newKeySet()
companion object {
private val startedPorts: MutableSet<Int> = ConcurrentHashMap.newKeySet()
private const val RECONNECT_DELAY_MS = 3000L
private const val PING_INTERVAL_MS = 5000L
private const val PING_TIMEOUT_MS = 10000L
}
private var server: EmbeddedServer<*, *>? = null
private var currentPort: Int? = null
private val client = HttpClient {
install(WebSockets) {
pingInterval = PING_INTERVAL_MS.milliseconds
}
private var server: EmbeddedServer<*, *>? = null
private var currentPort: Int? = null
private val client = HttpClient {
install(io.ktor.client.plugins.websocket.WebSockets)
}
private val _incomingEvents = MutableSharedFlow<SyncEvent>(extraBufferCapacity = 64)
override val incomingEvents: Flow<SyncEvent> = _incomingEvents.asSharedFlow()
private val activeSessions = Collections.synchronizedSet(LinkedHashSet<DefaultWebSocketSession>())
private val _connectedPeers = MutableStateFlow<List<String>>(emptyList())
override val connectedPeers: Flow<List<String>> = _connectedPeers.asStateFlow()
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val connectionJobs = ConcurrentHashMap<String, Job>()
override fun startServer(port: Int) {
if (server != null) {
println("[P2P Server] Bereits aktiv auf Port ${currentPort ?: port}")
return
}
private val _incomingEvents = MutableSharedFlow<SyncEvent>()
override val incomingEvents: Flow<SyncEvent> = _incomingEvents.asSharedFlow()
private val activeSessions = Collections.synchronizedSet(LinkedHashSet<DefaultWebSocketSession>())
private val _connectedPeers = MutableStateFlow<List<String>>(emptyList())
override val connectedPeers: Flow<List<String>> = _connectedPeers.asStateFlow()
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
override fun startServer(port: Int) {
// Instanz-Guard (gleiche Instanz)
if (server != null) {
println("[P2P Server] Bereits gestartet (Instanz) auf Port ${currentPort ?: port} idempotent")
return
}
// Prozessweiter, portbasierter Guard
println("[P2P Server] Versuche Port $port zu reservieren...")
if (!startedPorts.add(port)) {
println("[P2P Server] Bereits gestartet (Prozess) auf Port $port idempotent, kein neuer Bind")
return
}
try {
server = embeddedServer(Netty, port = port) {
install(io.ktor.server.websocket.WebSockets)
routing {
webSocket("/sync") {
println("[P2P Server] Neuer Peer verbunden")
activeSessions.add(this)
updatePeers()
try {
for (frame in incoming) {
if (frame is Frame.Text) {
val text = frame.readText()
try {
val event = Json.decodeFromString<SyncEvent>(text)
_incomingEvents.emit(event)
} catch (e: Exception) {
println("[P2P Server] Fehler beim Dekodieren: ${e.message}")
}
}
}
} finally {
activeSessions.remove(this)
updatePeers()
println("[P2P Server] Peer getrennt")
}
}
}
}.start(wait = false)
currentPort = port
} catch (e: Exception) {
// Start fehlgeschlagen -> Port-Lock wieder freigeben
startedPorts.remove(port)
server = null
currentPort = null
println("[P2P Server] Start auf Port $port fehlgeschlagen: ${e.message}")
throw e
}
if (!startedPorts.add(port)) {
println("[P2P Server] Port $port wird bereits von einer anderen Instanz genutzt.")
return
}
override fun stopServer() {
try {
server?.stop(1000, 2000)
} finally {
server = null
currentPort?.let { startedPorts.remove(it) }
currentPort = null
try {
server = embeddedServer(Netty, port = port, host = "0.0.0.0") {
install(io.ktor.server.websocket.WebSockets) {
pingPeriod = PING_INTERVAL_MS.milliseconds
timeout = PING_TIMEOUT_MS.milliseconds
}
}
override suspend fun connectToPeer(host: String, port: Int) {
scope.launch {
routing {
webSocket("/sync") {
val remote = call.request.local.remoteAddress
println("[P2P Server] Neuer Peer verbunden: $remote")
activeSessions.add(this)
updatePeers()
try {
client.webSocket(host = host, port = port, path = "/sync") {
println("[P2P Client] Verbunden mit $host:$port")
activeSessions.add(this)
updatePeers()
try {
for (frame in incoming) {
if (frame is Frame.Text) {
val text = frame.readText()
val event = Json.decodeFromString<SyncEvent>(text)
_incomingEvents.emit(event)
}
}
} finally {
activeSessions.remove(this)
updatePeers()
println("[P2P Client] Verbindung zu $host:$port beendet")
}
for (frame in incoming) {
if (frame is Frame.Text) {
val text = frame.readText()
try {
val event = Json.decodeFromString<SyncEvent>(text)
_incomingEvents.emit(event)
} catch (ex: Exception) {
println("[P2P Server] Fehler beim Dekodieren von $remote: ${ex.message}")
}
}
} catch (e: Exception) {
println("[P2P Client] Fehler bei Verbindung zu $host:$port: ${e.message}")
}
} catch (ex: Exception) {
println("[P2P Server] Verbindung zu $remote unterbrochen: ${ex.message}")
} finally {
activeSessions.remove(this)
updatePeers()
println("[P2P Server] Peer $remote getrennt")
}
}
}
}.start(wait = false)
currentPort = port
println("[P2P Server] Erfolgreich gestartet auf Port $port")
} catch (ex: Exception) {
startedPorts.remove(port)
server = null
currentPort = null
println("[P2P Server] Fehler beim Starten des Servers auf Port $port: ${ex.message}")
throw ex
}
}
override suspend fun broadcastEvent(event: SyncEvent) {
val text = Json.encodeToString(event)
activeSessions.toList().forEach { session ->
override fun stopServer() {
connectionJobs.values.forEach { it.cancel() }
connectionJobs.clear()
try {
server?.stop(1000, 2000)
} finally {
server = null
currentPort?.let { startedPorts.remove(it) }
currentPort = null
println("[P2P Server] Server gestoppt.")
}
}
override suspend fun connectToPeer(host: String, port: Int) {
val peerKey = "$host:$port"
connectionJobs[peerKey]?.cancel()
val job = scope.launch {
while (isActive) {
try {
println("[P2P Client] Verbindungsversuch zu $peerKey...")
client.webSocket(host = host, port = port, path = "/sync") {
println("[P2P Client] Verbunden mit $peerKey")
activeSessions.add(this)
updatePeers()
try {
session.send(Frame.Text(text))
} catch (e: Exception) {
println("[P2P] Fehler beim Senden an Session: ${e.message}")
for (frame in incoming) {
if (frame is Frame.Text) {
val text = frame.readText()
val event = Json.decodeFromString<SyncEvent>(text)
_incomingEvents.emit(event)
}
}
} catch (ex: Exception) {
println("[P2P Client] Verbindung zu $peerKey abgebrochen: ${ex.message}")
} finally {
activeSessions.remove(this)
updatePeers()
println("[P2P Client] Session mit $peerKey beendet.")
}
}
} catch (ex: Exception) {
println("[P2P Client] Konnte keine Verbindung zu $peerKey herstellen: ${ex.message}")
}
}
private fun updatePeers() {
// Da wir keine einfachen IPs in den Sessions haben ohne tieferes Casting,
// nutzen wir hier erst mal einen Platzhalter oder zählen nur.
_connectedPeers.value = activeSessions.map { "Peer-${it.hashCode()}" }
if (isActive) {
println("[P2P Client] Erneuter Versuch für $peerKey in ${RECONNECT_DELAY_MS}ms...")
delay(RECONNECT_DELAY_MS.milliseconds)
}
}
}
connectionJobs[peerKey] = job
}
override suspend fun broadcastEvent(event: SyncEvent) {
val text = Json.encodeToString(event)
val sessions = activeSessions.toList()
sessions.forEach { session ->
try {
if (session.isActive) {
session.send(Frame.Text(text))
}
} catch (_: Exception) {
// Session wird durch Heartbeat/Loop automatisch bereinigt
}
}
}
private fun updatePeers() {
_connectedPeers.value = activeSessions.map { session ->
when (session) {
is DefaultWebSocketServerSession -> session.call.request.local.remoteAddress
else -> "Outgoing-Peer"
}
}.distinct()
}
}
@@ -7,6 +7,6 @@ import org.koin.dsl.module
* JVM-spezifische Implementierung des SyncModules.
*/
actual val syncModule: Module = module {
single<P2pSyncService> { JvmP2pSyncService() }
single { SyncManager(get(), get()) }
single<P2pSyncService> { JvmP2pSyncService() }
single { SyncManager(get(), get()) }
}
@@ -5,33 +5,33 @@ import kotlin.test.Test
class JvmP2pSyncServiceTest {
@Test
fun starting_server_twice_on_same_port_should_not_fail_but_use_guard() = runTest {
val service1 = JvmP2pSyncService()
val service2 = JvmP2pSyncService()
val port = 9091
@Test
fun starting_server_twice_on_same_port_should_not_fail_but_use_guard() = runTest {
val service1 = JvmP2pSyncService()
val service2 = JvmP2pSyncService()
val port = 9091
try {
service1.startServer(port)
// Second start should just return/log and not throw an exception (idempotent)
service2.startServer(port)
} finally {
service1.stopServer()
service2.stopServer()
}
try {
service1.startServer(port)
// Second start should just return/log and not throw an exception (idempotent)
service2.startServer(port)
} finally {
service1.stopServer()
service2.stopServer()
}
}
@Test
fun stopping_server_should_release_port_lock() = runTest {
val service1 = JvmP2pSyncService()
val service2 = JvmP2pSyncService()
val port = 9092
@Test
fun stopping_server_should_release_port_lock() = runTest {
val service1 = JvmP2pSyncService()
val service2 = JvmP2pSyncService()
val port = 9092
service1.startServer(port)
service1.stopServer()
service1.startServer(port)
service1.stopServer()
// After stopping, starting again on same port (even from different instance) should work
service2.startServer(port)
service2.stopServer()
}
// After stopping, starting again on same port (even from different instance) should work
service2.startServer(port)
service2.stopServer()
}
}
@@ -1,23 +1,23 @@
package at.mocode.frontend.core.network.sync
import org.koin.core.module.Module
import org.koin.dsl.module
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import org.koin.core.module.Module
import org.koin.dsl.module
/**
* Wasm-spezifische Implementierung (vorerst No-op).
*/
actual val syncModule: Module = module {
single<P2pSyncService> { NoOpP2pSyncService() }
single { SyncManager(get(), get()) }
single<P2pSyncService> { NoOpP2pSyncService() }
single { SyncManager(get(), get()) }
}
class NoOpP2pSyncService : P2pSyncService {
override fun startServer(port: Int) {}
override fun stopServer() {}
override suspend fun connectToPeer(host: String, port: Int) {}
override suspend fun broadcastEvent(event: SyncEvent) {}
override val incomingEvents: Flow<SyncEvent> = emptyFlow()
override val connectedPeers: Flow<List<String>> = emptyFlow()
override fun startServer(port: Int) {}
override fun stopServer() {}
override suspend fun connectToPeer(host: String, port: Int) {}
override suspend fun broadcastEvent(event: SyncEvent) {}
override val incomingEvents: Flow<SyncEvent> = emptyFlow()
override val connectedPeers: Flow<List<String>> = emptyFlow()
}
@@ -26,10 +26,11 @@ import org.koin.compose.viewmodel.koinViewModel
*/
@Composable
fun DesktopApp() {
val deviceInitViewModel: at.mocode.frontend.features.device.initialization.presentation.DeviceInitializationViewModel = koinViewModel()
val deviceInitViewModel: at.mocode.frontend.features.device.initialization.presentation.DeviceInitializationViewModel =
koinViewModel()
val deviceSettings by deviceInitViewModel.uiState.collectAsState()
val isDark = when(deviceSettings.settings.appTheme) {
val isDark = when (deviceSettings.settings.appTheme) {
at.mocode.frontend.features.device.initialization.domain.model.AppThemeSetting.SYSTEM -> isSystemInDarkTheme()
at.mocode.frontend.features.device.initialization.domain.model.AppThemeSetting.LIGHT -> false
at.mocode.frontend.features.device.initialization.domain.model.AppThemeSetting.DARK -> true
@@ -27,7 +27,7 @@ private fun PreviewContent() {
Surface {
// --- REITER ---
//ReiterScreen(viewModel = ReiterViewModel())
//ReiterScreen(viewModel = ReiterViewModel())
// --- PFERDE ---
// PferdeScreen(viewModel = PferdeViewModel())
@@ -35,8 +35,6 @@ private fun PreviewContent() {
// --- VEREIN ---
// ── Hier den gewünschten Screen eintragen ──────────────────────
// VeranstalterAuswahlScreen(onVeranstalterSelected = {}, onNeuerVeranstalter = {})
// VeranstalterNeuScreen(onBack = {}, onSave = {})
@@ -8,6 +8,8 @@ import at.mocode.frontend.core.navigation.DeepLinkHandler
import at.mocode.frontend.core.navigation.NavigationPort
import at.mocode.frontend.shell.desktop.navigation.DesktopNavigationPort
import at.mocode.frontend.shell.desktop.repository.DesktopMasterdataRepository
import at.mocode.frontend.shell.desktop.screens.chat.presentation.ChatViewModel
import org.koin.core.module.dsl.viewModel
import org.koin.dsl.module
/**
@@ -35,4 +37,5 @@ val desktopModule = module {
single<CurrentUserProvider> { DesktopCurrentUserProvider(get()) }
single { DeepLinkHandler(get(), get()) }
single<MasterdataRepository> { DesktopMasterdataRepository(get()) }
viewModel { ChatViewModel(get()) }
}
@@ -1,17 +1,12 @@
package at.mocode.frontend.shell.desktop
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.WindowState
import androidx.compose.ui.window.application
import at.mocode.frontend.core.auth.di.authModule
import at.mocode.frontend.core.localdb.AppDatabase
import at.mocode.frontend.core.localdb.DatabaseProvider
import at.mocode.frontend.core.localdb.localDbModule
import at.mocode.frontend.core.network.chat.KtorWebSocketServerService
import at.mocode.frontend.core.network.discovery.NetworkDiscoveryService
import at.mocode.frontend.core.network.networkModule
import at.mocode.frontend.core.sync.di.syncModule
import at.mocode.frontend.core.network.sync.SyncManager
import at.mocode.frontend.features.billing.di.billingModule
import at.mocode.frontend.features.device.initialization.di.deviceInitializationModule
import at.mocode.frontend.features.funktionaer.di.funktionaerModule
@@ -24,76 +19,51 @@ import at.mocode.frontend.features.turnier.di.turnierFeatureModule
import at.mocode.frontend.features.veranstalter.di.veranstalterModule
import at.mocode.frontend.features.verein.di.vereinFeatureModule
import at.mocode.frontend.features.zns.import.di.znsImportModule
import at.mocode.frontend.shell.desktop.data.repository.StoreVeranstaltungRepository
import at.mocode.frontend.shell.desktop.di.desktopModule
import at.mocode.veranstaltung.feature.di.veranstaltungModule
import at.mocode.veranstaltung.feature.domain.repository.VeranstaltungRepository
import kotlinx.coroutines.runBlocking
import org.koin.core.context.GlobalContext
import org.koin.core.context.loadKoinModules
import org.koin.core.context.startKoin
import org.koin.dsl.module
fun main() = application {
try {
startKoin {
fun main() {
application {
// Koin Starten
val koinApp = startKoin {
printLogger()
modules(
networkModule,
syncModule,
authModule,
localDbModule,
pingFeatureModule,
nennungFeatureModule,
znsImportModule,
profileModule,
billingModule,
pferdeModule,
reiterModule,
funktionaerModule,
vereinFeatureModule,
veranstalterModule,
turnierFeatureModule,
veranstaltungModule,
module {
single<VeranstaltungRepository> { StoreVeranstaltungRepository() }
},
deviceInitializationModule,
desktopModule,
deviceInitializationModule,
billingModule,
funktionaerModule,
nennungFeatureModule,
pferdeModule,
pingFeatureModule,
profileModule,
reiterModule,
turnierFeatureModule,
veranstalterModule,
veranstaltungModule,
vereinFeatureModule,
znsImportModule
)
}
// Datenbank EAGER initialisieren (JVM-safe via runBlocking)
val koin = GlobalContext.get()
val dbProvider = koin.get<DatabaseProvider>()
val koin = koinApp.koin
// Datenbank initialisieren und als Singleton registrieren
val dbProvider: DatabaseProvider = koin.get()
val database = runBlocking { dbProvider.createDatabase() }
koin.loadModules(listOf(module { single { database } }))
loadKoinModules(module {
single<AppDatabase> { database }
})
// SyncManager initialisieren und starten (Default Port 8080)
val syncManager: SyncManager = koin.get()
syncManager.start(8080)
println("[DesktopApp] KOIN & DB initialisiert")
// Start POC Netzwerk-Dienste
try {
val wsServer = koin.get<KtorWebSocketServerService>()
wsServer.start()
val discovery = koin.get<NetworkDiscoveryService>()
discovery.startDiscovery()
discovery.registerService(wsServer.getPort())
} catch(e: Exception) {
println("[DesktopApp] Netzwerk-Dienste Fehler: %s".format(e.message))
Window(onCloseRequest = ::exitApplication, title = "Meldestelle Desktop") {
DesktopApp()
}
at.mocode.frontend.shell.desktop.data.Store.seed()
} catch (e: Exception) {
println("[DesktopApp] Startup-Fehler: %s".format(e.message))
}
Window(
onCloseRequest = ::exitApplication,
title = "Meldestelle",
state = WindowState(width = 1600.dp, height = 900.dp),
) {
DesktopApp()
}
}
@@ -4,6 +4,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Send
@@ -16,30 +17,24 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import at.mocode.frontend.core.designsystem.theme.AppColors
import at.mocode.frontend.core.designsystem.theme.Dimens
import java.time.LocalTime
import java.time.format.DateTimeFormatter
data class ChatMessage(
val id: String,
val sender: String,
val text: String,
val time: String,
val isFromMe: Boolean
)
import at.mocode.frontend.shell.desktop.screens.chat.presentation.ChatMessageState
import at.mocode.frontend.shell.desktop.screens.chat.presentation.ChatViewModel
import org.koin.compose.viewmodel.koinViewModel
@Composable
fun ChatScreen(
onBack: () -> Unit
onBack: () -> Unit,
viewModel: ChatViewModel = koinViewModel()
) {
var messageText by remember { mutableStateOf("") }
val messages = remember { mutableStateListOf<ChatMessage>() }
val timeFormatter = DateTimeFormatter.ofPattern("HH:mm")
val messages by viewModel.messages.collectAsState()
val peerCount by viewModel.peerCount.collectAsState()
val scrollState = rememberLazyListState()
// Mock initial messages
LaunchedEffect(Unit) {
if (messages.isEmpty()) {
messages.add(ChatMessage("1", "Richter-Turm 1", "Startliste für Bewerb 5 ist fertig?", "10:45", false))
messages.add(ChatMessage("2", "Meldestelle", "Ja, wird gerade gedruckt.", "10:46", true))
// Auto-scroll to bottom on new messages
LaunchedEffect(messages.size) {
if (messages.isNotEmpty()) {
scrollState.animateScrollToItem(messages.size - 1)
}
}
@@ -61,9 +56,9 @@ fun ChatScreen(
fontWeight = FontWeight.Bold
)
Text(
"LAN-Kanal: aktiv (3 Teilnehmer)",
"LAN-Kanal: aktiv ($peerCount Teilnehmer verbunden)",
style = MaterialTheme.typography.labelMedium,
color = AppColors.Success
color = if (peerCount > 0) AppColors.Success else MaterialTheme.colorScheme.error
)
}
}
@@ -71,11 +66,12 @@ fun ChatScreen(
// Chat Messages
LazyColumn(
state = scrollState,
modifier = Modifier.weight(1f).fillMaxWidth().padding(horizontal = Dimens.SpacingM),
contentPadding = PaddingValues(vertical = Dimens.SpacingM),
verticalArrangement = Arrangement.spacedBy(Dimens.SpacingS)
) {
items(messages) { msg ->
items(messages, key = { it.id }) { msg ->
ChatBubble(msg)
}
}
@@ -102,18 +98,11 @@ fun ChatScreen(
IconButton(
onClick = {
if (messageText.isNotBlank()) {
messages.add(
ChatMessage(
id = messages.size.toString(),
sender = "Meldestelle",
text = messageText,
time = LocalTime.now().format(timeFormatter),
isFromMe = true
)
)
viewModel.sendMessage(messageText)
messageText = ""
}
},
enabled = messageText.isNotBlank(),
colors = IconButtonDefaults.iconButtonColors(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary
@@ -128,7 +117,7 @@ fun ChatScreen(
}
@Composable
private fun ChatBubble(msg: ChatMessage) {
private fun ChatBubble(msg: ChatMessageState) {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = if (msg.isFromMe) Alignment.End else Alignment.Start
@@ -0,0 +1,88 @@
package at.mocode.frontend.shell.desktop.screens.chat.presentation
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import at.mocode.frontend.core.network.sync.ChatMessageEvent
import at.mocode.frontend.core.network.sync.SyncManager
import at.mocode.frontend.features.device.initialization.data.local.DeviceInitializationSettingsManager
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.time.LocalTime
import java.time.format.DateTimeFormatter
import java.util.*
import kotlin.time.Clock
data class ChatMessageState(
val id: String,
val sender: String,
val text: String,
val time: String,
val isFromMe: Boolean
)
class ChatViewModel(
private val syncManager: SyncManager
) : ViewModel() {
private val timeFormatter = DateTimeFormatter.ofPattern("HH:mm")
private val settings = DeviceInitializationSettingsManager.loadSettings()
private val myName = settings?.deviceName ?: "Meldestelle"
private val _messages = MutableStateFlow<List<ChatMessageState>>(emptyList())
val messages: StateFlow<List<ChatMessageState>> = _messages.asStateFlow()
private val _peerCount = MutableStateFlow(0)
val peerCount: StateFlow<Int> = _peerCount.asStateFlow()
init {
viewModelScope.launch {
syncManager.getIncomingEvents().collect { event ->
if (event is ChatMessageEvent) {
_messages.update {
it + ChatMessageState(
id = event.eventId,
sender = event.senderName,
text = event.message,
time = LocalTime.now().format(timeFormatter),
isFromMe = event.originNodeId == myName
)
}
}
}
}
viewModelScope.launch {
syncManager.getConnectedPeers().collect { peers ->
_peerCount.value = peers.size
}
}
}
fun sendMessage(text: String) {
if (text.isBlank()) return
val event = ChatMessageEvent(
eventId = UUID.randomUUID().toString(),
sequenceNumber = 0,
originNodeId = myName,
createdAt = Clock.System.now().toEpochMilliseconds(),
senderName = myName,
message = text
)
// Sofort lokal anzeigen
_messages.update {
it + ChatMessageState(
id = event.eventId,
sender = myName,
text = text,
time = LocalTime.now().format(timeFormatter),
isFromMe = true
)
}
syncManager.broadcastEvent(event)
}
}
+29 -16
View File
@@ -1,29 +1,42 @@
#!/bin/bash
echo "==========================================="
echo "Meldestelle - Netzwerk-Setup für POC"
echo "Meldestelle - Netzwerk-Optimierung (Firewall)"
echo "==========================================="
if [ "$EUID" -ne 0 ]; then
echo "Bitte mit sudo ausführen: sudo ./setup-firewall-linux.sh"
exit
exit 1
fi
# Erkennung der Firewall (firewalld für Fedora/KDE, ufw für Ubuntu)
if command -v firewall-cmd &> /dev/null; then
echo "[Fedora/firewalld] Öffne Ports 8090 (TCP), 8080 (TCP) und 5353 (UDP)..."
firewall-cmd --permanent --add-port=8090/tcp
# Ports:
# 8080 (P2P Sync), 8090 (Chat WS), 5353 (mDNS)
# 8500 (Consul UI - optional), 8600 (Consul DNS - optional)
open_ports_firewalld() {
echo "[Fedora/firewalld] Konfiguriere..."
firewall-cmd --permanent --add-port=8080/tcp
firewall-cmd --permanent --add-port=8090/tcp
firewall-cmd --permanent --add-service=mdns
# Optional: Consul Ports falls nötig
# firewall-cmd --permanent --add-port=8500/tcp
firewall-cmd --reload
echo "Fertig!"
elif command -v ufw &> /dev/null; then
echo "[Ubuntu/ufw] Öffne Ports 8090 (TCP), 8080 (TCP) und 5353 (UDP)..."
ufw allow 8090/tcp
ufw allow 8080/tcp
ufw allow 5353/udp
echo "Fertig!"
else
echo "Keine bekannte Firewall (ufw/firewalld) gefunden. Bitte Ports manuell prüfen."
fi
}
echo "Das System ist nun bereit für den Meldestelle-POC."
open_ports_ufw() {
echo "[Ubuntu/ufw] Konfiguriere..."
ufw allow 8080/tcp comment 'Meldestelle Sync'
ufw allow 8090/tcp comment 'Meldestelle Chat'
ufw allow 5353/udp comment 'mDNS Discovery'
ufw reload
echo "Fertig!"
}
if command -v firewall-cmd &> /dev/null; then
open_ports_firewalld
elif command -v ufw &> /dev/null; then
open_ports_ufw
else
echo "Keine unterstützte Firewall (ufw/firewalld) gefunden."
echo "Bitte öffnen Sie manuell: 8080/tcp, 8090/tcp und 5353/udp."
fi