4 Commits

20 changed files with 575 additions and 83 deletions
+2 -1
View File
@@ -40,7 +40,8 @@ app {
jvm-options = [ jvm-options = [
"-Xms128m", "-Xms128m",
"-Xmx512m", "-Xmx512m",
"-Dfile.encoding=UTF-8" "-Dfile.encoding=UTF-8",
"--enable-native-access=ALL-UNNAMED"
] ]
} }
@@ -0,0 +1,37 @@
---
type: Journal
status: ACTIVE
owner: Curator
last_update: 2026-05-07
---
# 2026-05-07 — Session Log (Frontend Networking, Discovery, Connectivity)
## Kontext
- Fokus: Stabilisierung der lokalen Host/ClientKommunikation (mDNS, WSChat), robuste ConnectivityChecks, UX für BackupPfad, SessionAbschluss mit Dokumentation.
## Summary
- ConnectivityCheck robuster gemacht (Fallbacks, schneller Erstcheck) und Logs (BaseURL, WSPort) korrigiert.
- Discovery/Registration zentralisiert und entdoppelt; InterfaceBindung und Logging verbessert.
- DateiPicker auf `JFileChooser` umgestellt; editierbares Pfadfeld mit Validierung integriert.
- Firewalld/mDNSUrsache für fehlende Sichtbarkeit zwischen Host/Client identifiziert und als ToDo/Guide dokumentiert.
## Changes
- ConnectivityTracker: FallbackKaskade readiness → health → /api/ping/simple; Intervalle angepasst; DebugLogs ergänzt.
- main.kt: korrekte StringInterpolation; StartLog der `NetworkConfig.baseUrl`; WSPort 8090 konsistent.
- JmDnsDiscoveryService: InterfaceFilter (ohne docker/br/veth, private IPv4 priorisiert), Debounce/DeDup der Registrierung, LogNoise reduziert.
- Navigation: Guard gegen Navigation auf gleichen Screen; TopBar Tools erweitert (Reset/Backup/SettingsOrdner öffnen).
- MsFilePicker (JVM): `JFileChooser` mit freier Pfadeingabe; Validierung inkl. SchreibProbe; automatische Ordnererstellung bei Auswahl.
- conveyor.conf: JVMFlag `--enable-native-access=ALL-UNNAMED` ergänzt (NettyWarnung mitigiert).
## Verification
- Build (Gradle): erfolgreich ✓
- Laufzeit/Netzwerk: Verifikation ausstehend (mDNS nach FirewallFreigaben; KDEPicker unter Fedora 44; Host/ClientSichtbarkeit LAN/WLAN) — AntiHalluzinationsProtokoll beachtet.
## Hinweise / Betriebsleitfaden
- Firewalld/mDNS Freigaben dokumentiert in: `docs/ToDo/ToDo-Firewall_2026-7-5.md` (mdns + Ports 8090/8080; Reload/Kontrolle; Avahi/Tcpdump Checks).
## Nächste Schritte
1. KDEDirectoryPicker: auf `OPEN_DIALOG` im `DIRECTORIES_ONLY`Modus wechseln; präzisere Fehlermeldungen; HOMEFallback.
2. Guard gegen mehrfachen P2PStart ergänzen.
3. Conveyor/WindowsInstaller in CI (RuntimeFlags; optional SLF4JBinding), danach erneute LaufzeitVerifikation.
+17 -21
View File
@@ -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.
+72
View File
@@ -0,0 +1,72 @@
🎨 [Frontend Expert]
### Kurzfazit
- Die beiden Rechner sehen sich über mDNS höchstwahrscheinlich wegen FirewallRegeln/Zonen nicht. JmDNS registriert korrekt auf 192.168.0.124:8090, aber der Client (192.168.0.167, WLAN) empfängt keine mDNSAntworten → Fedora/Firewalld blockiert UDP 5353 Multicast in der aktiven Zone (WLAN/Public) sehr häufig standardmäßig.
- Der DateiPicker unter Fedora KDE hakt im VerzeichnisModus vermutlich wegen der Kombination DIRECTORIES_ONLY + SAVE_DIALOG und/oder wegen unserer „schreibbar“-Validierung.
### Sofortmaßnahmen (auf beiden Geräten, Host + Client)
1) Firewalld freischalten (mdns + Ports) und neu laden
```
sudo firewall-cmd --get-active-zones
# notieren, in welcher Zone das aktive Interface liegt (z. B. public, home)
ZONE=public # ggf. anpassen
# mDNS erlauben (UDP 5353 Multicast)
sudo firewall-cmd --zone=$ZONE --add-service=mdns --permanent
# unsere Dienste öffnen
sudo firewall-cmd --zone=$ZONE --add-port=8090/tcp --permanent # WS-Chat
sudo firewall-cmd --zone=$ZONE --add-port=8080/tcp --permanent # P2P-Server (PoC)
# Regeln anwenden
sudo firewall-cmd --reload
# Kontrolle
sudo firewall-cmd --zone=$ZONE --list-all
```
Hinweis: WLAN und Ethernet können in unterschiedlichen Zonen liegen (z. B. public vs. home). Bitte in beiden relevanten Zonen freischalten.
2) Optional: Avahi als Indikator prüfen (nicht zwingend für JmDNS, aber gut für mDNSBaseline)
```
systemctl status avahi-daemon
```
3) mDNSPakete verifizieren (nur wenn es nach 1) noch nicht klappt)
```
# Auf dem Client (WLANInterface anpassen):
sudo tcpdump -n -i wlp0s20f3 port 5353
# Auf dem Host (EthernetInterface anpassen):
sudo tcpdump -n -i <eth-iface> port 5353
```
Erwartung: Bei AppStart siehst du Multicast an 224.0.0.251. Wenn auf einer Seite „Stille“ herrscht, blockiert noch etwas.
4) WSKonnektivität direkt testen (um Networking unabhängig von mDNS zu prüfen)
```
# vom Client auf den Host
websocat ws://192.168.0.124:8090/chat
# oder wscat: wscat -c ws://192.168.0.124:8090/chat
```
Wenn das klappt, ist der WSPfad ok; dann bleibt nur mDNS/Firewall als Ursache.
### Erklärung zu deinen Logs
- „[Discovery] Dienst 'fedora-stefan' … (Port 8090)“ → Host hat korrekt via mDNS veröffentlicht.
- „Discovery Update: 0 Dienste gefunden“ am Client → keine mDNSAntworten angekommen (typisches FirewallSymptom bei Fedora/KDE, vor allem in Zone „public“).
- Mehrfach „[P2P Server] Gestartet auf Port 8080“ → Wir haben noch einen Trigger, der den P2PStart mehrfach feuert. Funktional unkritisch für den aktuellen Befund; ich bereinige das im nächsten Patch mit einem zusätzlichen Guard.
### Fedora KDE DateiPicker (JFileChooser) Workarounds jetzt, Fix als Patch im Anschluss
- Mögliche Ursache 1: `SAVE_DIALOG` im OrdnerModus unter KDE verhält sich zickig. Besser: `OPEN_DIALOG` im `DIRECTORIES_ONLY`Modus + eigener BestätigungsButtonText.
- Mögliche Ursache 2: Unsere Validierung „Ordner existiert und ist beschreibbar“ verhindert die Übernahme (z. B. externer Stick nur lesbar, NTFS ohne Schreibrechte, Netzwerkpfad ohne Rechte). Teste bitte einmal mit einem lokalen Pfad wie `~/MeldestelleBackup`.
- QuickTest jetzt: Tippe im editierbaren Feld manuell `~/MeldestelleBackup`, bestätige → sollte grün werden. Falls rot: bitte den Text der Fehlermeldung nennen.
Geplanter Fix (nach deiner Bestätigung):
- DirectoryPicker: Wechsel auf `showOpenDialog(null)` mit `JFileChooser.DIRECTORIES_ONLY`, `approveButtonText = "Auswählen"`, `acceptAllFileFilterUsed=false`. Fallback auf HOME, wenn `currentDirectory` ungültig. Beibehalt der freien Texteingabe.
- Validierung: Fehlermeldung präziser („existiert nicht“ vs. „keine Schreibrechte“) und angebotene AutoErstellung, wenn der User bestätigt.
### Bitte liefere kurz zurück
- Nach Schritt 1 (firewalld): Finden sich Host (192.168.0.124) und Client (192.168.0.167) gegenseitig? Tauchen Services in der UI/Logs auf?
- Falls noch nicht: kurzer Ausschnitt aus `tcpdump` beider Seiten (je 35 Zeilen genügen).
- DateiPicker auf dem Client: Was passiert bei manuellem Pfad `~/MeldestelleBackup`? Erscheint eine Fehlermeldung? Wenn ja, welcher Text?
### Nächste Schritte (nach Feedback)
- Ich liefere: Patch für den KDEPicker (OPEN_DIALOG) und einen zusätzlichen Guard gegen mehrfachen P2PStart; außerdem noch etwas DiscoveryLogging (Interface/ZonenHinweis).
- Danach kümmern wir uns um Conveyor (WindowsInstaller aus CI, inkl. JVMFlag gegen die NettyWarnung).
@@ -8,9 +8,12 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import java.awt.FileDialog
import java.awt.Frame
import java.io.File import java.io.File
import java.nio.file.Files
import java.nio.file.Path
import javax.swing.JFileChooser
import javax.swing.SwingUtilities
import javax.swing.filechooser.FileNameExtensionFilter
@Composable @Composable
actual fun MsFilePicker( actual fun MsFilePicker(
@@ -23,17 +26,35 @@ actual fun MsFilePicker(
enabled: Boolean, enabled: Boolean,
modifier: Modifier modifier: Modifier
) { ) {
val currentValue = selectedPath ?: ""
val (isError, errorMessage) = run {
if (!enabled) false to null
else if (currentValue.isBlank()) false to null
else {
val f = File(currentValue)
if (directoryOnly) {
val ok = f.exists() && f.isDirectory && f.canWrite()
(!ok) to if (!ok) "Ordner existiert nicht oder ist nicht beschreibbar" else null
} else {
val ok = (f.exists() && f.isFile && f.canWrite()) || (f.parentFile?.canWrite() == true)
(!ok) to if (!ok) "Datei/Ordner nicht beschreibbar" else null
}
}
}
Row( Row(
modifier = modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
MsTextField( MsTextField(
value = selectedPath ?: "", value = currentValue,
onValueChange = { }, onValueChange = { newValue -> onFileSelected(newValue) },
readOnly = true, readOnly = false,
label = label, label = label,
helpDescription = helpDescription, helpDescription = helpDescription,
placeholder = if (directoryOnly) "Verzeichnis wählen..." else "Datei wählen...", placeholder = if (directoryOnly) "Verzeichnis wählen..." else "Datei wählen...",
isError = isError,
errorMessage = errorMessage,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
enabled = enabled, enabled = enabled,
compact = true compact = true
@@ -43,39 +64,48 @@ actual fun MsFilePicker(
MsButton( MsButton(
onClick = { onClick = {
// Einheitlich plattformübergreifend: Swing JFileChooser verwenden
SwingUtilities.invokeLater {
val chooser = JFileChooser().apply {
isMultiSelectionEnabled = false
// Initiales Verzeichnis/Pfad
selectedPath?.let { p ->
val f = File(p)
currentDirectory = when {
f.isDirectory -> f
f.parentFile?.isDirectory == true -> f.parentFile
else -> currentDirectory
}
if (!directoryOnly && f.isFile) selectedFile = f
}
if (directoryOnly) { if (directoryOnly) {
// AWT FileDialog für nativen Look auch bei Verzeichnissen (Windows/Linux/macOS) fileSelectionMode = JFileChooser.DIRECTORIES_ONLY
// unter macOS erzwingt dies die Verzeichnisauswahl. Unter Windows/Linux ist es der Standard-Dialog. dialogType = JFileChooser.SAVE_DIALOG // ermöglicht "Neuer Ordner"
System.setProperty("apple.awt.fileDialogForDirectories", "true")
val dialog = FileDialog(null as Frame?, label, FileDialog.LOAD).apply {
selectedPath?.let {
val currentDir = File(it)
if (currentDir.exists()) {
directory = currentDir.absolutePath
}
}
}
dialog.isVisible = true
if (dialog.directory != null && dialog.file != null) {
// Bei FileDialog.LOAD unter Windows/Linux wählt man oft eine Datei im Ordner,
// aber wir wollen den Ordner. Wir nehmen also das Verzeichnis.
onFileSelected(File(dialog.directory, dialog.file).parentFile.absolutePath)
} else if (dialog.directory != null) {
onFileSelected(dialog.directory)
}
System.setProperty("apple.awt.fileDialogForDirectories", "false")
} else { } else {
// AWT FileDialog für nativen Look bei Dateiauswahl (wie vom User gewünscht) fileSelectionMode = JFileChooser.FILES_ONLY
val dialog = FileDialog(null as Frame?, label, FileDialog.LOAD).apply {
if (fileExtensions.isNotEmpty()) { if (fileExtensions.isNotEmpty()) {
setFilenameFilter { _, name -> fileFilter = FileNameExtensionFilter(
fileExtensions.any { name.lowercase().endsWith(it.lowercase()) } "Erlaubte Dateien",
*fileExtensions.map { it.trimStart('.') }.toTypedArray()
)
} }
} }
} }
dialog.isVisible = true
if (dialog.file != null) { val result = chooser.showDialog(null, "Auswählen")
onFileSelected(File(dialog.directory, dialog.file).absolutePath) if (result == JFileChooser.APPROVE_OPTION) {
val chosen = chooser.selectedFile
if (directoryOnly) {
if (!chosen.exists()) {
try {
Files.createDirectories(Path.of(chosen.absolutePath))
} catch (_: Exception) { /* ignorieren, Validierung zeigt Fehler */ }
}
onFileSelected(chosen.absolutePath)
} else {
onFileSelected(chosen.absolutePath)
}
} }
} }
}, },
@@ -13,6 +13,11 @@ import kotlin.time.Duration.Companion.milliseconds
/** /**
* Überwacht die Konnektivität zum API-Gateway. * Überwacht die Konnektivität zum API-Gateway.
*
* Robustere Strategie:
* 1) /actuator/health/readiness
* 2) /actuator/health (Fallback)
* 3) /api/ping/simple (Fallback)
*/ */
class ConnectivityTracker : KoinComponent { class ConnectivityTracker : KoinComponent {
private val client: HttpClient by inject(named("baseHttpClient")) private val client: HttpClient by inject(named("baseHttpClient"))
@@ -24,20 +29,47 @@ class ConnectivityTracker : KoinComponent {
fun startTracking() { fun startTracking() {
if (scope.isActive && _isOnline.value) return // Bereits aktiv (Dummy-Check) if (scope.isActive && _isOnline.value) return // Bereits aktiv (Dummy-Check)
scope.launch { scope.launch {
while (isActive) { // Sofort prüfen
_isOnline.value = checkConnection() _isOnline.value = checkConnection()
// Zweiter Check nach kurzer Wartezeit, um Start-Races zu glätten
delay(3_000.milliseconds)
_isOnline.value = checkConnection()
// Danach im Intervall prüfen
while (isActive) {
delay(10_000.milliseconds) // Alle 10 Sekunden prüfen delay(10_000.milliseconds) // Alle 10 Sekunden prüfen
_isOnline.value = checkConnection()
} }
} }
} }
private suspend fun checkConnection(): Boolean { private suspend fun checkConnection(): Boolean {
return try { val base = NetworkConfig.baseUrl.trimEnd('/')
val response = client.get(NetworkConfig.baseUrl.trimEnd('/') + "/actuator/health/readiness") // 1) readiness
response.status.value in 200..299 try {
} catch (_: Exception) { val r1 = client.get("$base/actuator/health/readiness")
false if (r1.status.value in 200..299) return true
} catch (e: Exception) {
// Debug-Log schlank halten
println("[Connectivity] readiness failed: ${e.message}")
} }
// 2) health
try {
val r2 = client.get("$base/actuator/health")
if (r2.status.value in 200..299) return true
} catch (e: Exception) {
println("[Connectivity] health failed: ${e.message}")
}
// 3) public ping via gateway routing
try {
val r3 = client.get("$base/api/ping/simple")
if (r3.status.value in 200..299) return true
} catch (e: Exception) {
println("[Connectivity] ping/simple failed: ${e.message}")
}
return false
} }
fun stopTracking() { fun stopTracking() {
@@ -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() }
@@ -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
)
@@ -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
@@ -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.
*/ */
@@ -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() }
}
@@ -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 (_: Exception) {
// Fehler beim Senden ignorieren; Verbindung wird beim nächsten Empfang entfernt
}
}
}
fun getPort(): Int = port
companion object {
const val DEFAULT_PORT: Int = 8090
}
}
@@ -19,12 +19,23 @@ class JmDnsDiscoveryService : NetworkDiscoveryService {
private val jmdnsInstances = mutableListOf<JmDNS>() private val jmdnsInstances = mutableListOf<JmDNS>()
private val SERVICE_TYPE = "_meldestelle._tcp.local." private val SERVICE_TYPE = "_meldestelle._tcp.local."
private val discoveredServicesMap = ConcurrentHashMap<String, DiscoveredService>() private val discoveredServicesMap = ConcurrentHashMap<String, DiscoveredService>()
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
private val _discoveredServices = MutableStateFlow<List<DiscoveredService>>(emptyList()) private val _discoveredServices = MutableStateFlow<List<DiscoveredService>>(emptyList())
override val discoveredServices: StateFlow<List<DiscoveredService>> = _discoveredServices.asStateFlow() override val discoveredServices: StateFlow<List<DiscoveredService>> = _discoveredServices.asStateFlow()
override fun startDiscovery(preferredIp: String?) { override fun startDiscovery(preferredIp: String?) {
if (jmdnsInstances.isNotEmpty()) return // Debounce schnelle Folgeaufrufe mit identischer IP
val now = System.currentTimeMillis()
if (jmdnsInstances.isNotEmpty() && lastStartIp == preferredIp && (now - lastStartRequestedAt) < 500) {
return
}
lastStartRequestedAt = now
lastStartIp = preferredIp
val addresses = getRelevantAddresses(preferredIp) val addresses = getRelevantAddresses(preferredIp)
if (addresses.isEmpty()) { if (addresses.isEmpty()) {
@@ -51,11 +62,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,12 +117,19 @@ 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 {
val key = "${name}@${jmdns.inetAddress.hostAddress}:$port"
if (registeredSet.add(key)) {
jmdns.registerService(serviceInfo) jmdns.registerService(serviceInfo)
println("[Discovery] Dienst '$name' auf ${jmdns.inetAddress} registriert (Port $port)") println("[Discovery] Dienst '$name' auf ${jmdns.inetAddress} registriert (Port $port)")
} else {
// bereits registriert kein Spam
}
} catch (e: Exception) { } catch (e: Exception) {
println("[Discovery] Fehler bei Registrierung auf ${jmdns.inetAddress}: ${e.message}") println("[Discovery] Fehler bei Registrierung auf ${jmdns.inetAddress}: ${e.message}")
} }
@@ -125,13 +146,19 @@ class JmDnsDiscoveryService : NetworkDiscoveryService {
val interfaces = NetworkInterface.getNetworkInterfaces() val interfaces = NetworkInterface.getNetworkInterfaces()
while (interfaces.hasMoreElements()) { while (interfaces.hasMoreElements()) {
val iface = interfaces.nextElement() val iface = interfaces.nextElement()
val name = iface.name.lowercase()
// Filtere Docker/Bridged/VETH/VM-Schnittstellen heraus
if (iface.isLoopback || !iface.isUp || iface.isVirtual) continue if (iface.isLoopback || !iface.isUp || iface.isVirtual) continue
if (name.startsWith("br-") || name.startsWith("docker") || name.startsWith("veth") || name.contains("vmnet") || name.contains("virbr")) continue
val inetAddresses = iface.inetAddresses val inetAddresses = iface.inetAddresses
while (inetAddresses.hasMoreElements()) { while (inetAddresses.hasMoreElements()) {
val addr = inetAddresses.nextElement() val addr = inetAddresses.nextElement()
// Nur IPv4 für maximale Kompatibilität in lokalen Netzen (ÖTO/FEI Standardumgebungen) // Nur IPv4 für maximale Kompatibilität in lokalen Netzen (ÖTO/FEI Standardumgebungen)
if (addr is java.net.Inet4Address) { if (addr is java.net.Inet4Address) {
// Exkludiere Link-Local
val host = addr.hostAddress
if (host.startsWith("169.254.")) continue
addresses.add(addr) addresses.add(addr)
} }
} }
@@ -140,7 +167,15 @@ class JmDnsDiscoveryService : NetworkDiscoveryService {
println("[Discovery] Fehler beim Auflisten der Interfaces: ${e.message}") println("[Discovery] Fehler beim Auflisten der Interfaces: ${e.message}")
} }
return if (addresses.isEmpty()) listOf(InetAddress.getLocalHost()) else addresses if (addresses.isEmpty()) return listOf(InetAddress.getLocalHost())
// 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 addresses.sortedWith(compareByDescending<InetAddress> { isPrivateIPv4(it) }
.thenBy { it.hostAddress })
} }
override fun getDiscoveredServices(): List<DiscoveredService> { override fun getDiscoveredServices(): List<DiscoveredService> {
@@ -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 { }
@@ -208,10 +208,11 @@ class DeviceInitializationViewModel(
discoveryService.stopDiscovery() discoveryService.stopDiscovery()
discoveryService.startDiscovery(ip) discoveryService.startDiscovery(ip)
// Falls wir ein Master sind, registrieren wir uns auch direkt, damit andere uns finden // Falls wir ein Master sind, starten wir den lokalen P2PServer.
// Die mDNSRegistrierung erfolgt zentral beim AppStart (entkoppelt, um Duplikate zu vermeiden).
if (uiState.value.settings.networkRole == NetworkRole.MASTER) { if (uiState.value.settings.networkRole == NetworkRole.MASTER) {
discoveryService.registerService(8080, ip, uiState.value.settings.deviceName)
syncService.startServer(8080) syncService.startServer(8080)
println("[P2P Server] Gestartet auf Port 8080")
} }
} }
@@ -45,4 +45,23 @@ actual object DeviceInitializationSettingsManager {
val settings = loadSettings() ?: return false val settings = loadSettings() ?: return false
return DeviceInitializationValidator.canContinue(settings) return DeviceInitializationValidator.canContinue(settings)
} }
// Hilfsfunktionen (nur JVM): Pfad anzeigen und Reset durchführen
fun getSettingsFilePath(): String = settingsFile.absolutePath
/**
* Setzt die Desktop-App lokal zurück.
* - Löscht settings.json (Device-Initialization)
* - Optional: Löscht die lokale Datenbank unter ~/.meldestelle
*/
fun resetToFactoryDefaults(deleteDatabase: Boolean = false): Result<Unit> = try {
if (settingsFile.exists()) settingsFile.delete()
if (deleteDatabase) {
val dbDir = File(System.getProperty("user.home"), ".meldestelle")
if (dbDir.exists()) dbDir.deleteRecursively()
}
Result.success(Unit)
} catch (e: Exception) {
Result.failure(e)
}
} }
@@ -214,7 +214,22 @@ actual fun DeviceInitializationConfig(
MsFilePicker( MsFilePicker(
label = "Backup-Verzeichnis (Plan-USB)", label = "Backup-Verzeichnis (Plan-USB)",
selectedPath = settings.backupPath, selectedPath = settings.backupPath,
onFileSelected = { viewModel.updateSettings { s -> s.copy(backupPath = it) } }, onFileSelected = { path ->
if (path.isNotBlank()) {
try {
val dir = java.io.File(path)
if (!dir.exists()) dir.mkdirs()
val probe = java.io.File(dir, ".ms_write_test.tmp")
probe.writeText("ok")
probe.delete()
viewModel.updateSettings { s -> s.copy(backupPath = path) }
} catch (e: Exception) {
println("[DeviceInit] Backup-Verzeichnis nicht beschreibbar: ${e.message}")
}
} else {
viewModel.updateSettings { s -> s.copy(backupPath = path) }
}
},
directoryOnly = true, directoryOnly = true,
modifier = Modifier.focusRequester(backupPathFocus), modifier = Modifier.focusRequester(backupPathFocus),
enabled = !uiState.isLocked enabled = !uiState.isLocked
@@ -8,6 +8,9 @@ import at.mocode.frontend.core.auth.di.authModule
import at.mocode.frontend.core.localdb.AppDatabase import at.mocode.frontend.core.localdb.AppDatabase
import at.mocode.frontend.core.localdb.DatabaseProvider import at.mocode.frontend.core.localdb.DatabaseProvider
import at.mocode.frontend.core.localdb.localDbModule import at.mocode.frontend.core.localdb.localDbModule
import at.mocode.frontend.core.network.NetworkConfig
import at.mocode.frontend.core.network.chat.KtorWebSocketServerService
import at.mocode.frontend.core.network.discovery.NetworkDiscoveryService
import at.mocode.frontend.core.network.networkModule import at.mocode.frontend.core.network.networkModule
import at.mocode.frontend.core.sync.di.syncModule import at.mocode.frontend.core.sync.di.syncModule
import at.mocode.frontend.features.billing.di.billingModule import at.mocode.frontend.features.billing.di.billingModule
@@ -60,6 +63,28 @@ fun main() = application {
) )
} }
println("[DesktopApp] KOIN initialisiert") println("[DesktopApp] KOIN initialisiert")
// Base URL Log für schnelle Fehlerdiagnose
println("[Network] baseUrl=${NetworkConfig.baseUrl}")
// Starte Netzwerk-Dienste für den POC
val koin = GlobalContext.get()
try {
val wsServer = koin.get<KtorWebSocketServerService>()
wsServer.start()
val discovery = koin.get<NetworkDiscoveryService>()
discovery.startDiscovery()
// Im Host-Modus würden wir hier registerService aufrufen.
// Für den POC registrieren wir den lokalen Host-Dienst immer mit dem WS-Port
try {
discovery.registerService(wsServer.getPort())
println("[DesktopApp] Discovery-Registrierung durchgeführt (Port ${wsServer.getPort()})")
} catch (e: Exception) {
println("[DesktopApp] Discovery-Registrierung fehlgeschlagen: ${e.message}")
}
} catch(e: Exception) {
println("[DesktopApp] POC-Dienste konnten nicht gestartet werden: ${e.message}")
}
// Testdaten für Prototyp laden // Testdaten für Prototyp laden
at.mocode.frontend.shell.desktop.data.Store.seed() at.mocode.frontend.shell.desktop.data.Store.seed()
} catch (e: Exception) { } catch (e: Exception) {
@@ -24,14 +24,14 @@ class DesktopNavigationPort : NavigationPort {
} }
override fun navigateToScreen(screen: AppScreen) { override fun navigateToScreen(screen: AppScreen) {
println("[DesktopNav] navigateToScreen -> $screen")
// Aktuellen Screen auf den Stack legen, falls er nicht derselbe ist
val current = _currentScreen.value val current = _currentScreen.value
if (current != screen) { if (current == screen) {
backStack.add(current) // Keine Aktion/kein Log bei identischem Ziel beruhigt die Navigation
// Begrenzung des Backstacks auf z. B. 50 Einträge return
if (backStack.size > 50) backStack.removeAt(0)
} }
println("[DesktopNav] navigateToScreen -> $screen")
backStack.add(current)
if (backStack.size > 50) backStack.removeAt(0)
_currentScreen.value = screen _currentScreen.value = screen
} }
@@ -11,8 +11,7 @@ import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.Person
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.*
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
@@ -20,6 +19,10 @@ import androidx.compose.ui.unit.dp
import at.mocode.frontend.core.designsystem.theme.AppColors import at.mocode.frontend.core.designsystem.theme.AppColors
import at.mocode.frontend.core.designsystem.theme.Dimens import at.mocode.frontend.core.designsystem.theme.Dimens
import at.mocode.frontend.core.navigation.AppScreen import at.mocode.frontend.core.navigation.AppScreen
import at.mocode.frontend.core.network.backup.BackupService
import at.mocode.frontend.features.device.initialization.data.local.DeviceInitializationSettingsManager
import org.koin.core.context.GlobalContext
import org.koin.core.parameter.parametersOf
@Composable @Composable
fun DesktopTopHeader( fun DesktopTopHeader(
@@ -126,6 +129,87 @@ fun DesktopTopHeader(
color = MaterialTheme.colorScheme.outlineVariant color = MaterialTheme.colorScheme.outlineVariant
) )
// Diagnose/Tools: Backup jetzt erstellen + Reset-Aktionen
var menuOpen by remember { mutableStateOf(false) }
Box {
Button(onClick = { menuOpen = true }, enabled = true) {
Text("Tools")
}
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
DropdownMenuItem(
text = { Text("Backup jetzt erstellen (PoC)") },
onClick = {
menuOpen = false
val settings = DeviceInitializationSettingsManager.loadSettings()
val backupPath = settings?.backupPath.orEmpty()
val sharedKey = settings?.sharedKey.orEmpty()
val deviceName = settings?.deviceName.orEmpty().ifBlank { "Meldestelle-Device" }
if (backupPath.isBlank() || sharedKey.isBlank()) {
println("[Backup] Abbruch: backupPath oder sharedKey nicht gesetzt. Öffne DeviceInitialization.")
onNavigate(AppScreen.DeviceInitialization)
} else {
try {
val backupService: BackupService = GlobalContext.get().get<BackupService> { parametersOf(deviceName) }
val result = backupService.exportDelta("poc-backup", backupPath, sharedKey)
result.onSuccess { fileName -> println("[Backup] Erfolgreich exportiert: $fileName") }
.onFailure { ex -> println("[Backup] Fehler: ${ex.message}") }
} catch (e: Exception) {
println("[Backup] Fehler bei der Initialisierung: ${e.message}")
}
}
}
)
HorizontalDivider(Modifier, DividerDefaults.Thickness, DividerDefaults.color)
DropdownMenuItem(
text = { Text("Einstellungen-Ordner öffnen") },
onClick = {
menuOpen = false
val settingsDir = DeviceInitializationSettingsManager.getSettingsFilePath()
val parent = java.io.File(settingsDir).parentFile?.absolutePath ?: settingsDir
try {
// Versuche plattformspezifisch den Ordner zu öffnen
val os = System.getProperty("os.name").lowercase()
if (os.contains("win")) {
Runtime.getRuntime().exec(arrayOf("explorer", parent))
} else if (os.contains("mac")) {
Runtime.getRuntime().exec(arrayOf("open", parent))
} else {
Runtime.getRuntime().exec(arrayOf("xdg-open", parent))
}
} catch (e: Exception) {
println("[Tools] Konnte Ordner nicht öffnen: ${e.message}. Pfad: $parent")
}
}
)
DropdownMenuItem(
text = { Text("Einstellungen zurücksetzen") },
onClick = {
menuOpen = false
val res = DeviceInitializationSettingsManager.resetToFactoryDefaults(deleteDatabase = false)
if (res.isSuccess) {
println("[Reset] settings.json gelöscht: ${DeviceInitializationSettingsManager.getSettingsFilePath()}")
} else {
println("[Reset] Fehler: ${res.exceptionOrNull()?.message}")
}
onNavigate(AppScreen.DeviceInitialization)
}
)
DropdownMenuItem(
text = { Text("Alles zurücksetzen (inkl. DB)") },
onClick = {
menuOpen = false
val res = DeviceInitializationSettingsManager.resetToFactoryDefaults(deleteDatabase = true)
if (res.isSuccess) {
println("[Reset] settings + ~/.meldestelle gelöscht")
} else {
println("[Reset] Fehler: ${res.exceptionOrNull()?.message}")
}
onNavigate(AppScreen.DeviceInitialization)
}
)
}
}
// Profil / Logout Bereich // Profil / Logout Bereich
if (isAuthenticated) { if (isAuthenticated) {
Text( Text(