feat(desktop, device-initialization): Tools-Menü mit Backup-Option und Reset-Funktion ergänzt

Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
This commit is contained in:
2026-05-07 15:42:06 +02:00
parent 223bf77776
commit 95a130c72e
6 changed files with 214 additions and 45 deletions
@@ -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,40 +64,49 @@ 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 { // Initiales Verzeichnis/Pfad
selectedPath?.let { selectedPath?.let { p ->
val currentDir = File(it) val f = File(p)
if (currentDir.exists()) { currentDirectory = when {
directory = currentDir.absolutePath 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) { val result = chooser.showDialog(null, "Auswählen")
// 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",
@@ -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() {
@@ -100,6 +100,6 @@ class KtorWebSocketServerService(
fun getPort(): Int = port fun getPort(): Int = port
companion object { companion object {
const val DEFAULT_PORT: Int = 8081 const val DEFAULT_PORT: Int = 8090
} }
} }
@@ -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)
}
} }
@@ -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) {
@@ -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,66 @@ 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 { 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 // Profil / Logout Bereich
if (isAuthenticated) { if (isAuthenticated) {
Text( Text(