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.
@@ -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,33 +2,41 @@ 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 server: EmbeddedServer<*, *>? = null
private var currentPort: Int? = null private var currentPort: Int? = null
private val client = HttpClient { private val client = HttpClient {
install(io.ktor.client.plugins.websocket.WebSockets) install(WebSockets) {
pingInterval = PING_INTERVAL_MS.milliseconds
}
} }
private val _incomingEvents = MutableSharedFlow<SyncEvent>() private val _incomingEvents = MutableSharedFlow<SyncEvent>(extraBufferCapacity = 64)
override val incomingEvents: Flow<SyncEvent> = _incomingEvents.asSharedFlow() override val incomingEvents: Flow<SyncEvent> = _incomingEvents.asSharedFlow()
private val activeSessions = Collections.synchronizedSet(LinkedHashSet<DefaultWebSocketSession>()) private val activeSessions = Collections.synchronizedSet(LinkedHashSet<DefaultWebSocketSession>())
@@ -36,27 +44,29 @@ class JvmP2pSyncService : P2pSyncService {
override val connectedPeers: Flow<List<String>> = _connectedPeers.asStateFlow() override val connectedPeers: Flow<List<String>> = _connectedPeers.asStateFlow()
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val connectionJobs = ConcurrentHashMap<String, Job>()
override fun startServer(port: Int) { override fun startServer(port: Int) {
// Instanz-Guard (gleiche Instanz)
if (server != null) { if (server != null) {
println("[P2P Server] Bereits gestartet (Instanz) auf Port ${currentPort ?: port} idempotent") println("[P2P Server] Bereits aktiv auf Port ${currentPort ?: port}")
return return
} }
// Prozessweiter, portbasierter Guard
println("[P2P Server] Versuche Port $port zu reservieren...")
if (!startedPorts.add(port)) { if (!startedPorts.add(port)) {
println("[P2P Server] Bereits gestartet (Prozess) auf Port $port idempotent, kein neuer Bind") println("[P2P Server] Port $port wird bereits von einer anderen Instanz genutzt.")
return return
} }
try { try {
server = embeddedServer(Netty, port = port) { server = embeddedServer(Netty, port = port, host = "0.0.0.0") {
install(io.ktor.server.websocket.WebSockets) install(io.ktor.server.websocket.WebSockets) {
pingPeriod = PING_INTERVAL_MS.milliseconds
timeout = PING_TIMEOUT_MS.milliseconds
}
routing { routing {
webSocket("/sync") { webSocket("/sync") {
println("[P2P Server] Neuer Peer verbunden") val remote = call.request.local.remoteAddress
println("[P2P Server] Neuer Peer verbunden: $remote")
activeSessions.add(this) activeSessions.add(this)
updatePeers() updatePeers()
try { try {
@@ -66,45 +76,56 @@ class JvmP2pSyncService : P2pSyncService {
try { try {
val event = Json.decodeFromString<SyncEvent>(text) val event = Json.decodeFromString<SyncEvent>(text)
_incomingEvents.emit(event) _incomingEvents.emit(event)
} catch (e: Exception) { } catch (ex: Exception) {
println("[P2P Server] Fehler beim Dekodieren: ${e.message}") println("[P2P Server] Fehler beim Dekodieren von $remote: ${ex.message}")
} }
} }
} }
} catch (ex: Exception) {
println("[P2P Server] Verbindung zu $remote unterbrochen: ${ex.message}")
} finally { } finally {
activeSessions.remove(this) activeSessions.remove(this)
updatePeers() updatePeers()
println("[P2P Server] Peer getrennt") println("[P2P Server] Peer $remote getrennt")
} }
} }
} }
}.start(wait = false) }.start(wait = false)
currentPort = port currentPort = port
} catch (e: Exception) { println("[P2P Server] Erfolgreich gestartet auf Port $port")
// Start fehlgeschlagen -> Port-Lock wieder freigeben } catch (ex: Exception) {
startedPorts.remove(port) startedPorts.remove(port)
server = null server = null
currentPort = null currentPort = null
println("[P2P Server] Start auf Port $port fehlgeschlagen: ${e.message}") println("[P2P Server] Fehler beim Starten des Servers auf Port $port: ${ex.message}")
throw e throw ex
} }
} }
override fun stopServer() { override fun stopServer() {
connectionJobs.values.forEach { it.cancel() }
connectionJobs.clear()
try { try {
server?.stop(1000, 2000) server?.stop(1000, 2000)
} finally { } finally {
server = null server = null
currentPort?.let { startedPorts.remove(it) } currentPort?.let { startedPorts.remove(it) }
currentPort = null currentPort = null
println("[P2P Server] Server gestoppt.")
} }
} }
override suspend fun connectToPeer(host: String, port: Int) { override suspend fun connectToPeer(host: String, port: Int) {
scope.launch { val peerKey = "$host:$port"
connectionJobs[peerKey]?.cancel()
val job = scope.launch {
while (isActive) {
try { try {
println("[P2P Client] Verbindungsversuch zu $peerKey...")
client.webSocket(host = host, port = port, path = "/sync") { client.webSocket(host = host, port = port, path = "/sync") {
println("[P2P Client] Verbunden mit $host:$port") println("[P2P Client] Verbunden mit $peerKey")
activeSessions.add(this) activeSessions.add(this)
updatePeers() updatePeers()
try { try {
@@ -115,32 +136,47 @@ class JvmP2pSyncService : P2pSyncService {
_incomingEvents.emit(event) _incomingEvents.emit(event)
} }
} }
} catch (ex: Exception) {
println("[P2P Client] Verbindung zu $peerKey abgebrochen: ${ex.message}")
} finally { } finally {
activeSessions.remove(this) activeSessions.remove(this)
updatePeers() updatePeers()
println("[P2P Client] Verbindung zu $host:$port beendet") println("[P2P Client] Session mit $peerKey beendet.")
} }
} }
} catch (e: Exception) { } catch (ex: Exception) {
println("[P2P Client] Fehler bei Verbindung zu $host:$port: ${e.message}") println("[P2P Client] Konnte keine Verbindung zu $peerKey herstellen: ${ex.message}")
}
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) { override suspend fun broadcastEvent(event: SyncEvent) {
val text = Json.encodeToString(event) val text = Json.encodeToString(event)
activeSessions.toList().forEach { session -> val sessions = activeSessions.toList()
sessions.forEach { session ->
try { try {
if (session.isActive) {
session.send(Frame.Text(text)) session.send(Frame.Text(text))
} catch (e: Exception) { }
println("[P2P] Fehler beim Senden an Session: ${e.message}") } catch (_: Exception) {
// Session wird durch Heartbeat/Loop automatisch bereinigt
} }
} }
} }
private fun updatePeers() { private fun updatePeers() {
// Da wir keine einfachen IPs in den Sessions haben ohne tieferes Casting, _connectedPeers.value = activeSessions.map { session ->
// nutzen wir hier erst mal einen Platzhalter oder zählen nur. when (session) {
_connectedPeers.value = activeSessions.map { "Peer-${it.hashCode()}" } is DefaultWebSocketServerSession -> session.call.request.local.remoteAddress
else -> "Outgoing-Peer"
}
}.distinct()
} }
} }
@@ -1,9 +1,9 @@
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).
@@ -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
@@ -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") {
// 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() 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