feat(core, network): lokale Chat-Kommunikation und WebSocket-Server hinzugefügt
Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
This commit is contained in:
+17
-21
@@ -1,32 +1,28 @@
|
|||||||
# ⚡ ACTIVE TASK: Event- & TurnierAnlage-Wizard Migration
|
# ⚡ ACTIVE TASK: Desktop App - Local Network Chat & Host/Client Setup
|
||||||
|
|
||||||
**Status:** 🏗️ In Arbeit
|
**Status:** 🏗️ In Arbeit
|
||||||
**SCS:** Event Management / Desktop App
|
**SCS:** Desktop App / Infrastructure
|
||||||
**Branch:** `feature/turnier-anlage-wizard`
|
**Branch:** `feature/desktop-network-chat` (neuer Branch, erstellt ausgehend von `feature/turnier-anlage-wizard`)
|
||||||
|
|
||||||
## 🎯 Aktuelles Ziel
|
## 🎯 Aktuelles Ziel
|
||||||
1. **Event-Wizard Migration:** Migration des Veranstaltungs-Wizards auf den deklarativen Orchestrator (ADR-0025) abgeschlossen. ✓
|
1. **Netzwerk-Kommunikation (Chat POC):** Implementierung einer simplen Chat-Funktion für die Desktop-App, die im lokalen Netzwerk funktioniert (Verbindungstest).
|
||||||
2. **TurnierAnlage:** Implementierung des Wizards zur Anlage von Turnieren, Bewerben und Abteilungen nach ÖTO-Regeln in der Desktop-App.
|
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. **ÖTO-Validierung:** Integration der Abteilungs-Trennungs-Regeln (§ 39) als Warn-Logik im Wizard.
|
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.
|
||||||
|
|
||||||
## 🛠️ Letzte Änderungen
|
## 🛠️ Letzte Änderungen
|
||||||
- Event-Wizard: `EventFlowSample.kt` erfolgreich nach `EventWizardFlow.kt` migriert, umbenannt und um ÖTO-Schritte erweitert. ✓
|
- Fokus auf Netzwerk- & Offline-Fähigkeiten gelegt. Turnier-Anlage-Wizard pausiert.
|
||||||
- Wissens-Sicherung Plan-B: Caddy & Pangolin Runbook vervollständigt (MIME, COOP/COEP, SMTP-Härtung). ✓
|
- Neuer Branch `feature/desktop-network-chat` für die anstehenden Arbeiten.
|
||||||
- CI/CD: Gitea-Action für automatisierte Docker-Builds bei Git-Tags (`v*`) aktiviert. ✓
|
|
||||||
- TurnierAnlage: `TurnierAnlageFlow.kt` Skelett erstellt. ✓
|
|
||||||
|
|
||||||
## 📍 Fokus-Dateien
|
## 📍 Fokus-Bereiche
|
||||||
- `frontend/features/veranstaltung-feature/src/commonMain/kotlin/at/mocode/veranstaltung/feature/wizard/EventWizardFlow.kt`
|
- Lokale Netzwerk-Discovery (z.B. Ktor, UDP Broadcast, mDNS).
|
||||||
- `frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/frontend/features/turnier/wizard/TurnierAnlageFlow.kt`
|
- P2P oder Client-Server Chat-Kommunikation im lokalen Netzwerk für den Verbindungs-Check.
|
||||||
- `docs/03_Domain/02_Reference/OETO_Regelwerk/Abteilungs-Trennungs-Schwellenwerte.md`
|
- KMP Desktop-Modul.
|
||||||
- `frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/frontend/features/turnier/presentation/CreateBewerbWizardScreen.kt`
|
|
||||||
|
|
||||||
## 🚧 Offene Punkte / Blocker
|
## 🚧 Offene Punkte / Blocker
|
||||||
- [ ] Erstellung der Compose-Screens für `TurnierBasisdatenStep`.
|
- [ ] Konzept für Host/Client-Discovery im lokalen Netz umsetzen.
|
||||||
- [ ] Erstellung der Compose-Screens für `TurnierKategorieStep`.
|
- [ ] Implementierung eines lokalen Chat-Moduls in der Desktop-App (Linux/Desktop-Test).
|
||||||
- [ ] Implementierung der ÖTO-Check Logik für Abteilungen.
|
- [ ] Erfolgreicher Conveyor Build für Windows & Linux (Später).
|
||||||
- [ ] Sync-Logik zum Backend für die Web-Generierung vorbereiten.
|
|
||||||
|
|
||||||
## 🔄 Nächste Schritte
|
## 🔄 Nächste Schritte
|
||||||
- [ ] Implementierung von `TurnierBasisdatenScreen` (Compose Desktop).
|
- [ ] Architektur-Entscheidung (ADR) für lokale Netzwerk-Discovery und Kommunikation treffen (Ktor Sockets, UDP, etc.).
|
||||||
- [ ] Verknüpfung des `TurnierAnlageFlow` mit dem UI-Orchestrator.
|
- [ ] Erste Implementierung des Discovery-Mechanismus.
|
||||||
|
|||||||
+2
-1
@@ -1,5 +1,6 @@
|
|||||||
package at.mocode.frontend.core.network
|
package at.mocode.frontend.core.network
|
||||||
|
|
||||||
|
import at.mocode.frontend.core.network.chat.chatModule
|
||||||
import at.mocode.frontend.core.network.discovery.discoveryModule
|
import at.mocode.frontend.core.network.discovery.discoveryModule
|
||||||
import at.mocode.frontend.core.network.sync.syncModule
|
import at.mocode.frontend.core.network.sync.syncModule
|
||||||
import io.ktor.client.*
|
import io.ktor.client.*
|
||||||
@@ -26,7 +27,7 @@ interface TokenProvider {
|
|||||||
* - "apiClient": Konfigurierter Client für das API-Gateway (Auth-Header, Retry, Timeout)
|
* - "apiClient": Konfigurierter Client für das API-Gateway (Auth-Header, Retry, Timeout)
|
||||||
*/
|
*/
|
||||||
val networkModule: Module = module {
|
val networkModule: Module = module {
|
||||||
includes(discoveryModule, syncModule)
|
includes(discoveryModule, syncModule, chatModule)
|
||||||
|
|
||||||
single<ConnectivityTracker> { ConnectivityTracker() }
|
single<ConnectivityTracker> { ConnectivityTracker() }
|
||||||
|
|
||||||
|
|||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
package at.mocode.frontend.core.network.chat
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Einfaches Chat-Message Modell für lokale Host/Client-Kommunikation.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class ChatMessage(
|
||||||
|
val sender: String,
|
||||||
|
val message: String,
|
||||||
|
val timestamp: Long
|
||||||
|
)
|
||||||
+8
@@ -0,0 +1,8 @@
|
|||||||
|
package at.mocode.frontend.core.network.chat
|
||||||
|
|
||||||
|
import org.koin.core.module.Module
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erwartetes Koin-Modul für Chat/WS-Server.
|
||||||
|
*/
|
||||||
|
expect val chatModule: Module
|
||||||
+3
-1
@@ -9,6 +9,8 @@ data class DiscoveredService(
|
|||||||
val name: String,
|
val name: String,
|
||||||
val host: String,
|
val host: String,
|
||||||
val port: Int,
|
val port: Int,
|
||||||
|
/** Optional: expliziter WebSocket-Port, falls vom Haupt-Port abweichend. */
|
||||||
|
val websocketPort: Int? = null,
|
||||||
val metadata: Map<String, String> = emptyMap()
|
val metadata: Map<String, String> = emptyMap()
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -36,7 +38,7 @@ interface NetworkDiscoveryService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Registriert den eigenen Dienst, damit andere Instanzen ihn finden können.
|
* Registriert den eigenen Dienst, damit andere Instanzen ihn finden können.
|
||||||
* @param port Der Port, auf dem der lokale WebSocket-Server lauscht.
|
* @param port Der Haupt-Port des Dienstes (z. B. HTTP/API). Der WebSocket-Port wird zusätzlich als Metadatum veröffentlicht.
|
||||||
* @param preferredIp Optional eine IP-Adresse, an die der Discovery-Dienst gebunden werden soll.
|
* @param preferredIp Optional eine IP-Adresse, an die der Discovery-Dienst gebunden werden soll.
|
||||||
* @param deviceName Der Name des Geräts, das im Netzwerk angezeigt werden soll.
|
* @param deviceName Der Name des Geräts, das im Netzwerk angezeigt werden soll.
|
||||||
*/
|
*/
|
||||||
|
|||||||
+9
@@ -0,0 +1,9 @@
|
|||||||
|
package at.mocode.frontend.core.network.chat
|
||||||
|
|
||||||
|
import org.koin.core.module.Module
|
||||||
|
import org.koin.dsl.module
|
||||||
|
|
||||||
|
actual val chatModule: Module = module {
|
||||||
|
// Ktor WebSocket Server (lokaler Host)
|
||||||
|
single { KtorWebSocketServerService() }
|
||||||
|
}
|
||||||
+105
@@ -0,0 +1,105 @@
|
|||||||
|
package at.mocode.frontend.core.network.chat
|
||||||
|
|
||||||
|
import io.ktor.serialization.kotlinx.json.*
|
||||||
|
import io.ktor.server.application.*
|
||||||
|
import io.ktor.server.engine.*
|
||||||
|
import io.ktor.server.netty.*
|
||||||
|
import io.ktor.server.plugins.contentnegotiation.*
|
||||||
|
import io.ktor.server.routing.*
|
||||||
|
import io.ktor.server.websocket.*
|
||||||
|
import io.ktor.websocket.*
|
||||||
|
import kotlinx.coroutines.channels.ClosedReceiveChannelException
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Einfacher Ktor WebSocket Server für lokale Chat-Kommunikation.
|
||||||
|
*/
|
||||||
|
class KtorWebSocketServerService(
|
||||||
|
private val port: Int = DEFAULT_PORT
|
||||||
|
) {
|
||||||
|
|
||||||
|
private var server: EmbeddedServer<NettyApplicationEngine, NettyApplicationEngine.Configuration>? = null
|
||||||
|
|
||||||
|
private val connections = mutableSetOf<DefaultWebSocketServerSession>()
|
||||||
|
private val lock = Mutex()
|
||||||
|
|
||||||
|
fun start() {
|
||||||
|
if (server != null) return
|
||||||
|
|
||||||
|
val engine = embeddedServer(Netty, port = port, host = "0.0.0.0") {
|
||||||
|
install(WebSockets)
|
||||||
|
install(ContentNegotiation) {
|
||||||
|
json(Json)
|
||||||
|
}
|
||||||
|
|
||||||
|
routing {
|
||||||
|
webSocket("/chat") {
|
||||||
|
// Verbindung merken
|
||||||
|
lock.withLock { connections.add(this) }
|
||||||
|
try {
|
||||||
|
for (frame in incoming) {
|
||||||
|
when (frame) {
|
||||||
|
is Frame.Text -> {
|
||||||
|
val text = frame.readText()
|
||||||
|
// JSON -> ChatMessage
|
||||||
|
val msg = try {
|
||||||
|
Json.decodeFromString(ChatMessage.serializer(), text)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
application.log.warn("[WS] Ungültige Nachricht: ${e.message}")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Broadcast an alle Clients
|
||||||
|
broadcast(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
is Frame.Binary -> {
|
||||||
|
// Ignorieren oder in Zukunft unterstützen
|
||||||
|
}
|
||||||
|
|
||||||
|
is Frame.Ping, is Frame.Pong, is Frame.Close -> {
|
||||||
|
// nichts
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_: ClosedReceiveChannelException) {
|
||||||
|
// Verbindung wurde geschlossen
|
||||||
|
} catch (e: Exception) {
|
||||||
|
application.log.error("[WS] Fehler in Session: ${e.message}", e)
|
||||||
|
} finally {
|
||||||
|
lock.withLock { connections.remove(this) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
engine.start(wait = false)
|
||||||
|
server = engine
|
||||||
|
println("[WS] Ktor WebSocket Server gestartet auf Port $port (Pfad: /chat)")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop() {
|
||||||
|
server?.stop(gracePeriodMillis = 1000, timeoutMillis = 3000)
|
||||||
|
server = null
|
||||||
|
println("[WS] Ktor WebSocket Server gestoppt")
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun broadcast(message: ChatMessage) {
|
||||||
|
val json = Json.encodeToString(ChatMessage.serializer(), message)
|
||||||
|
val snapshot: List<DefaultWebSocketServerSession> = lock.withLock { connections.toList() }
|
||||||
|
snapshot.forEach { session ->
|
||||||
|
try {
|
||||||
|
session.send(Frame.Text(json))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Fehler beim Senden ignorieren; Verbindung wird beim nächsten Empfang entfernt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getPort(): Int = port
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val DEFAULT_PORT: Int = 8081
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
-2
@@ -51,11 +51,14 @@ class JmDnsDiscoveryService : NetworkDiscoveryService {
|
|||||||
|
|
||||||
override fun serviceResolved(event: ServiceEvent) {
|
override fun serviceResolved(event: ServiceEvent) {
|
||||||
val info = event.info
|
val info = event.info
|
||||||
|
val md = info.propertyNames.asSequence().associateWith { info.getPropertyString(it) }
|
||||||
|
val wsPort = md["websocketPort"]?.toIntOrNull()
|
||||||
val service = DiscoveredService(
|
val service = DiscoveredService(
|
||||||
name = event.name,
|
name = event.name,
|
||||||
host = info.inetAddresses.firstOrNull()?.hostAddress ?: "unknown",
|
host = info.inetAddresses.firstOrNull()?.hostAddress ?: "unknown",
|
||||||
port = info.port,
|
port = info.port,
|
||||||
metadata = info.propertyNames.asSequence().associateWith { info.getPropertyString(it) }
|
websocketPort = wsPort,
|
||||||
|
metadata = md
|
||||||
)
|
)
|
||||||
discoveredServicesMap[event.name] = service
|
discoveredServicesMap[event.name] = service
|
||||||
_discoveredServices.value = discoveredServicesMap.values.toList()
|
_discoveredServices.value = discoveredServicesMap.values.toList()
|
||||||
@@ -103,7 +106,9 @@ class JmDnsDiscoveryService : NetworkDiscoveryService {
|
|||||||
mapOf(
|
mapOf(
|
||||||
"version" to "1.0.0",
|
"version" to "1.0.0",
|
||||||
"type" to "master",
|
"type" to "master",
|
||||||
"nodeId" to name
|
"nodeId" to name,
|
||||||
|
// Der Ktor WebSocket-Server lauscht (derzeit) auf demselben Port; kann abweichen
|
||||||
|
"websocketPort" to port.toString()
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
try {
|
try {
|
||||||
|
|||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
package at.mocode.frontend.core.network.chat
|
||||||
|
|
||||||
|
import org.koin.core.module.Module
|
||||||
|
import org.koin.dsl.module
|
||||||
|
|
||||||
|
// Auf WASM/JS gibt es keinen lokalen Ktor-Server; bereiten ein leeres Modul vor.
|
||||||
|
actual val chatModule: Module = module { }
|
||||||
Reference in New Issue
Block a user