From 95a130c72e221adced8583968bfc91d2ca14b218 Mon Sep 17 00:00:00 2001 From: Stefan Mogeritsch Date: Thu, 7 May 2026 15:42:06 +0200 Subject: [PATCH] =?UTF-8?q?feat(desktop,=20device-initialization):=20Tools?= =?UTF-8?q?-Men=C3=BC=20mit=20Backup-Option=20und=20Reset-Funktion=20erg?= =?UTF-8?q?=C3=A4nzt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Stefan Mogeritsch --- .../components/MsFilePicker.jvm.kt | 96 ++++++++++++------- .../core/network/ConnectivityTracker.kt | 44 +++++++-- .../chat/KtorWebSocketServerService.kt | 2 +- ...DeviceInitializationSettingsManager.jvm.kt | 19 ++++ .../at/mocode/frontend/shell/desktop/main.kt | 25 +++++ .../screens/layout/components/TopHeader.kt | 73 +++++++++++++- 6 files changed, 214 insertions(+), 45 deletions(-) diff --git a/frontend/core/design-system/src/jvmMain/kotlin/at/mocode/frontend/core/designsystem/components/MsFilePicker.jvm.kt b/frontend/core/design-system/src/jvmMain/kotlin/at/mocode/frontend/core/designsystem/components/MsFilePicker.jvm.kt index 009578c4..a3772d99 100644 --- a/frontend/core/design-system/src/jvmMain/kotlin/at/mocode/frontend/core/designsystem/components/MsFilePicker.jvm.kt +++ b/frontend/core/design-system/src/jvmMain/kotlin/at/mocode/frontend/core/designsystem/components/MsFilePicker.jvm.kt @@ -8,9 +8,12 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import java.awt.FileDialog -import java.awt.Frame 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 actual fun MsFilePicker( @@ -23,17 +26,35 @@ actual fun MsFilePicker( enabled: Boolean, 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( modifier = modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically ) { MsTextField( - value = selectedPath ?: "", - onValueChange = { }, - readOnly = true, + value = currentValue, + onValueChange = { newValue -> onFileSelected(newValue) }, + readOnly = false, label = label, helpDescription = helpDescription, placeholder = if (directoryOnly) "Verzeichnis wählen..." else "Datei wählen...", + isError = isError, + errorMessage = errorMessage, modifier = Modifier.weight(1f), enabled = enabled, compact = true @@ -43,40 +64,49 @@ actual fun MsFilePicker( MsButton( onClick = { - if (directoryOnly) { - // AWT FileDialog für nativen Look auch bei Verzeichnissen (Windows/Linux/macOS) - // unter macOS erzwingt dies die Verzeichnisauswahl. Unter Windows/Linux ist es der Standard-Dialog. - 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 + // 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) { + fileSelectionMode = JFileChooser.DIRECTORIES_ONLY + dialogType = JFileChooser.SAVE_DIALOG // ermöglicht "Neuer Ordner" + } 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) { - // 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 { - // 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()) } + + val result = chooser.showDialog(null, "Auswählen") + 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) } } - dialog.isVisible = true - if (dialog.file != null) { - onFileSelected(File(dialog.directory, dialog.file).absolutePath) - } } }, text = "Durchsuchen", diff --git a/frontend/core/network/src/commonMain/kotlin/at/mocode/frontend/core/network/ConnectivityTracker.kt b/frontend/core/network/src/commonMain/kotlin/at/mocode/frontend/core/network/ConnectivityTracker.kt index 208956bd..4254a5b7 100644 --- a/frontend/core/network/src/commonMain/kotlin/at/mocode/frontend/core/network/ConnectivityTracker.kt +++ b/frontend/core/network/src/commonMain/kotlin/at/mocode/frontend/core/network/ConnectivityTracker.kt @@ -13,6 +13,11 @@ import kotlin.time.Duration.Companion.milliseconds /** * Ü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 { private val client: HttpClient by inject(named("baseHttpClient")) @@ -24,20 +29,47 @@ class ConnectivityTracker : KoinComponent { fun startTracking() { if (scope.isActive && _isOnline.value) return // Bereits aktiv (Dummy-Check) 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) { - _isOnline.value = checkConnection() delay(10_000.milliseconds) // Alle 10 Sekunden prüfen + _isOnline.value = checkConnection() } } } private suspend fun checkConnection(): Boolean { - return try { - val response = client.get(NetworkConfig.baseUrl.trimEnd('/') + "/actuator/health/readiness") - response.status.value in 200..299 - } catch (_: Exception) { - false + val base = NetworkConfig.baseUrl.trimEnd('/') + // 1) readiness + try { + val r1 = client.get("$base/actuator/health/readiness") + 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() { diff --git a/frontend/core/network/src/jvmMain/kotlin/at/mocode/frontend/core/network/chat/KtorWebSocketServerService.kt b/frontend/core/network/src/jvmMain/kotlin/at/mocode/frontend/core/network/chat/KtorWebSocketServerService.kt index 86ccde13..f7905ecc 100644 --- a/frontend/core/network/src/jvmMain/kotlin/at/mocode/frontend/core/network/chat/KtorWebSocketServerService.kt +++ b/frontend/core/network/src/jvmMain/kotlin/at/mocode/frontend/core/network/chat/KtorWebSocketServerService.kt @@ -100,6 +100,6 @@ class KtorWebSocketServerService( fun getPort(): Int = port companion object { - const val DEFAULT_PORT: Int = 8081 + const val DEFAULT_PORT: Int = 8090 } } diff --git a/frontend/features/device-initialization/src/jvmMain/kotlin/at/mocode/frontend/features/device/initialization/data/local/DeviceInitializationSettingsManager.jvm.kt b/frontend/features/device-initialization/src/jvmMain/kotlin/at/mocode/frontend/features/device/initialization/data/local/DeviceInitializationSettingsManager.jvm.kt index 7cffe3b4..8242527c 100644 --- a/frontend/features/device-initialization/src/jvmMain/kotlin/at/mocode/frontend/features/device/initialization/data/local/DeviceInitializationSettingsManager.jvm.kt +++ b/frontend/features/device-initialization/src/jvmMain/kotlin/at/mocode/frontend/features/device/initialization/data/local/DeviceInitializationSettingsManager.jvm.kt @@ -45,4 +45,23 @@ actual object DeviceInitializationSettingsManager { val settings = loadSettings() ?: return false 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 = 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) + } } diff --git a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/main.kt b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/main.kt index bda9c5d8..2ad7d717 100644 --- a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/main.kt +++ b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/main.kt @@ -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.DatabaseProvider 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.sync.di.syncModule import at.mocode.frontend.features.billing.di.billingModule @@ -60,6 +63,28 @@ fun main() = application { ) } 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() + wsServer.start() + val discovery = koin.get() + 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 at.mocode.frontend.shell.desktop.data.Store.seed() } catch (e: Exception) { diff --git a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/screens/layout/components/TopHeader.kt b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/screens/layout/components/TopHeader.kt index 72f102d9..3172bbdf 100644 --- a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/screens/layout/components/TopHeader.kt +++ b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/screens/layout/components/TopHeader.kt @@ -11,8 +11,7 @@ import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.filled.Person import androidx.compose.material3.* -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier 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.Dimens 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 fun DesktopTopHeader( @@ -84,9 +87,9 @@ fun DesktopTopHeader( } Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(Dimens.SpacingM) - ) { + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Dimens.SpacingM) + ) { // Sync-Status Indikator val syncColor = if (connectedPeersCount > 0) AppColors.Success else MaterialTheme.colorScheme.outline val syncText = if (connectedPeersCount > 0) "$connectedPeersCount Peer(s)" else "Offline" @@ -126,6 +129,66 @@ fun DesktopTopHeader( 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 { parametersOf(deviceName) } + val result = backupService.exportDelta("poc-backup", backupPath, sharedKey) + result.onSuccess { path -> println("[Backup] Erfolgreich exportiert: ${'$'}path") } + .onFailure { e -> println("[Backup] Fehler: ${'$'}{e.message}") } + } catch (e: Exception) { + println("[Backup] Fehler bei der Initialisierung: ${'$'}{e.message}") + } + } + } + ) + Divider() + 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 if (isAuthenticated) { Text(