diff --git a/docs/ACTIVE_TASK.md b/docs/ACTIVE_TASK.md index f343ddb0..484f9eef 100644 --- a/docs/ACTIVE_TASK.md +++ b/docs/ACTIVE_TASK.md @@ -1,32 +1,28 @@ -# ⚡ ACTIVE TASK: Event- & TurnierAnlage-Wizard Migration +# ⚡ ACTIVE TASK: Desktop App - Local Network Chat & Host/Client Setup **Status:** 🏗️ In Arbeit -**SCS:** Event Management / Desktop App -**Branch:** `feature/turnier-anlage-wizard` +**SCS:** Desktop App / Infrastructure +**Branch:** `feature/desktop-network-chat` (neuer Branch, erstellt ausgehend von `feature/turnier-anlage-wizard`) ## 🎯 Aktuelles Ziel -1. **Event-Wizard Migration:** Migration des Veranstaltungs-Wizards auf den deklarativen Orchestrator (ADR-0025) abgeschlossen. ✓ -2. **TurnierAnlage:** Implementierung des Wizards zur Anlage von Turnieren, Bewerben und Abteilungen nach ÖTO-Regeln in der Desktop-App. -3. **ÖTO-Validierung:** Integration der Abteilungs-Trennungs-Regeln (§ 39) als Warn-Logik im Wizard. +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. ## 🛠️ Letzte Änderungen -- Event-Wizard: `EventFlowSample.kt` erfolgreich nach `EventWizardFlow.kt` migriert, umbenannt und um ÖTO-Schritte erweitert. ✓ -- Wissens-Sicherung Plan-B: Caddy & Pangolin Runbook vervollständigt (MIME, COOP/COEP, SMTP-Härtung). ✓ -- CI/CD: Gitea-Action für automatisierte Docker-Builds bei Git-Tags (`v*`) aktiviert. ✓ -- TurnierAnlage: `TurnierAnlageFlow.kt` Skelett erstellt. ✓ +- Fokus auf Netzwerk- & Offline-Fähigkeiten gelegt. Turnier-Anlage-Wizard pausiert. +- Neuer Branch `feature/desktop-network-chat` für die anstehenden Arbeiten. -## 📍 Fokus-Dateien -- `frontend/features/veranstaltung-feature/src/commonMain/kotlin/at/mocode/veranstaltung/feature/wizard/EventWizardFlow.kt` -- `frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/frontend/features/turnier/wizard/TurnierAnlageFlow.kt` -- `docs/03_Domain/02_Reference/OETO_Regelwerk/Abteilungs-Trennungs-Schwellenwerte.md` -- `frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/frontend/features/turnier/presentation/CreateBewerbWizardScreen.kt` +## 📍 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 -- [ ] Erstellung der Compose-Screens für `TurnierBasisdatenStep`. -- [ ] Erstellung der Compose-Screens für `TurnierKategorieStep`. -- [ ] Implementierung der ÖTO-Check Logik für Abteilungen. -- [ ] Sync-Logik zum Backend für die Web-Generierung vorbereiten. +- [ ] 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 -- [ ] Implementierung von `TurnierBasisdatenScreen` (Compose Desktop). -- [ ] Verknüpfung des `TurnierAnlageFlow` mit dem UI-Orchestrator. +- [ ] Architektur-Entscheidung (ADR) für lokale Netzwerk-Discovery und Kommunikation treffen (Ktor Sockets, UDP, etc.). +- [ ] Erste Implementierung des Discovery-Mechanismus. diff --git a/frontend/core/network/src/commonMain/kotlin/at/mocode/frontend/core/network/NetworkModule.kt b/frontend/core/network/src/commonMain/kotlin/at/mocode/frontend/core/network/NetworkModule.kt index 5028cb17..60aa683a 100644 --- a/frontend/core/network/src/commonMain/kotlin/at/mocode/frontend/core/network/NetworkModule.kt +++ b/frontend/core/network/src/commonMain/kotlin/at/mocode/frontend/core/network/NetworkModule.kt @@ -1,5 +1,6 @@ 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.sync.syncModule import io.ktor.client.* @@ -26,7 +27,7 @@ interface TokenProvider { * - "apiClient": Konfigurierter Client für das API-Gateway (Auth-Header, Retry, Timeout) */ val networkModule: Module = module { - includes(discoveryModule, syncModule) + includes(discoveryModule, syncModule, chatModule) single { ConnectivityTracker() } diff --git a/frontend/core/network/src/commonMain/kotlin/at/mocode/frontend/core/network/chat/ChatMessage.kt b/frontend/core/network/src/commonMain/kotlin/at/mocode/frontend/core/network/chat/ChatMessage.kt new file mode 100644 index 00000000..34ce7777 --- /dev/null +++ b/frontend/core/network/src/commonMain/kotlin/at/mocode/frontend/core/network/chat/ChatMessage.kt @@ -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 +) diff --git a/frontend/core/network/src/commonMain/kotlin/at/mocode/frontend/core/network/chat/ChatModule.kt b/frontend/core/network/src/commonMain/kotlin/at/mocode/frontend/core/network/chat/ChatModule.kt new file mode 100644 index 00000000..9d19fe15 --- /dev/null +++ b/frontend/core/network/src/commonMain/kotlin/at/mocode/frontend/core/network/chat/ChatModule.kt @@ -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 diff --git a/frontend/core/network/src/commonMain/kotlin/at/mocode/frontend/core/network/discovery/NetworkDiscoveryService.kt b/frontend/core/network/src/commonMain/kotlin/at/mocode/frontend/core/network/discovery/NetworkDiscoveryService.kt index be29b117..fd7d8ed1 100644 --- a/frontend/core/network/src/commonMain/kotlin/at/mocode/frontend/core/network/discovery/NetworkDiscoveryService.kt +++ b/frontend/core/network/src/commonMain/kotlin/at/mocode/frontend/core/network/discovery/NetworkDiscoveryService.kt @@ -9,6 +9,8 @@ data class DiscoveredService( val name: String, val host: String, val port: Int, + /** Optional: expliziter WebSocket-Port, falls vom Haupt-Port abweichend. */ + val websocketPort: Int? = null, val metadata: Map = emptyMap() ) @@ -36,7 +38,7 @@ interface NetworkDiscoveryService { /** * 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 deviceName Der Name des Geräts, das im Netzwerk angezeigt werden soll. */ diff --git a/frontend/core/network/src/jvmMain/kotlin/at/mocode/frontend/core/network/chat/ChatModule.kt b/frontend/core/network/src/jvmMain/kotlin/at/mocode/frontend/core/network/chat/ChatModule.kt new file mode 100644 index 00000000..68857961 --- /dev/null +++ b/frontend/core/network/src/jvmMain/kotlin/at/mocode/frontend/core/network/chat/ChatModule.kt @@ -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() } +} diff --git a/frontend/core/network/src/jvmMain/kotlin/at/mocode/frontend/core/network/chat/KtorWebSocketServerService.kt b/frontend/core/network/src/jvmMain/kotlin/at/mocode/frontend/core/network/chat/KtorWebSocketServerService.kt new file mode 100644 index 00000000..86ccde13 --- /dev/null +++ b/frontend/core/network/src/jvmMain/kotlin/at/mocode/frontend/core/network/chat/KtorWebSocketServerService.kt @@ -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? = null + + private val connections = mutableSetOf() + 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 = 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 + } +} 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 56307376..7380f512 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 @@ -51,11 +51,14 @@ class JmDnsDiscoveryService : NetworkDiscoveryService { override fun serviceResolved(event: ServiceEvent) { val info = event.info + val md = info.propertyNames.asSequence().associateWith { info.getPropertyString(it) } + val wsPort = md["websocketPort"]?.toIntOrNull() val service = DiscoveredService( name = event.name, host = info.inetAddresses.firstOrNull()?.hostAddress ?: "unknown", port = info.port, - metadata = info.propertyNames.asSequence().associateWith { info.getPropertyString(it) } + websocketPort = wsPort, + metadata = md ) discoveredServicesMap[event.name] = service _discoveredServices.value = discoveredServicesMap.values.toList() @@ -103,7 +106,9 @@ class JmDnsDiscoveryService : NetworkDiscoveryService { mapOf( "version" to "1.0.0", "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 { diff --git a/frontend/core/network/src/wasmJsMain/kotlin/at/mocode/frontend/core/network/chat/ChatModule.kt b/frontend/core/network/src/wasmJsMain/kotlin/at/mocode/frontend/core/network/chat/ChatModule.kt new file mode 100644 index 00000000..26ab7421 --- /dev/null +++ b/frontend/core/network/src/wasmJsMain/kotlin/at/mocode/frontend/core/network/chat/ChatModule.kt @@ -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 { }