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 @@
# =============================================================================
# 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
+59 -72
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)
Um native Pakete bauen zu können, müssen folgende Werkzeuge auf dem System vorhanden sein:
```bash
# Für RPM-Pakete (Fedora)
sudo dnf install rpm-build
# Für DEB-Pakete (Ubuntu/Debian)
sudo apt install dpkg-dev fakeroot
```
### Conveyor (Cross-Packaging Tool)
Conveyor wird benötigt, um von Linux aus Windows-Installer (.msi) oder macOS-Pakete zu erzeugen.
**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:**
```bash
# Entpacken
tar -xvf hydraulic-conveyor-*-linux-amd64.tar.gz
# In den Pfad verschieben
sudo mv conveyor /usr/local/bin/
```
3. **Verifizieren:**
```bash
conveyor --version
```
| 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 |
---
## 2. Lokale Linux-Builds (Gradle)
## 2. Cross-Packaging mit Conveyor
Die schnellste Methode, um während der Entwicklung ein installierbares Paket für das eigene System zu erstellen.
Conveyor ist so konfiguriert, dass es von Linux aus Pakete für alle Zielsysteme schnüren kann.
### 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
### Voraussetzungen
1. **JAR-Dateien:** Die App muss kompiliert sein:
```bash
./gradlew :frontend:shells:meldestelle-desktop:jvmJar
```
2. **Icons:** Das System sucht nach `icon.png` in `frontend/shells/meldestelle-desktop/src/jvmMain/resources/`.
### Pakete bauen
Führen Sie Conveyor im Projekt-Root aus:
### 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.
+14 -16
View File
@@ -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.
@@ -22,8 +22,10 @@ class JmDnsDiscoveryService : NetworkDiscoveryService {
private val registeredSet = ConcurrentHashMap.newKeySet<String>() // 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<List<DiscoveredService>>(emptyList())
override val discoveredServices: StateFlow<List<DiscoveredService>> = _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<InetAddress> { isPrivateIPv4(it) }
.thenBy { it.hostAddress })
@@ -2,33 +2,41 @@ 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<Int> = ConcurrentHashMap.newKeySet()
private const val RECONNECT_DELAY_MS = 3000L
private const val PING_INTERVAL_MS = 5000L
private const val PING_TIMEOUT_MS = 10000L
}
private var server: EmbeddedServer<*, *>? = null
private var currentPort: Int? = null
private val client = HttpClient {
install(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()
private val activeSessions = Collections.synchronizedSet(LinkedHashSet<DefaultWebSocketSession>())
@@ -36,27 +44,29 @@ class JvmP2pSyncService : P2pSyncService {
override val connectedPeers: Flow<List<String>> = _connectedPeers.asStateFlow()
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val connectionJobs = ConcurrentHashMap<String, Job>()
override fun startServer(port: Int) {
// Instanz-Guard (gleiche Instanz)
if (server != null) {
println("[P2P Server] Bereits gestartet (Instanz) auf Port ${currentPort ?: port} idempotent")
println("[P2P Server] Bereits aktiv auf Port ${currentPort ?: port}")
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")
println("[P2P Server] Port $port wird bereits von einer anderen Instanz genutzt.")
return
}
try {
server = embeddedServer(Netty, port = port) {
install(io.ktor.server.websocket.WebSockets)
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
}
routing {
webSocket("/sync") {
println("[P2P Server] Neuer Peer verbunden")
val remote = call.request.local.remoteAddress
println("[P2P Server] Neuer Peer verbunden: $remote")
activeSessions.add(this)
updatePeers()
try {
@@ -66,45 +76,56 @@ class JvmP2pSyncService : P2pSyncService {
try {
val event = Json.decodeFromString<SyncEvent>(text)
_incomingEvents.emit(event)
} catch (e: Exception) {
println("[P2P Server] Fehler beim Dekodieren: ${e.message}")
} catch (ex: Exception) {
println("[P2P Server] Fehler beim Dekodieren von $remote: ${ex.message}")
}
}
}
} catch (ex: Exception) {
println("[P2P Server] Verbindung zu $remote unterbrochen: ${ex.message}")
} finally {
activeSessions.remove(this)
updatePeers()
println("[P2P Server] Peer getrennt")
println("[P2P Server] Peer $remote getrennt")
}
}
}
}.start(wait = false)
currentPort = port
} catch (e: Exception) {
// Start fehlgeschlagen -> Port-Lock wieder freigeben
println("[P2P Server] Erfolgreich gestartet auf Port $port")
} catch (ex: Exception) {
startedPorts.remove(port)
server = null
currentPort = null
println("[P2P Server] Start auf Port $port fehlgeschlagen: ${e.message}")
throw e
println("[P2P Server] Fehler beim Starten des Servers auf Port $port: ${ex.message}")
throw ex
}
}
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) {
scope.launch {
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 $host:$port")
println("[P2P Client] Verbunden mit $peerKey")
activeSessions.add(this)
updatePeers()
try {
@@ -115,32 +136,47 @@ class JvmP2pSyncService : P2pSyncService {
_incomingEvents.emit(event)
}
}
} catch (ex: Exception) {
println("[P2P Client] Verbindung zu $peerKey abgebrochen: ${ex.message}")
} finally {
activeSessions.remove(this)
updatePeers()
println("[P2P Client] Verbindung zu $host:$port beendet")
println("[P2P Client] Session mit $peerKey beendet.")
}
}
} catch (e: Exception) {
println("[P2P Client] Fehler bei Verbindung zu $host:$port: ${e.message}")
} catch (ex: Exception) {
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) {
val text = Json.encodeToString(event)
activeSessions.toList().forEach { session ->
val sessions = activeSessions.toList()
sessions.forEach { session ->
try {
if (session.isActive) {
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() {
// 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()}" }
_connectedPeers.value = activeSessions.map { session ->
when (session) {
is DefaultWebSocketServerSession -> session.call.request.local.remoteAddress
else -> "Outgoing-Peer"
}
}.distinct()
}
}
@@ -1,9 +1,9 @@
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).
@@ -26,7 +26,8 @@ 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) {
@@ -35,8 +35,6 @@ private fun PreviewContent() {
// --- VEREIN ---
// ── Hier den gewünschten Screen eintragen ──────────────────────
// VeranstalterAuswahlScreen(onVeranstalterSelected = {}, onNeuerVeranstalter = {})
// 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.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<CurrentUserProvider> { DesktopCurrentUserProvider(get()) }
single { DeepLinkHandler(get(), get()) }
single<MasterdataRepository> { DesktopMasterdataRepository(get()) }
viewModel { ChatViewModel(get()) }
}
@@ -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<VeranstaltungRepository> { 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<DatabaseProvider>()
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<AppDatabase> { 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<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),
) {
Window(onCloseRequest = ::exitApplication, title = "Meldestelle Desktop") {
DesktopApp()
}
}
}
@@ -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<ChatMessage>() }
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
@@ -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
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