diff --git a/conveyor.conf b/conveyor.conf index f4ae9879..a7f7a045 100644 --- a/conveyor.conf +++ b/conveyor.conf @@ -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 diff --git a/docs/02_Guides/Desktop-Packaging-Guide.md b/docs/02_Guides/Desktop-Packaging-Guide.md index 05df4bc9..0d24b961 100644 --- a/docs/02_Guides/Desktop-Packaging-Guide.md +++ b/docs/02_Guides/Desktop-Packaging-Guide.md @@ -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. diff --git a/docs/ACTIVE_TASK.md b/docs/ACTIVE_TASK.md index 484f9eef..545d874f 100644 --- a/docs/ACTIVE_TASK.md +++ b/docs/ACTIVE_TASK.md @@ -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. diff --git a/frontend/core/network/src/jvmMain/kotlin/at/mocode/frontend/core/network/backup/FileBackupService.kt b/frontend/core/network/src/jvmMain/kotlin/at/mocode/frontend/core/network/backup/FileBackupService.kt index aba03f6f..6ff11cf0 100644 --- a/frontend/core/network/src/jvmMain/kotlin/at/mocode/frontend/core/network/backup/FileBackupService.kt +++ b/frontend/core/network/src/jvmMain/kotlin/at/mocode/frontend/core/network/backup/FileBackupService.kt @@ -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 { - 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 { + 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 { - return try { - val file = File(filePath) - val encryptedData = file.readText() - val jsonContent = decrypt(encryptedData, sharedKey) - val payload = json.decodeFromString(jsonContent) + override fun importDelta(filePath: String, sharedKey: String): Result { + return try { + val file = File(filePath) + val encryptedData = file.readText() + val jsonContent = decrypt(encryptedData, sharedKey) + val payload = json.decodeFromString(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 diff --git a/frontend/core/network/src/jvmMain/kotlin/at/mocode/frontend/core/network/discovery/DiscoveryModule.kt b/frontend/core/network/src/jvmMain/kotlin/at/mocode/frontend/core/network/discovery/DiscoveryModule.kt index 4cf497b6..e162ba17 100644 --- a/frontend/core/network/src/jvmMain/kotlin/at/mocode/frontend/core/network/discovery/DiscoveryModule.kt +++ b/frontend/core/network/src/jvmMain/kotlin/at/mocode/frontend/core/network/discovery/DiscoveryModule.kt @@ -9,6 +9,6 @@ import org.koin.dsl.module * JVM-spezifische Implementierung des DiscoveryModules. */ actual val discoveryModule: Module = module { - single { JmDnsDiscoveryService() } - single { (deviceName: String) -> FileBackupService(deviceName) } + single { JmDnsDiscoveryService() } + single { (deviceName: String) -> FileBackupService(deviceName) } } diff --git a/frontend/core/network/src/jvmMain/kotlin/at/mocode/frontend/core/network/discovery/JmDnsDiscoveryService.kt b/frontend/core/network/src/jvmMain/kotlin/at/mocode/frontend/core/network/discovery/JmDnsDiscoveryService.kt index c98c3f7c..230734d4 100644 --- a/frontend/core/network/src/jvmMain/kotlin/at/mocode/frontend/core/network/discovery/JmDnsDiscoveryService.kt +++ b/frontend/core/network/src/jvmMain/kotlin/at/mocode/frontend/core/network/discovery/JmDnsDiscoveryService.kt @@ -22,8 +22,10 @@ class JmDnsDiscoveryService : NetworkDiscoveryService { private val registeredSet = ConcurrentHashMap.newKeySet() // 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>(emptyList()) override val discoveredServices: StateFlow> = _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 { isPrivateIPv4(it) } .thenBy { it.hostAddress }) diff --git a/frontend/core/network/src/jvmMain/kotlin/at/mocode/frontend/core/network/sync/JvmP2pSyncService.kt b/frontend/core/network/src/jvmMain/kotlin/at/mocode/frontend/core/network/sync/JvmP2pSyncService.kt index ff939447..a1823462 100644 --- a/frontend/core/network/src/jvmMain/kotlin/at/mocode/frontend/core/network/sync/JvmP2pSyncService.kt +++ b/frontend/core/network/src/jvmMain/kotlin/at/mocode/frontend/core/network/sync/JvmP2pSyncService.kt @@ -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 = ConcurrentHashMap.newKeySet() + companion object { + private val startedPorts: MutableSet = 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(extraBufferCapacity = 64) + override val incomingEvents: Flow = _incomingEvents.asSharedFlow() + + private val activeSessions = Collections.synchronizedSet(LinkedHashSet()) + private val _connectedPeers = MutableStateFlow>(emptyList()) + override val connectedPeers: Flow> = _connectedPeers.asStateFlow() + + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val connectionJobs = ConcurrentHashMap() + + override fun startServer(port: Int) { + if (server != null) { + println("[P2P Server] Bereits aktiv auf Port ${currentPort ?: port}") + return } - private val _incomingEvents = MutableSharedFlow() - override val incomingEvents: Flow = _incomingEvents.asSharedFlow() - - private val activeSessions = Collections.synchronizedSet(LinkedHashSet()) - private val _connectedPeers = MutableStateFlow>(emptyList()) - override val connectedPeers: Flow> = _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(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(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(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(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() + } } diff --git a/frontend/core/network/src/jvmMain/kotlin/at/mocode/frontend/core/network/sync/SyncModule.kt b/frontend/core/network/src/jvmMain/kotlin/at/mocode/frontend/core/network/sync/SyncModule.kt index f4fae6af..aea4f9ca 100644 --- a/frontend/core/network/src/jvmMain/kotlin/at/mocode/frontend/core/network/sync/SyncModule.kt +++ b/frontend/core/network/src/jvmMain/kotlin/at/mocode/frontend/core/network/sync/SyncModule.kt @@ -7,6 +7,6 @@ import org.koin.dsl.module * JVM-spezifische Implementierung des SyncModules. */ actual val syncModule: Module = module { - single { JvmP2pSyncService() } - single { SyncManager(get(), get()) } + single { JvmP2pSyncService() } + single { SyncManager(get(), get()) } } diff --git a/frontend/core/network/src/jvmTest/kotlin/at/mocode/frontend/core/network/sync/JvmP2pSyncServiceTest.kt b/frontend/core/network/src/jvmTest/kotlin/at/mocode/frontend/core/network/sync/JvmP2pSyncServiceTest.kt index 0d207d5e..464fb98d 100644 --- a/frontend/core/network/src/jvmTest/kotlin/at/mocode/frontend/core/network/sync/JvmP2pSyncServiceTest.kt +++ b/frontend/core/network/src/jvmTest/kotlin/at/mocode/frontend/core/network/sync/JvmP2pSyncServiceTest.kt @@ -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() + } } diff --git a/frontend/core/network/src/wasmJsMain/kotlin/at/mocode/frontend/core/network/sync/SyncModule.kt b/frontend/core/network/src/wasmJsMain/kotlin/at/mocode/frontend/core/network/sync/SyncModule.kt index ae031bc8..cc366fda 100644 --- a/frontend/core/network/src/wasmJsMain/kotlin/at/mocode/frontend/core/network/sync/SyncModule.kt +++ b/frontend/core/network/src/wasmJsMain/kotlin/at/mocode/frontend/core/network/sync/SyncModule.kt @@ -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 { NoOpP2pSyncService() } - single { SyncManager(get(), get()) } + single { 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 = emptyFlow() - override val connectedPeers: Flow> = 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 = emptyFlow() + override val connectedPeers: Flow> = emptyFlow() } diff --git a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/DesktopApp.kt b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/DesktopApp.kt index 55bc2fc0..91382a63 100644 --- a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/DesktopApp.kt +++ b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/DesktopApp.kt @@ -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 diff --git a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/PreviewMain.kt b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/PreviewMain.kt index 7ebff486..7194e526 100644 --- a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/PreviewMain.kt +++ b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/PreviewMain.kt @@ -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 = {}) diff --git a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/di/DesktopModule.kt b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/di/DesktopModule.kt index 1437495f..903384db 100644 --- a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/di/DesktopModule.kt +++ b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/di/DesktopModule.kt @@ -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 { DesktopCurrentUserProvider(get()) } single { DeepLinkHandler(get(), get()) } single { DesktopMasterdataRepository(get()) } + viewModel { ChatViewModel(get()) } } diff --git a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/main.kt b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/main.kt index 6f063ad2..d47d3d21 100644 --- a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/main.kt +++ b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/main.kt @@ -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 { 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() + 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 { 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() - wsServer.start() - val discovery = koin.get() - 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() } } diff --git a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/screens/chat/ChatScreen.kt b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/screens/chat/ChatScreen.kt index 281c44c1..5123e527 100644 --- a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/screens/chat/ChatScreen.kt +++ b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/screens/chat/ChatScreen.kt @@ -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() } - 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 diff --git a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/screens/chat/presentation/ChatViewModel.kt b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/screens/chat/presentation/ChatViewModel.kt new file mode 100644 index 00000000..bf580b94 --- /dev/null +++ b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/screens/chat/presentation/ChatViewModel.kt @@ -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>(emptyList()) + val messages: StateFlow> = _messages.asStateFlow() + + private val _peerCount = MutableStateFlow(0) + val peerCount: StateFlow = _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) + } +} diff --git a/setup-firewall-linux.sh b/setup-firewall-linux.sh index 8026f155..2be5f243 100755 --- a/setup-firewall-linux.sh +++ b/setup-firewall-linux.sh @@ -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