Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3aaf5cc59c | |||
| a2d94bbc7e | |||
| 95a130c72e | |||
| 223bf77776 |
+2
-1
@@ -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/Client‑Kommunikation (mDNS, WS‑Chat), robuste Connectivity‑Checks, UX für Backup‑Pfad, Session‑Abschluss mit Dokumentation.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
- ConnectivityCheck robuster gemacht (Fallbacks, schneller Erstcheck) und Logs (Base‑URL, WS‑Port) korrigiert.
|
||||||
|
- Discovery/Registration zentralisiert und entdoppelt; Interface‑Bindung und Logging verbessert.
|
||||||
|
- Datei‑Picker auf `JFileChooser` umgestellt; editierbares Pfadfeld mit Validierung integriert.
|
||||||
|
- Firewalld/mDNS‑Ursache für fehlende Sichtbarkeit zwischen Host/Client identifiziert und als ToDo/Guide dokumentiert.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
- ConnectivityTracker: Fallback‑Kaskade readiness → health → /api/ping/simple; Intervalle angepasst; Debug‑Logs ergänzt.
|
||||||
|
- main.kt: korrekte String‑Interpolation; Start‑Log der `NetworkConfig.baseUrl`; WS‑Port 8090 konsistent.
|
||||||
|
- JmDnsDiscoveryService: Interface‑Filter (ohne docker/br/veth, private IPv4 priorisiert), Debounce/De‑Dup der Registrierung, Log‑Noise reduziert.
|
||||||
|
- Navigation: Guard gegen Navigation auf gleichen Screen; Top‑Bar Tools erweitert (Reset/Backup/Settings‑Ordner öffnen).
|
||||||
|
- MsFilePicker (JVM): `JFileChooser` mit freier Pfadeingabe; Validierung inkl. Schreib‑Probe; automatische Ordnererstellung bei Auswahl.
|
||||||
|
- conveyor.conf: JVM‑Flag `--enable-native-access=ALL-UNNAMED` ergänzt (Netty‑Warnung mitigiert).
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
- Build (Gradle): erfolgreich ✓
|
||||||
|
- Laufzeit/Netzwerk: Verifikation ausstehend (mDNS nach Firewall‑Freigaben; KDE‑Picker unter Fedora 44; Host/Client‑Sichtbarkeit LAN/WLAN) — Anti‑Halluzinations‑Protokoll 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. KDE‑Directory‑Picker: auf `OPEN_DIALOG` im `DIRECTORIES_ONLY`‑Modus wechseln; präzisere Fehlermeldungen; HOME‑Fallback.
|
||||||
|
2. Guard gegen mehrfachen P2P‑Start ergänzen.
|
||||||
|
3. Conveyor/Windows‑Installer in CI (Runtime‑Flags; optional SLF4J‑Binding), danach erneute Laufzeit‑Verifikation.
|
||||||
+17
-21
@@ -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.
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
🎨 [Frontend Expert]
|
||||||
|
|
||||||
|
### Kurzfazit
|
||||||
|
- Die beiden Rechner sehen sich über mDNS höchstwahrscheinlich wegen Firewall‑Regeln/Zonen nicht. JmDNS registriert korrekt auf 192.168.0.124:8090, aber der Client (192.168.0.167, WLAN) empfängt keine mDNS‑Antworten → Fedora/Firewalld blockiert UDP 5353 Multicast in der aktiven Zone (WLAN/Public) sehr häufig standardmäßig.
|
||||||
|
- Der Datei‑Picker unter Fedora KDE hakt im Verzeichnis‑Modus 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 mDNS‑Baseline)
|
||||||
|
```
|
||||||
|
systemctl status avahi-daemon
|
||||||
|
```
|
||||||
|
|
||||||
|
3) mDNS‑Pakete verifizieren (nur wenn es nach 1) noch nicht klappt)
|
||||||
|
```
|
||||||
|
# Auf dem Client (WLAN‑Interface anpassen):
|
||||||
|
sudo tcpdump -n -i wlp0s20f3 port 5353
|
||||||
|
# Auf dem Host (Ethernet‑Interface anpassen):
|
||||||
|
sudo tcpdump -n -i <eth-iface> port 5353
|
||||||
|
```
|
||||||
|
Erwartung: Bei App‑Start siehst du Multicast an 224.0.0.251. Wenn auf einer Seite „Stille“ herrscht, blockiert noch etwas.
|
||||||
|
|
||||||
|
4) WS‑Konnektivitä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 WS‑Pfad 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 mDNS‑Antworten angekommen (typisches Firewall‑Symptom bei Fedora/KDE, vor allem in Zone „public“).
|
||||||
|
- Mehrfach „[P2P Server] Gestartet auf Port 8080“ → Wir haben noch einen Trigger, der den P2P‑Start mehrfach feuert. Funktional unkritisch für den aktuellen Befund; ich bereinige das im nächsten Patch mit einem zusätzlichen Guard.
|
||||||
|
|
||||||
|
### Fedora KDE Datei‑Picker (JFileChooser) – Workarounds jetzt, Fix als Patch im Anschluss
|
||||||
|
- Mögliche Ursache 1: `SAVE_DIALOG` im Ordner‑Modus unter KDE verhält sich zickig. Besser: `OPEN_DIALOG` im `DIRECTORIES_ONLY`‑Modus + eigener Bestätigungs‑Button‑Text.
|
||||||
|
- 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`.
|
||||||
|
- Quick‑Test 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):
|
||||||
|
- Directory‑Picker: 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 Auto‑Erstellung, 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 3–5 Zeilen genügen).
|
||||||
|
- Datei‑Picker 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 KDE‑Picker (OPEN_DIALOG) und einen zusätzlichen Guard gegen mehrfachen P2P‑Start; außerdem noch etwas Discovery‑Logging (Interface/Zonen‑Hinweis).
|
||||||
|
- Danach kümmern wir uns um Conveyor (Windows‑Installer aus CI, inkl. JVM‑Flag gegen die Netty‑Warnung).
|
||||||
+62
-32
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
+38
-6
@@ -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() {
|
||||||
|
|||||||
+2
-1
@@ -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() }
|
||||||
|
|
||||||
|
|||||||
+13
@@ -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
|
||||||
|
)
|
||||||
+8
@@ -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
|
||||||
+3
-1
@@ -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.
|
||||||
*/
|
*/
|
||||||
|
|||||||
+9
@@ -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() }
|
||||||
|
}
|
||||||
+105
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
+39
-4
@@ -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> {
|
||||||
|
|||||||
+7
@@ -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 { }
|
||||||
+3
-2
@@ -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 P2P‑Server.
|
||||||
|
// Die mDNS‑Registrierung erfolgt zentral beim App‑Start (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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+19
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-1
@@ -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
|
||||||
|
|||||||
+25
@@ -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) {
|
||||||
|
|||||||
+6
-6
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+86
-2
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user