7 Commits

24 changed files with 725 additions and 109 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.
@@ -0,0 +1,33 @@
---
type: Journal
status: COMPLETED
owner: Curator
last_update: 2026-05-08
---
# 2026-05-08 — Session Log (P2P Guards, FilePicker & Test Verification)
## Kontext
- Fokus: Stabilisierung des P2P-Sync-Servers (Guard gegen Mehrfachstart) und finale Optimierung des JVM File-Pickers für KDE/Fedora.
- Basierend auf den ToDos vom Vortag.
## Summary
- **P2P Sync Guard:** `JvmP2pSyncService` wurde um einen port-basierten Guard erweitert. Mehrfache Start-Aufrufe auf demselben Port werden nun prozessweit abgefangen (idempotent), was Ressourcen schont und Fehler beim Bind verhindert.
- **Test-Verifikation:** Neuer Integration-Test `JvmP2pSyncServiceTest` erstellt, der das Guard-Verhalten und die Freigabe des Ports nach Stop verifiziert.
- **MsFilePicker (JVM):** Finale Anpassungen für KDE (Fedora 44). Umstellung auf `isAcceptAllFileFilterUsed = false` und explizites `approveButtonText = "Auswählen"`. Der Directory-Picker nutzt nun konsequent `OPEN_DIALOG` im `DIRECTORIES_ONLY` Modus.
- **Build-Fix:** Ein Tippfehler (`acceptAllFileFilterUsed` -> `isAcceptAllFileFilterUsed`) wurde korrigiert.
## Changes
- `at.mocode.frontend.core.network.sync.JvmP2pSyncService`: Port-Guard integriert.
- `at.mocode.frontend.core.network.sync.JvmP2pSyncServiceTest`: Neuer JVM-Test (verifiziert ✅).
- `at.mocode.frontend.core.designsystem.components.MsFilePicker.jvm.kt`: UI-Anpassungen für Swing JFileChooser.
- `frontend/core/network/build.gradle.kts`: Test-Abhängigkeiten hinzugefügt.
## Verification
- **Unit/Integration Tests:** `JvmP2pSyncServiceTest` erfolgreich durchgelaufen ✓.
- **Build (Gradle):** Gesamter Build inkl. Packaging-Hüllen erfolgreich ✓.
- **Laufzeit (Netzwerk):** P2P-Guard loggt korrekt: "[P2P Server] Bereits gestartet...". Discovery-Sichtbarkeit LAN/WLAN weiterhin abhängig von Firewalld-Status (siehe ToDo-Firewall).
## Nächste Schritte
1. Conveyor-Build auf einem x86_64 Runner (oder lokal) verifizieren, um Windows-Installer zu erzeugen.
2. Erste physische Turnier-Hierarchie (MEILENSTEIN 1) angehen.
+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,45 @@ 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) {
when {
!f.exists() -> true to "Ordner existiert nicht"
!f.isDirectory -> true to "Pfad ist kein Ordner"
!f.canWrite() -> true to "Ordner ist schreibgeschützt"
else -> false to null
}
} else {
val ok = (f.exists() && f.isFile && f.canWrite()) || (f.parentFile?.canWrite() == true)
(!ok) to if (!ok) {
when {
!f.exists() && f.parentFile?.exists() != true -> "Pfad existiert nicht"
f.exists() && !f.isFile -> "Pfad ist keine Datei"
else -> "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,40 +74,57 @@ actual fun MsFilePicker(
MsButton( MsButton(
onClick = { onClick = {
if (directoryOnly) { // Einheitlich plattformübergreifend: Swing JFileChooser verwenden
// AWT FileDialog für nativen Look auch bei Verzeichnissen (Windows/Linux/macOS) SwingUtilities.invokeLater {
// unter macOS erzwingt dies die Verzeichnisauswahl. Unter Windows/Linux ist es der Standard-Dialog. val chooser = JFileChooser().apply {
System.setProperty("apple.awt.fileDialogForDirectories", "true") isMultiSelectionEnabled = false
val dialog = FileDialog(null as Frame?, label, FileDialog.LOAD).apply { isAcceptAllFileFilterUsed = false
selectedPath?.let { approveButtonText = "Auswählen"
val currentDir = File(it)
if (currentDir.exists()) { // Initiales Verzeichnis/Pfad
directory = currentDir.absolutePath run {
val home = File(System.getProperty("user.home") ?: ".")
val initial = selectedPath?.takeIf { it.isNotBlank() }?.let { File(it) }
val baseDir = when {
initial == null -> home
directoryOnly && initial.isDirectory -> initial
!directoryOnly && initial.isFile -> initial.parentFile ?: home
initial.parentFile?.isDirectory == true -> initial.parentFile
else -> home
}
currentDirectory = baseDir
if (!directoryOnly && initial?.isFile == true) selectedFile = initial
}
if (directoryOnly) {
fileSelectionMode = JFileChooser.DIRECTORIES_ONLY
// KDE/Plasma: OPEN_DIALOG im DIRECTORIES_ONLYModus verwenden (kein SaveDialog)
dialogType = JFileChooser.OPEN_DIALOG
} else {
fileSelectionMode = JFileChooser.FILES_ONLY
if (fileExtensions.isNotEmpty()) {
fileFilter = FileNameExtensionFilter(
"Erlaubte Dateien",
*fileExtensions.map { it.trimStart('.') }.toTypedArray()
)
} }
} }
} }
dialog.isVisible = true
if (dialog.directory != null && dialog.file != null) { val result = chooser.showOpenDialog(null)
// Bei FileDialog.LOAD unter Windows/Linux wählt man oft eine Datei im Ordner, if (result == JFileChooser.APPROVE_OPTION) {
// aber wir wollen den Ordner. Wir nehmen also das Verzeichnis. val chosen = chooser.selectedFile
onFileSelected(File(dialog.directory, dialog.file).parentFile.absolutePath) if (directoryOnly) {
} else if (dialog.directory != null) { if (!chosen.exists()) {
onFileSelected(dialog.directory) try {
} Files.createDirectories(Path.of(chosen.absolutePath))
System.setProperty("apple.awt.fileDialogForDirectories", "false") } catch (_: Exception) { /* ignorieren, Validierung zeigt Fehler */ }
} else {
// AWT FileDialog für nativen Look bei Dateiauswahl (wie vom User gewünscht)
val dialog = FileDialog(null as Frame?, label, FileDialog.LOAD).apply {
if (fileExtensions.isNotEmpty()) {
setFilenameFilter { _, name ->
fileExtensions.any { name.lowercase().endsWith(it.lowercase()) }
} }
onFileSelected(chosen.absolutePath)
} else {
onFileSelected(chosen.absolutePath)
} }
} }
dialog.isVisible = true
if (dialog.file != null) {
onFileSelected(File(dialog.directory, dialog.file).absolutePath)
}
} }
}, },
text = "Durchsuchen", text = "Durchsuchen",
+5
View File
@@ -51,5 +51,10 @@ kotlin {
implementation(libs.ktor.client.js) implementation(libs.ktor.client.js)
implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.coroutines.core)
} }
commonTest.dependencies {
implementation(libs.kotlin.test)
implementation(libs.kotlinx.coroutines.test)
}
} }
} }
@@ -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 {
// Sofort prüfen
_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) { while (isActive) {
_isOnline.value = checkConnection()
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 {
jmdns.registerService(serviceInfo) val key = "${name}@${jmdns.inetAddress.hostAddress}:$port"
println("[Discovery] Dienst '$name' auf ${jmdns.inetAddress} registriert (Port $port)") if (registeredSet.add(key)) {
jmdns.registerService(serviceInfo)
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> {
@@ -15,9 +15,15 @@ import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import java.util.* import java.util.*
import java.util.concurrent.ConcurrentHashMap
class JvmP2pSyncService : P2pSyncService { class JvmP2pSyncService : P2pSyncService {
companion object {
// Prozessweiter, portbasierter Guard gegen Mehrfachstart
private val startedPorts: MutableSet<Int> = ConcurrentHashMap.newKeySet()
}
private var server: EmbeddedServer<*, *>? = null private var server: EmbeddedServer<*, *>? = null
private var currentPort: Int? = null
private val client = HttpClient { private val client = HttpClient {
install(io.ktor.client.plugins.websocket.WebSockets) install(io.ktor.client.plugins.websocket.WebSockets)
} }
@@ -32,41 +38,66 @@ class JvmP2pSyncService : P2pSyncService {
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
override fun startServer(port: Int) { override fun startServer(port: Int) {
if (server != null) return // Instanz-Guard (gleiche Instanz)
if (server != null) {
println("[P2P Server] Bereits gestartet (Instanz) auf Port ${currentPort ?: port} idempotent")
return
}
server = embeddedServer(Netty, port = port) { // Prozessweiter, portbasierter Guard
install(io.ktor.server.websocket.WebSockets) if (!startedPorts.add(port)) {
routing { println("[P2P Server] Bereits gestartet (Prozess) auf Port $port idempotent, kein neuer Bind")
webSocket("/sync") { return
println("[P2P Server] Neuer Peer verbunden") }
activeSessions.add(this)
updatePeers() try {
try { server = embeddedServer(Netty, port = port) {
for (frame in incoming) { install(io.ktor.server.websocket.WebSockets)
if (frame is Frame.Text) { routing {
val text = frame.readText() webSocket("/sync") {
try { println("[P2P Server] Neuer Peer verbunden")
val event = Json.decodeFromString<SyncEvent>(text) activeSessions.add(this)
_incomingEvents.emit(event) updatePeers()
} catch (e: Exception) { try {
println("[P2P Server] Fehler beim Dekodieren: ${e.message}") for (frame in incoming) {
if (frame is Frame.Text) {
val text = frame.readText()
try {
val event = Json.decodeFromString<SyncEvent>(text)
_incomingEvents.emit(event)
} catch (e: Exception) {
println("[P2P Server] Fehler beim Dekodieren: ${e.message}")
}
} }
} }
} finally {
activeSessions.remove(this)
updatePeers()
println("[P2P Server] Peer getrennt")
} }
} finally {
activeSessions.remove(this)
updatePeers()
println("[P2P Server] Peer getrennt")
} }
} }
} }.start(wait = false)
}.start(wait = false) currentPort = port
println("[P2P Server] Gestartet auf Port $port") println("[P2P Server] Gestartet auf Port $port")
} catch (e: Exception) {
// Start fehlgeschlagen -> Port-Lock wieder freigeben
startedPorts.remove(port)
server = null
currentPort = null
println("[P2P Server] Start auf Port $port fehlgeschlagen: ${e.message}")
throw e
}
} }
override fun stopServer() { override fun stopServer() {
server?.stop(1000, 2000) try {
server = null server?.stop(1000, 2000)
} finally {
server = null
currentPort?.let { startedPorts.remove(it) }
currentPort = null
}
} }
override suspend fun connectToPeer(host: String, port: Int) { override suspend fun connectToPeer(host: String, port: Int) {
@@ -0,0 +1,37 @@
package at.mocode.frontend.core.network.sync
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
class JvmP2pSyncServiceTest {
@Test
fun starting_server_twice_on_same_port_should_not_fail_but_use_guard() = runTest {
val service1 = JvmP2pSyncService()
val service2 = JvmP2pSyncService()
val port = 9091
try {
service1.startServer(port)
// Second start should just return/log and not throw an exception (idempotent)
service2.startServer(port)
} finally {
service1.stopServer()
service2.stopServer()
}
}
@Test
fun stopping_server_should_release_port_lock() = runTest {
val service1 = JvmP2pSyncService()
val service2 = JvmP2pSyncService()
val port = 9092
service1.startServer(port)
service1.stopServer()
// After stopping, starting again on same port (even from different instance) should work
service2.startServer(port)
service2.stopServer()
}
}
@@ -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(
@@ -84,9 +87,9 @@ fun DesktopTopHeader(
} }
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Dimens.SpacingM) horizontalArrangement = Arrangement.spacedBy(Dimens.SpacingM)
) { ) {
// Sync-Status Indikator // Sync-Status Indikator
val syncColor = if (connectedPeersCount > 0) AppColors.Success else MaterialTheme.colorScheme.outline val syncColor = if (connectedPeersCount > 0) AppColors.Success else MaterialTheme.colorScheme.outline
val syncText = if (connectedPeersCount > 0) "$connectedPeersCount Peer(s)" else "Offline" val syncText = if (connectedPeersCount > 0) "$connectedPeersCount Peer(s)" else "Offline"
@@ -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(