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:
+63
-33
@@ -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",
|
||||||
|
|||||||
+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 {
|
||||||
|
// 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
-1
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+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) {
|
||||||
|
|||||||
+68
-5
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user