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:
+12
-13
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
+10
-4
@@ -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 })
|
||||
|
||||
+69
-33
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -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).
|
||||
|
||||
+2
-1
@@ -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) {
|
||||
|
||||
-2
@@ -35,8 +35,6 @@ private fun PreviewContent() {
|
||||
// --- VEREIN ---
|
||||
|
||||
|
||||
|
||||
|
||||
// ── Hier den gewünschten Screen eintragen ──────────────────────
|
||||
// VeranstalterAuswahlScreen(onVeranstalterSelected = {}, onNeuerVeranstalter = {})
|
||||
// VeranstalterNeuScreen(onBack = {}, onSave = {})
|
||||
|
||||
+3
@@ -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()) }
|
||||
}
|
||||
|
||||
+29
-59
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+20
-31
@@ -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
|
||||
|
||||
+88
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user