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 @@
# ============================================================================= include required("/stdlib/jdk/21/amazon.conf")
# Conveyor Configuration for Meldestelle Desktop App
# =============================================================================
include required("/stdlib/jdk/21/openjdk.conf")
include required("https://raw.githubusercontent.com/hydraulic-software/conveyor/master/configs/jvm/extract-native-libraries.conf") include required("https://raw.githubusercontent.com/hydraulic-software/conveyor/master/configs/jvm/extract-native-libraries.conf")
app { app {
display-name = "Meldestelle" display-name = "Meldestelle"
rdns-name = "at.mocode.meldestelle" rdns-name = "at.mocode.meldestelle"
vendor = "mo-code.at" vendor = "mo-code.at"
contact-email = "support@mo-code.at" contact-email = "support@mo-code.at"
version = "1.0.0" version = "1.0.1"
description = "ÖTO-konforme Turnier-Meldestelle Desktop App" 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 ] machines = [ windows.amd64, linux.amd64.glibc ]
site.base-url = "localhost" site.base-url = "localhost"
# Wir geben nur den Ordner an, Conveyor findet die icon.png darin automatisch # Icons werden im Ordner gesucht
icons = "frontend/shells/meldestelle-desktop/src/jvmMain/resources" icons = "frontend/shells/meldestelle-desktop/src/jvmMain/resources/icon.png"
jvm { jvm {
gui { gui {
@@ -27,8 +22,8 @@ app {
} }
jvm-options = [ jvm-options = [
"-Xms128m", "-Xms256m",
"-Xmx512m", "-Xmx1024m",
"-Dfile.encoding=UTF-8", "-Dfile.encoding=UTF-8",
"--enable-native-access=ALL-UNNAMED" "--enable-native-access=ALL-UNNAMED"
] ]
@@ -42,6 +37,10 @@ app {
menu-group = "Meldestelle" menu-group = "Meldestelle"
desktop-shortcut = true desktop-shortcut = true
} }
linux {
debian.control.depends = "libasound2, libgl1-mesa-glx, libx11-6"
}
} }
conveyor.compatibility-level = 22 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) | Feature | Conveyor (Empfohlen) | Gradle (Compose Plugin) |
Um native Pakete bauen zu können, müssen folgende Werkzeuge auf dem System vorhanden sein: | :--- | :--- | :--- |
| **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) ## 2. Cross-Packaging mit Conveyor
sudo apt install dpkg-dev fakeroot
```
### Conveyor (Cross-Packaging Tool) Conveyor ist so konfiguriert, dass es von Linux aus Pakete für alle Zielsysteme schnüren kann.
Conveyor wird benötigt, um von Linux aus Windows-Installer (.msi) oder macOS-Pakete zu erzeugen.
**Installation auf Fedora/Linux:** ### Voraussetzungen
Da automatisierte Skripte manchmal unzuverlässig sind, hier der direkte Weg über das Binär-Paket: 1. **JAR-Dateien:** Die App muss kompiliert sein:
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:**
```bash ```bash
# Entpacken ./gradlew :frontend:shells:meldestelle-desktop:jvmJar
tar -xvf hydraulic-conveyor-*-linux-amd64.tar.gz
# In den Pfad verschieben
sudo mv conveyor /usr/local/bin/
``` ```
2. **Icons:** Das System sucht nach `icon.png` in `frontend/shells/meldestelle-desktop/src/jvmMain/resources/`.
3. **Verifizieren:** ### Pakete bauen
```bash Führen Sie Conveyor im Projekt-Root aus:
conveyor --version
```
---
## 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 ```bash
./gradlew :frontend:shells:meldestelle-desktop:packageRpm # Komplette Release-Site (Windows & Linux)
```
*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
conveyor make site 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 Ergebnisse liegen im Ordner `output/`.
Die fertigen Installer (z.B. `.msi` für Windows) befinden sich im neu erstellten Ordner `output/`.
--- ---
## 4. Problembehandlung & Optimierung ## 3. Konfiguration (`conveyor.conf`)
### Native Access Warnungen Wichtige Parameter der aktuellen Konfiguration (v1.0.1):
Die App benötigt Zugriff auf native Bibliotheken (Netty/SQLite). Der notwendige Parameter `--enable-native-access=ALL-UNNAMED` ist bereits fest hinterlegt. * **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 ```bash
sudo ./setup-firewall-linux.sh 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 **Status:** 🏗️ In Arbeit
**SCS:** Desktop App / Infrastructure **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 ## 🎯 Aktuelles Ziel
1. **Netzwerk-Kommunikation (Chat POC):** Implementierung einer simplen Chat-Funktion für die Desktop-App, die im lokalen Netzwerk funktioniert (Verbindungstest). 1. **Stabile Netzwerk-Kommunikation:** Implementierung einer robusten P2P-Kommunikation mit Reconnection-Logik und Heartbeats.
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. 2. **Multi-Node Architektur:** Host-Client-Modell stabilisiert.
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. 3. **Professional Packaging:** Vorbereitung für echte Installer (.msi, .deb) via Conveyor.
## 🛠️ Letzte Änderungen ## 🛠️ Letzte Änderungen
- Fokus auf Netzwerk- & Offline-Fähigkeiten gelegt. Turnier-Anlage-Wizard pausiert. - **Hardening P2P:** `JvmP2pSyncService` komplett refactored. Jetzt mit automatischem Reconnect (3s Intervall) und Ktor Heartbeats (Ping/Pong alle 5s).
- Neuer Branch `feature/desktop-network-chat` für die anstehenden Arbeiten. - **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 ## 📍 Fokus-Bereiche
- Lokale Netzwerk-Discovery (z.B. Ktor, UDP Broadcast, mDNS). - [x] Robuste Reconnection-Logik im P2P Service.
- P2P oder Client-Server Chat-Kommunikation im lokalen Netzwerk für den Verbindungs-Check. - [x] Heartbeats zur Erkennung toter Verbindungen.
- KMP Desktop-Modul. - [ ] In-App Feedback bei Firewall-Blockaden.
- [ ] Multi-Node Test mit > 2 Teilnehmern.
## 🚧 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).
## 🔄 Nächste Schritte ## 🔄 Nächste Schritte
- [ ] Architektur-Entscheidung (ADR) für lokale Netzwerk-Discovery und Kommunikation treffen (Ktor Sockets, UDP, etc.). - [ ] Multi-Node Stabilitätstest (Simulierte Netzwerk-Drops).
- [ ] Erste Implementierung des Discovery-Mechanismus. - [ ] 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 import javax.crypto.spec.SecretKeySpec
class FileBackupService(private val deviceName: String) : BackupService { 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> { override fun exportDelta(data: String, targetPath: String, sharedKey: String): Result<String> {
return try { return try {
val timestamp = System.currentTimeMillis() val timestamp = System.currentTimeMillis()
val checksum = calculateChecksum(data) val checksum = calculateChecksum(data)
val payload = BackupPayload(timestamp, deviceName, data, checksum) val payload = BackupPayload(timestamp, deviceName, data, checksum)
val jsonContent = json.encodeToString(payload) val jsonContent = json.encodeToString(payload)
val encryptedData = encrypt(jsonContent, sharedKey) val encryptedData = encrypt(jsonContent, sharedKey)
val dir = File(targetPath) val dir = File(targetPath)
if (!dir.exists()) dir.mkdirs() if (!dir.exists()) dir.mkdirs()
val fileName = "delta_${timestamp}_${deviceName}.msbackup" val fileName = "delta_${timestamp}_${deviceName}.msbackup"
val file = File(dir, fileName) val file = File(dir, fileName)
file.writeText(encryptedData) file.writeText(encryptedData)
println("[Plan-USB] Export erfolgreich: ${file.absolutePath}") println("[Plan-USB] Export erfolgreich: ${file.absolutePath}")
Result.success(file.absoluteName) Result.success(file.absoluteName)
} catch (e: Exception) { } catch (e: Exception) {
println("[Plan-USB] Export fehlgeschlagen: ${e.message}") println("[Plan-USB] Export fehlgeschlagen: ${e.message}")
Result.failure(e) Result.failure(e)
}
} }
}
override fun importDelta(filePath: String, sharedKey: String): Result<String> { override fun importDelta(filePath: String, sharedKey: String): Result<String> {
return try { return try {
val file = File(filePath) val file = File(filePath)
val encryptedData = file.readText() val encryptedData = file.readText()
val jsonContent = decrypt(encryptedData, sharedKey) val jsonContent = decrypt(encryptedData, sharedKey)
val payload = json.decodeFromString<BackupPayload>(jsonContent) val payload = json.decodeFromString<BackupPayload>(jsonContent)
if (calculateChecksum(payload.data) != payload.checksum) { if (calculateChecksum(payload.data) != payload.checksum) {
throw Exception("Checksummenfehler: Daten wurden möglicherweise manipuliert.") throw Exception("Checksummenfehler: Daten wurden möglicherweise manipuliert.")
} }
println("[Plan-USB] Import erfolgreich von ${payload.deviceName}") println("[Plan-USB] Import erfolgreich von ${payload.deviceName}")
Result.success(payload.data) Result.success(payload.data)
} catch (e: Exception) { } catch (e: Exception) {
println("[Plan-USB] Import fehlgeschlagen: ${e.message}") println("[Plan-USB] Import fehlgeschlagen: ${e.message}")
Result.failure(e) Result.failure(e)
}
} }
}
private fun calculateChecksum(data: String): String { private fun calculateChecksum(data: String): String {
val bytes = MessageDigest.getInstance("SHA-256").digest(data.toByteArray()) val bytes = MessageDigest.getInstance("SHA-256").digest(data.toByteArray())
return bytes.joinToString("") { "%02x".format(it) } return bytes.joinToString("") { "%02x".format(it) }
} }
private fun encrypt(data: String, key: String): String { private fun encrypt(data: String, key: String): String {
val secretKey = generateKey(key) val secretKey = generateKey(key)
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
val iv = IvParameterSpec(ByteArray(16)) // Vereinfacht für PoC val iv = IvParameterSpec(ByteArray(16)) // Vereinfacht für PoC
cipher.init(Cipher.ENCRYPT_MODE, secretKey, iv) cipher.init(Cipher.ENCRYPT_MODE, secretKey, iv)
val encrypted = cipher.doFinal(data.toByteArray()) val encrypted = cipher.doFinal(data.toByteArray())
return Base64.getEncoder().encodeToString(encrypted) return Base64.getEncoder().encodeToString(encrypted)
} }
private fun decrypt(encrypted: String, key: String): String { private fun decrypt(encrypted: String, key: String): String {
val secretKey = generateKey(key) val secretKey = generateKey(key)
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
val iv = IvParameterSpec(ByteArray(16)) val iv = IvParameterSpec(ByteArray(16))
cipher.init(Cipher.DECRYPT_MODE, secretKey, iv) cipher.init(Cipher.DECRYPT_MODE, secretKey, iv)
val decrypted = cipher.doFinal(Base64.getDecoder().decode(encrypted)) val decrypted = cipher.doFinal(Base64.getDecoder().decode(encrypted))
return String(decrypted) return String(decrypted)
} }
private fun generateKey(key: String): SecretKeySpec { private fun generateKey(key: String): SecretKeySpec {
val sha = MessageDigest.getInstance("SHA-256") val sha = MessageDigest.getInstance("SHA-256")
val keyBytes = sha.digest(key.toByteArray()).copyOf(16) // AES-128 für Kompatibilität val keyBytes = sha.digest(key.toByteArray()).copyOf(16) // AES-128 für Kompatibilität
return SecretKeySpec(keyBytes, "AES") return SecretKeySpec(keyBytes, "AES")
} }
} }
private val File.absoluteName: String get() = this.name private val File.absoluteName: String get() = this.name
@@ -9,6 +9,6 @@ import org.koin.dsl.module
* JVM-spezifische Implementierung des DiscoveryModules. * JVM-spezifische Implementierung des DiscoveryModules.
*/ */
actual val discoveryModule: Module = module { actual val discoveryModule: Module = module {
single<NetworkDiscoveryService> { JmDnsDiscoveryService() } single<NetworkDiscoveryService> { JmDnsDiscoveryService() }
single<BackupService> { (deviceName: String) -> FileBackupService(deviceName) } single<BackupService> { (deviceName: String) -> FileBackupService(deviceName) }
} }
@@ -22,8 +22,10 @@ class JmDnsDiscoveryService : NetworkDiscoveryService {
private val registeredSet = ConcurrentHashMap.newKeySet<String>() // key: "${name}@${addr.hostAddress}:$port" private val registeredSet = ConcurrentHashMap.newKeySet<String>() // key: "${name}@${addr.hostAddress}:$port"
// Debounce/Guards // Debounce/Guards
@Volatile private var lastStartRequestedAt: Long = 0L @Volatile
@Volatile private var lastStartIp: String? = null private var lastStartRequestedAt: Long = 0L
@Volatile
private var lastStartIp: String? = null
private val _discoveredServices = MutableStateFlow<List<DiscoveredService>>(emptyList()) private val _discoveredServices = MutableStateFlow<List<DiscoveredService>>(emptyList())
override val discoveredServices: StateFlow<List<DiscoveredService>> = _discoveredServices.asStateFlow() override val discoveredServices: StateFlow<List<DiscoveredService>> = _discoveredServices.asStateFlow()
@@ -149,7 +151,10 @@ class JmDnsDiscoveryService : NetworkDiscoveryService {
val name = iface.name.lowercase() val name = iface.name.lowercase()
// Filtere Docker/Bridged/VETH/VM-Schnittstellen heraus // Filtere Docker/Bridged/VETH/VM-Schnittstellen heraus
if (iface.isLoopback || !iface.isUp || iface.isVirtual) continue 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 val inetAddresses = iface.inetAddresses
while (inetAddresses.hasMoreElements()) { 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) // Bevorzuge private LAN IPv4 (192.168.x.x, 10.x.x.x, 172.16-31.x.x)
fun isPrivateIPv4(a: InetAddress): Boolean { fun isPrivateIPv4(a: InetAddress): Boolean {
val h = a.hostAddress 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) } return addresses.sortedWith(compareByDescending<InetAddress> { isPrivateIPv4(it) }
.thenBy { it.hostAddress }) .thenBy { it.hostAddress })
@@ -2,145 +2,181 @@ package at.mocode.frontend.core.network.sync
import io.ktor.client.* import io.ktor.client.*
import io.ktor.client.plugins.websocket.* import io.ktor.client.plugins.websocket.*
import io.ktor.client.plugins.websocket.WebSockets
import io.ktor.server.application.* import io.ktor.server.application.*
import io.ktor.server.engine.* import io.ktor.server.engine.*
import io.ktor.server.netty.* import io.ktor.server.netty.*
import io.ktor.server.routing.* import io.ktor.server.routing.*
import io.ktor.server.websocket.* import io.ktor.server.websocket.*
import io.ktor.websocket.* import io.ktor.websocket.*
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import java.util.* import java.util.*
import java.util.concurrent.ConcurrentHashMap 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 { class JvmP2pSyncService : P2pSyncService {
companion object { companion object {
// Prozessweiter, portbasierter Guard gegen Mehrfachstart private val startedPorts: MutableSet<Int> = ConcurrentHashMap.newKeySet()
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 { private val _incomingEvents = MutableSharedFlow<SyncEvent>(extraBufferCapacity = 64)
install(io.ktor.client.plugins.websocket.WebSockets) 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>() if (!startedPorts.add(port)) {
override val incomingEvents: Flow<SyncEvent> = _incomingEvents.asSharedFlow() println("[P2P Server] Port $port wird bereits von einer anderen Instanz genutzt.")
return
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
}
} }
override fun stopServer() { try {
try { server = embeddedServer(Netty, port = port, host = "0.0.0.0") {
server?.stop(1000, 2000) install(io.ktor.server.websocket.WebSockets) {
} finally { pingPeriod = PING_INTERVAL_MS.milliseconds
server = null timeout = PING_TIMEOUT_MS.milliseconds
currentPort?.let { startedPorts.remove(it) }
currentPort = null
} }
} routing {
webSocket("/sync") {
override suspend fun connectToPeer(host: String, port: Int) { val remote = call.request.local.remoteAddress
scope.launch { println("[P2P Server] Neuer Peer verbunden: $remote")
activeSessions.add(this)
updatePeers()
try { try {
client.webSocket(host = host, port = port, path = "/sync") { for (frame in incoming) {
println("[P2P Client] Verbunden mit $host:$port") if (frame is Frame.Text) {
activeSessions.add(this) val text = frame.readText()
updatePeers() try {
try { val event = Json.decodeFromString<SyncEvent>(text)
for (frame in incoming) { _incomingEvents.emit(event)
if (frame is Frame.Text) { } catch (ex: Exception) {
val text = frame.readText() println("[P2P Server] Fehler beim Dekodieren von $remote: ${ex.message}")
val event = Json.decodeFromString<SyncEvent>(text) }
_incomingEvents.emit(event)
}
}
} finally {
activeSessions.remove(this)
updatePeers()
println("[P2P Client] Verbindung zu $host:$port beendet")
}
} }
} 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) { override fun stopServer() {
val text = Json.encodeToString(event) connectionJobs.values.forEach { it.cancel() }
activeSessions.toList().forEach { session -> 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 { try {
session.send(Frame.Text(text)) for (frame in incoming) {
} catch (e: Exception) { if (frame is Frame.Text) {
println("[P2P] Fehler beim Senden an Session: ${e.message}") 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() { if (isActive) {
// Da wir keine einfachen IPs in den Sessions haben ohne tieferes Casting, println("[P2P Client] Erneuter Versuch für $peerKey in ${RECONNECT_DELAY_MS}ms...")
// nutzen wir hier erst mal einen Platzhalter oder zählen nur. delay(RECONNECT_DELAY_MS.milliseconds)
_connectedPeers.value = activeSessions.map { "Peer-${it.hashCode()}" } }
}
} }
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. * JVM-spezifische Implementierung des SyncModules.
*/ */
actual val syncModule: Module = module { actual val syncModule: Module = module {
single<P2pSyncService> { JvmP2pSyncService() } single<P2pSyncService> { JvmP2pSyncService() }
single { SyncManager(get(), get()) } single { SyncManager(get(), get()) }
} }
@@ -5,33 +5,33 @@ import kotlin.test.Test
class JvmP2pSyncServiceTest { class JvmP2pSyncServiceTest {
@Test @Test
fun starting_server_twice_on_same_port_should_not_fail_but_use_guard() = runTest { fun starting_server_twice_on_same_port_should_not_fail_but_use_guard() = runTest {
val service1 = JvmP2pSyncService() val service1 = JvmP2pSyncService()
val service2 = JvmP2pSyncService() val service2 = JvmP2pSyncService()
val port = 9091 val port = 9091
try { try {
service1.startServer(port) service1.startServer(port)
// Second start should just return/log and not throw an exception (idempotent) // Second start should just return/log and not throw an exception (idempotent)
service2.startServer(port) service2.startServer(port)
} finally { } finally {
service1.stopServer() service1.stopServer()
service2.stopServer() service2.stopServer()
}
} }
}
@Test @Test
fun stopping_server_should_release_port_lock() = runTest { fun stopping_server_should_release_port_lock() = runTest {
val service1 = JvmP2pSyncService() val service1 = JvmP2pSyncService()
val service2 = JvmP2pSyncService() val service2 = JvmP2pSyncService()
val port = 9092 val port = 9092
service1.startServer(port) service1.startServer(port)
service1.stopServer() service1.stopServer()
// After stopping, starting again on same port (even from different instance) should work // After stopping, starting again on same port (even from different instance) should work
service2.startServer(port) service2.startServer(port)
service2.stopServer() service2.stopServer()
} }
} }
@@ -1,23 +1,23 @@
package at.mocode.frontend.core.network.sync 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.Flow
import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.emptyFlow
import org.koin.core.module.Module
import org.koin.dsl.module
/** /**
* Wasm-spezifische Implementierung (vorerst No-op). * Wasm-spezifische Implementierung (vorerst No-op).
*/ */
actual val syncModule: Module = module { actual val syncModule: Module = module {
single<P2pSyncService> { NoOpP2pSyncService() } single<P2pSyncService> { NoOpP2pSyncService() }
single { SyncManager(get(), get()) } single { SyncManager(get(), get()) }
} }
class NoOpP2pSyncService : P2pSyncService { class NoOpP2pSyncService : P2pSyncService {
override fun startServer(port: Int) {} override fun startServer(port: Int) {}
override fun stopServer() {} override fun stopServer() {}
override suspend fun connectToPeer(host: String, port: Int) {} override suspend fun connectToPeer(host: String, port: Int) {}
override suspend fun broadcastEvent(event: SyncEvent) {} override suspend fun broadcastEvent(event: SyncEvent) {}
override val incomingEvents: Flow<SyncEvent> = emptyFlow() override val incomingEvents: Flow<SyncEvent> = emptyFlow()
override val connectedPeers: Flow<List<String>> = emptyFlow() override val connectedPeers: Flow<List<String>> = emptyFlow()
} }
@@ -26,10 +26,11 @@ import org.koin.compose.viewmodel.koinViewModel
*/ */
@Composable @Composable
fun DesktopApp() { 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 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.SYSTEM -> isSystemInDarkTheme()
at.mocode.frontend.features.device.initialization.domain.model.AppThemeSetting.LIGHT -> false at.mocode.frontend.features.device.initialization.domain.model.AppThemeSetting.LIGHT -> false
at.mocode.frontend.features.device.initialization.domain.model.AppThemeSetting.DARK -> true at.mocode.frontend.features.device.initialization.domain.model.AppThemeSetting.DARK -> true
@@ -27,7 +27,7 @@ private fun PreviewContent() {
Surface { Surface {
// --- REITER --- // --- REITER ---
//ReiterScreen(viewModel = ReiterViewModel()) //ReiterScreen(viewModel = ReiterViewModel())
// --- PFERDE --- // --- PFERDE ---
// PferdeScreen(viewModel = PferdeViewModel()) // PferdeScreen(viewModel = PferdeViewModel())
@@ -35,8 +35,6 @@ private fun PreviewContent() {
// --- VEREIN --- // --- VEREIN ---
// ── Hier den gewünschten Screen eintragen ────────────────────── // ── Hier den gewünschten Screen eintragen ──────────────────────
// VeranstalterAuswahlScreen(onVeranstalterSelected = {}, onNeuerVeranstalter = {}) // VeranstalterAuswahlScreen(onVeranstalterSelected = {}, onNeuerVeranstalter = {})
// VeranstalterNeuScreen(onBack = {}, onSave = {}) // 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.core.navigation.NavigationPort
import at.mocode.frontend.shell.desktop.navigation.DesktopNavigationPort import at.mocode.frontend.shell.desktop.navigation.DesktopNavigationPort
import at.mocode.frontend.shell.desktop.repository.DesktopMasterdataRepository 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 import org.koin.dsl.module
/** /**
@@ -35,4 +37,5 @@ val desktopModule = module {
single<CurrentUserProvider> { DesktopCurrentUserProvider(get()) } single<CurrentUserProvider> { DesktopCurrentUserProvider(get()) }
single { DeepLinkHandler(get(), get()) } single { DeepLinkHandler(get(), get()) }
single<MasterdataRepository> { DesktopMasterdataRepository(get()) } single<MasterdataRepository> { DesktopMasterdataRepository(get()) }
viewModel { ChatViewModel(get()) }
} }
@@ -1,17 +1,12 @@
package at.mocode.frontend.shell.desktop package at.mocode.frontend.shell.desktop
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Window import androidx.compose.ui.window.Window
import androidx.compose.ui.window.WindowState
import androidx.compose.ui.window.application import androidx.compose.ui.window.application
import at.mocode.frontend.core.auth.di.authModule 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.DatabaseProvider
import at.mocode.frontend.core.localdb.localDbModule 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.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.billing.di.billingModule
import at.mocode.frontend.features.device.initialization.di.deviceInitializationModule import at.mocode.frontend.features.device.initialization.di.deviceInitializationModule
import at.mocode.frontend.features.funktionaer.di.funktionaerModule 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.veranstalter.di.veranstalterModule
import at.mocode.frontend.features.verein.di.vereinFeatureModule import at.mocode.frontend.features.verein.di.vereinFeatureModule
import at.mocode.frontend.features.zns.import.di.znsImportModule 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.frontend.shell.desktop.di.desktopModule
import at.mocode.veranstaltung.feature.di.veranstaltungModule import at.mocode.veranstaltung.feature.di.veranstaltungModule
import at.mocode.veranstaltung.feature.domain.repository.VeranstaltungRepository
import kotlinx.coroutines.runBlocking 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.core.context.startKoin
import org.koin.dsl.module import org.koin.dsl.module
fun main() = application { fun main() {
try { application {
startKoin { // Koin Starten
val koinApp = startKoin {
printLogger()
modules( modules(
networkModule, networkModule,
syncModule,
authModule, authModule,
localDbModule, localDbModule,
pingFeatureModule,
nennungFeatureModule,
znsImportModule,
profileModule,
billingModule,
pferdeModule,
reiterModule,
funktionaerModule,
vereinFeatureModule,
veranstalterModule,
turnierFeatureModule,
veranstaltungModule,
module {
single<VeranstaltungRepository> { StoreVeranstaltungRepository() }
},
deviceInitializationModule,
desktopModule, desktopModule,
deviceInitializationModule,
billingModule,
funktionaerModule,
nennungFeatureModule,
pferdeModule,
pingFeatureModule,
profileModule,
reiterModule,
turnierFeatureModule,
veranstalterModule,
veranstaltungModule,
vereinFeatureModule,
znsImportModule
) )
} }
// Datenbank EAGER initialisieren (JVM-safe via runBlocking) val koin = koinApp.koin
val koin = GlobalContext.get()
val dbProvider = koin.get<DatabaseProvider>() // Datenbank initialisieren und als Singleton registrieren
val dbProvider: DatabaseProvider = koin.get()
val database = runBlocking { dbProvider.createDatabase() } val database = runBlocking { dbProvider.createDatabase() }
koin.loadModules(listOf(module { single { database } }))
loadKoinModules(module { // SyncManager initialisieren und starten (Default Port 8080)
single<AppDatabase> { database } val syncManager: SyncManager = koin.get()
}) syncManager.start(8080)
println("[DesktopApp] KOIN & DB initialisiert") Window(onCloseRequest = ::exitApplication, title = "Meldestelle Desktop") {
DesktopApp()
// 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))
} }
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.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Send 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 androidx.compose.ui.unit.sp
import at.mocode.frontend.core.designsystem.theme.AppColors import at.mocode.frontend.core.designsystem.theme.AppColors
import at.mocode.frontend.core.designsystem.theme.Dimens import at.mocode.frontend.core.designsystem.theme.Dimens
import java.time.LocalTime import at.mocode.frontend.shell.desktop.screens.chat.presentation.ChatMessageState
import java.time.format.DateTimeFormatter import at.mocode.frontend.shell.desktop.screens.chat.presentation.ChatViewModel
import org.koin.compose.viewmodel.koinViewModel
data class ChatMessage(
val id: String,
val sender: String,
val text: String,
val time: String,
val isFromMe: Boolean
)
@Composable @Composable
fun ChatScreen( fun ChatScreen(
onBack: () -> Unit onBack: () -> Unit,
viewModel: ChatViewModel = koinViewModel()
) { ) {
var messageText by remember { mutableStateOf("") } var messageText by remember { mutableStateOf("") }
val messages = remember { mutableStateListOf<ChatMessage>() } val messages by viewModel.messages.collectAsState()
val timeFormatter = DateTimeFormatter.ofPattern("HH:mm") val peerCount by viewModel.peerCount.collectAsState()
val scrollState = rememberLazyListState()
// Mock initial messages // Auto-scroll to bottom on new messages
LaunchedEffect(Unit) { LaunchedEffect(messages.size) {
if (messages.isEmpty()) { if (messages.isNotEmpty()) {
messages.add(ChatMessage("1", "Richter-Turm 1", "Startliste für Bewerb 5 ist fertig?", "10:45", false)) scrollState.animateScrollToItem(messages.size - 1)
messages.add(ChatMessage("2", "Meldestelle", "Ja, wird gerade gedruckt.", "10:46", true))
} }
} }
@@ -61,9 +56,9 @@ fun ChatScreen(
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
) )
Text( Text(
"LAN-Kanal: aktiv (3 Teilnehmer)", "LAN-Kanal: aktiv ($peerCount Teilnehmer verbunden)",
style = MaterialTheme.typography.labelMedium, 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 // Chat Messages
LazyColumn( LazyColumn(
state = scrollState,
modifier = Modifier.weight(1f).fillMaxWidth().padding(horizontal = Dimens.SpacingM), modifier = Modifier.weight(1f).fillMaxWidth().padding(horizontal = Dimens.SpacingM),
contentPadding = PaddingValues(vertical = Dimens.SpacingM), contentPadding = PaddingValues(vertical = Dimens.SpacingM),
verticalArrangement = Arrangement.spacedBy(Dimens.SpacingS) verticalArrangement = Arrangement.spacedBy(Dimens.SpacingS)
) { ) {
items(messages) { msg -> items(messages, key = { it.id }) { msg ->
ChatBubble(msg) ChatBubble(msg)
} }
} }
@@ -102,18 +98,11 @@ fun ChatScreen(
IconButton( IconButton(
onClick = { onClick = {
if (messageText.isNotBlank()) { if (messageText.isNotBlank()) {
messages.add( viewModel.sendMessage(messageText)
ChatMessage(
id = messages.size.toString(),
sender = "Meldestelle",
text = messageText,
time = LocalTime.now().format(timeFormatter),
isFromMe = true
)
)
messageText = "" messageText = ""
} }
}, },
enabled = messageText.isNotBlank(),
colors = IconButtonDefaults.iconButtonColors( colors = IconButtonDefaults.iconButtonColors(
containerColor = MaterialTheme.colorScheme.primary, containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary contentColor = MaterialTheme.colorScheme.onPrimary
@@ -128,7 +117,7 @@ fun ChatScreen(
} }
@Composable @Composable
private fun ChatBubble(msg: ChatMessage) { private fun ChatBubble(msg: ChatMessageState) {
Column( Column(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalAlignment = if (msg.isFromMe) Alignment.End else Alignment.Start 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 #!/bin/bash
echo "===========================================" echo "==========================================="
echo "Meldestelle - Netzwerk-Setup für POC" echo "Meldestelle - Netzwerk-Optimierung (Firewall)"
echo "===========================================" echo "==========================================="
if [ "$EUID" -ne 0 ]; then if [ "$EUID" -ne 0 ]; then
echo "Bitte mit sudo ausführen: sudo ./setup-firewall-linux.sh" echo "Bitte mit sudo ausführen: sudo ./setup-firewall-linux.sh"
exit exit 1
fi fi
# Erkennung der Firewall (firewalld für Fedora/KDE, ufw für Ubuntu) # Ports:
if command -v firewall-cmd &> /dev/null; then # 8080 (P2P Sync), 8090 (Chat WS), 5353 (mDNS)
echo "[Fedora/firewalld] Öffne Ports 8090 (TCP), 8080 (TCP) und 5353 (UDP)..." # 8500 (Consul UI - optional), 8600 (Consul DNS - optional)
firewall-cmd --permanent --add-port=8090/tcp
open_ports_firewalld() {
echo "[Fedora/firewalld] Konfiguriere..."
firewall-cmd --permanent --add-port=8080/tcp firewall-cmd --permanent --add-port=8080/tcp
firewall-cmd --permanent --add-port=8090/tcp
firewall-cmd --permanent --add-service=mdns firewall-cmd --permanent --add-service=mdns
# Optional: Consul Ports falls nötig
# firewall-cmd --permanent --add-port=8500/tcp
firewall-cmd --reload firewall-cmd --reload
echo "Fertig!" 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