feat(desktop, network): Fehlerhandling verbessert, Tools-Menü erweitert und mDNS-Discovery optimiert

Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
This commit is contained in:
2026-05-07 17:18:12 +02:00
parent a2d94bbc7e
commit 3aaf5cc59c
9 changed files with 201 additions and 24 deletions
@@ -19,12 +19,23 @@ class JmDnsDiscoveryService : NetworkDiscoveryService {
private val jmdnsInstances = mutableListOf<JmDNS>()
private val SERVICE_TYPE = "_meldestelle._tcp.local."
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())
override val discoveredServices: StateFlow<List<DiscoveredService>> = _discoveredServices.asStateFlow()
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)
if (addresses.isEmpty()) {
@@ -112,8 +123,13 @@ class JmDnsDiscoveryService : NetworkDiscoveryService {
)
)
try {
jmdns.registerService(serviceInfo)
println("[Discovery] Dienst '$name' auf ${jmdns.inetAddress} registriert (Port $port)")
val key = "${name}@${jmdns.inetAddress.hostAddress}:$port"
if (registeredSet.add(key)) {
jmdns.registerService(serviceInfo)
println("[Discovery] Dienst '$name' auf ${jmdns.inetAddress} registriert (Port $port)")
} else {
// bereits registriert kein Spam
}
} catch (e: Exception) {
println("[Discovery] Fehler bei Registrierung auf ${jmdns.inetAddress}: ${e.message}")
}
@@ -130,13 +146,19 @@ class JmDnsDiscoveryService : NetworkDiscoveryService {
val interfaces = NetworkInterface.getNetworkInterfaces()
while (interfaces.hasMoreElements()) {
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 (name.startsWith("br-") || name.startsWith("docker") || name.startsWith("veth") || name.contains("vmnet") || name.contains("virbr")) continue
val inetAddresses = iface.inetAddresses
while (inetAddresses.hasMoreElements()) {
val addr = inetAddresses.nextElement()
// Nur IPv4 für maximale Kompatibilität in lokalen Netzen (ÖTO/FEI Standardumgebungen)
if (addr is java.net.Inet4Address) {
// Exkludiere Link-Local
val host = addr.hostAddress
if (host.startsWith("169.254.")) continue
addresses.add(addr)
}
}
@@ -145,7 +167,15 @@ class JmDnsDiscoveryService : NetworkDiscoveryService {
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> {
@@ -208,10 +208,11 @@ class DeviceInitializationViewModel(
discoveryService.stopDiscovery()
discoveryService.startDiscovery(ip)
// Falls wir ein Master sind, registrieren wir uns auch direkt, damit andere uns finden
// Falls wir ein Master sind, starten wir den lokalen P2PServer.
// Die mDNSRegistrierung erfolgt zentral beim AppStart (entkoppelt, um Duplikate zu vermeiden).
if (uiState.value.settings.networkRole == NetworkRole.MASTER) {
discoveryService.registerService(8080, ip, uiState.value.settings.deviceName)
syncService.startServer(8080)
println("[P2P Server] Gestartet auf Port 8080")
}
}
@@ -214,7 +214,22 @@ actual fun DeviceInitializationConfig(
MsFilePicker(
label = "Backup-Verzeichnis (Plan-USB)",
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,
modifier = Modifier.focusRequester(backupPathFocus),
enabled = !uiState.isLocked
@@ -73,13 +73,13 @@ fun main() = application {
wsServer.start()
val discovery = koin.get<NetworkDiscoveryService>()
discovery.startDiscovery()
// Im Host-Modus würden wir hier registerService aufrufen
// 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 (_: Exception) {
println("[DesktopApp] Discovery-Registrierung fehlgeschlagen: ${'$'}{e.message}")
} catch (e: Exception) {
println("[DesktopApp] Discovery-Registrierung fehlgeschlagen: ${e.message}")
}
} catch(e: Exception) {
println("[DesktopApp] POC-Dienste konnten nicht gestartet werden: ${e.message}")
@@ -24,14 +24,14 @@ class DesktopNavigationPort : NavigationPort {
}
override fun navigateToScreen(screen: AppScreen) {
println("[DesktopNav] navigateToScreen -> $screen")
// Aktuellen Screen auf den Stack legen, falls er nicht derselbe ist
val current = _currentScreen.value
if (current != screen) {
backStack.add(current)
// Begrenzung des Backstacks auf z. B. 50 Einträge
if (backStack.size > 50) backStack.removeAt(0)
if (current == screen) {
// Keine Aktion/kein Log bei identischem Ziel beruhigt die Navigation
return
}
println("[DesktopNav] navigateToScreen -> $screen")
backStack.add(current)
if (backStack.size > 50) backStack.removeAt(0)
_currentScreen.value = screen
}
@@ -151,24 +151,45 @@ fun DesktopTopHeader(
try {
val backupService: BackupService = GlobalContext.get().get<BackupService> { parametersOf(deviceName) }
val result = backupService.exportDelta("poc-backup", backupPath, sharedKey)
result.onSuccess { _ -> println($$"[Backup] Erfolgreich exportiert: $path") }
.onFailure { _ -> println($$"[Backup] Fehler: ${e.message}") }
} catch (_: Exception) {
println($$"[Backup] Fehler bei der Initialisierung: ${e.message}")
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()}")
println("[Reset] settings.json gelöscht: ${DeviceInitializationSettingsManager.getSettingsFilePath()}")
} else {
println($$"[Reset] Fehler: ${res.exceptionOrNull()?.message}")
println("[Reset] Fehler: ${res.exceptionOrNull()?.message}")
}
onNavigate(AppScreen.DeviceInitialization)
}
@@ -181,7 +202,7 @@ fun DesktopTopHeader(
if (res.isSuccess) {
println("[Reset] settings + ~/.meldestelle gelöscht")
} else {
println("[Reset] Fehler: ${'$'}{res.exceptionOrNull()?.message}")
println("[Reset] Fehler: ${res.exceptionOrNull()?.message}")
}
onNavigate(AppScreen.DeviceInitialization)
}