15 Commits

Author SHA1 Message Date
stefan 7d064853e5 feat: optimiere Gradle-Konfiguration für bessere Build-Performance (JVM, Worker, Cache) und dokumentiere Änderungen
Signed-off-by: StefanMoCoAt <stefan.mo.co@gmail.com>
2026-05-11 21:39:46 +02:00
stefan 387180c12c chore: entferne index.html
Signed-off-by: StefanMoCoAt <stefan.mo.co@gmail.com>
2026-05-11 20:44:35 +02:00
stefan 49393d3eac feat: verbessere Build-Performance durch Standard-Deaktivierung von WASM und aktualisiere Dokumentation
Signed-off-by: StefanMoCoAt <stefan.mo.co@gmail.com>
2026-05-11 20:44:24 +02:00
stefan e389fe9bce feat(desktop, network): Chat-Funktion hinzugefügt und P2P-Sync verbessert
Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
2026-05-11 13:57:53 +02:00
stefan 1a4753cd73 refactor(frontend): HTML-Styles aufgeräumt und Konsistenz verbessert
Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
2026-05-09 17:23:17 +02:00
stefan ece3f8bf78 feat(frontend): Grundlegendes HTML-Template für Website hinzugefügt
Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
2026-05-09 17:09:51 +02:00
stefan 8d176ce955 refactor(gradle, desktop): Build-Konfiguration bereinigt, Ports optimiert und UI-Logik konsolidiert
Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
2026-05-09 14:27:22 +02:00
stefan 280db663c7 chore(build, docs): Gradle auf 9.5.0 und Kotlin auf 2.3.21 aktualisiert
Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
2026-05-09 10:20:02 +02:00
stefan 74ef6424b7 docs(journal): Session-Log zu P2P-Guards, FilePicker-Fixes und Tests hinzugefügt
Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
2026-05-08 15:08:20 +02:00
stefan 3959168695 feat(core, network): Port-Guards für Mehrfachstarts von P2P-Server integriert
Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
2026-05-08 12:45:13 +02:00
stefan 04a435df1d refactor(core, desktop): Fehlertexte präzisiert und Verzeichnisauswahl für JFileChooser optimiert
Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
2026-05-08 12:25:58 +02:00
stefan 3aaf5cc59c feat(desktop, network): Fehlerhandling verbessert, Tools-Menü erweitert und mDNS-Discovery optimiert
Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
2026-05-07 17:18:17 +02:00
stefan a2d94bbc7e refactor(desktop, core): Exception-Handling optimiert und Divider-Komponente angepasst
Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
2026-05-07 15:44:08 +02:00
stefan 95a130c72e feat(desktop, device-initialization): Tools-Menü mit Backup-Option und Reset-Funktion ergänzt
Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
2026-05-07 15:42:12 +02:00
stefan 223bf77776 feat(core, network): lokale Chat-Kommunikation und WebSocket-Server hinzugefügt
Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
2026-05-07 10:58:31 +02:00
73 changed files with 1676 additions and 624 deletions
+4
View File
@@ -56,3 +56,7 @@ desktop.ini
docs/temp/
docs/Bin/
docs/_archive/
# Conveyor
conveyor.rootkey
output/
+2 -2
View File
@@ -5,8 +5,8 @@
# ===================================================================
# === CENTRALIZED BUILD ARGUMENTS ===
ARG GRADLE_VERSION=9.4.1
ARG JAVA_VERSION=25
ARG GRADLE_VERSION=9.5.0
ARG JAVA_VERSION=25.0.2
ARG BUILD_DATE
ARG VERSION=1.0.0-SNAPSHOT
@@ -5,8 +5,8 @@
# ===================================================================
# === CENTRALIZED BUILD ARGUMENTS ===
ARG GRADLE_VERSION=9.4.1
ARG JAVA_VERSION=25
ARG GRADLE_VERSION=9.5.0
ARG JAVA_VERSION=25.0.2
ARG BUILD_DATE
ARG VERSION=1.0.0-SNAPSHOT
+2 -2
View File
@@ -5,8 +5,8 @@
# ===================================================================
# === CENTRALIZED BUILD ARGUMENTS ===
ARG GRADLE_VERSION=9.4.1
ARG JAVA_VERSION=25
ARG GRADLE_VERSION=9.5.0
ARG JAVA_VERSION=25.0.2
ARG BUILD_DATE
ARG VERSION=1.0.0-SNAPSHOT
+2 -2
View File
@@ -5,8 +5,8 @@
# ===================================================================
# === CENTRALIZED BUILD ARGUMENTS ===
ARG GRADLE_VERSION=9.4.1
ARG JAVA_VERSION=25
ARG GRADLE_VERSION=9.5.0
ARG JAVA_VERSION=25.0.2
ARG BUILD_DATE
ARG VERSION=1.0.0-SNAPSHOT
+2 -2
View File
@@ -4,8 +4,8 @@
# ===================================================================
# === CENTRALIZED BUILD ARGUMENTS ===
ARG GRADLE_VERSION=9.4.1
ARG JAVA_VERSION=25
ARG GRADLE_VERSION=9.5.0
ARG JAVA_VERSION=25.0.2
ARG BUILD_DATE
ARG VERSION=1.0.0-SNAPSHOT
+2 -2
View File
@@ -5,8 +5,8 @@
# ===================================================================
# === CENTRALIZED BUILD ARGUMENTS ===
ARG GRADLE_VERSION=9.4.1
ARG JAVA_VERSION=25
ARG GRADLE_VERSION=9.5.0
ARG JAVA_VERSION=25.0.2
ARG BUILD_DATE
ARG VERSION=1.0.0-SNAPSHOT
+2 -2
View File
@@ -5,8 +5,8 @@
# ===================================================================
# === CENTRALIZED BUILD ARGUMENTS ===
ARG GRADLE_VERSION=9.4.1
ARG JAVA_VERSION=25
ARG GRADLE_VERSION=9.5.0
ARG JAVA_VERSION=25.0.2
ARG BUILD_DATE
ARG VERSION=1.0.0-SNAPSHOT
@@ -5,8 +5,8 @@
# ===================================================================
# === CENTRALIZED BUILD ARGUMENTS ===
ARG GRADLE_VERSION=9.4.1
ARG JAVA_VERSION=25
ARG GRADLE_VERSION=9.5.0
ARG JAVA_VERSION=25.0.2
ARG BUILD_DATE
ARG VERSION=1.0.0-SNAPSHOT
@@ -5,8 +5,8 @@
# ===================================================================
# === CENTRALIZED BUILD ARGUMENTS ===
ARG GRADLE_VERSION=9.4.1
ARG JAVA_VERSION=25
ARG GRADLE_VERSION=9.5.0
ARG JAVA_VERSION=25.0.2
ARG BUILD_DATE
ARG VERSION=1.0.0-SNAPSHOT
@@ -5,8 +5,8 @@
# ===================================================================
# === CENTRALIZED BUILD ARGUMENTS ===
ARG GRADLE_VERSION=9.4.1
ARG JAVA_VERSION=25
ARG GRADLE_VERSION=9.5.0
ARG JAVA_VERSION=25.0.2
ARG BUILD_DATE
ARG VERSION=1.0.0-SNAPSHOT
+2 -2
View File
@@ -5,8 +5,8 @@
# ===================================================================
# === CENTRALIZED BUILD ARGUMENTS ===
ARG GRADLE_VERSION=9.4.1
ARG JAVA_VERSION=25
ARG GRADLE_VERSION=9.5.0
ARG JAVA_VERSION=25.0.2
ARG BUILD_DATE
ARG VERSION=1.0.0-SNAPSHOT
+29 -11
View File
@@ -90,7 +90,7 @@ subprojects {
jvmArgs("--add-opens=java.base/java.nio=ALL-UNNAMED")
// Suppress ByteBuddy/Mockito dynamic agent loading warnings (Java 21+)
jvmArgs("-XX:+EnableDynamicAgentLoading")
// Increase test JVM memory with a stable configuration
jvmArgs("--enable-native-access=ALL-UNNAMED")
minHeapSize = "512m"
maxHeapSize = "2g"
// Parallel test execution for better performance
@@ -166,6 +166,7 @@ subprojects {
jvmArgs("-Xshare:auto", "-Djdk.instrument.traceUsage=false")
jvmArgs("--add-opens=java.base/java.nio=ALL-UNNAMED")
jvmArgs("-XX:+EnableDynamicAgentLoading")
jvmArgs("--enable-native-access=ALL-UNNAMED")
maxHeapSize = "2g"
dependsOn("testClasses")
}
@@ -189,6 +190,14 @@ subprojects {
// ------------------------------
// Detekt & Ktlint default setup
// ------------------------------
// PERFORMANCE: Deaktiviert standardmäßig in jedem Build, nur explizit ausführen
tasks.withType<Detekt>().configureEach {
enabled = project.hasProperty("runStaticAnalysis")
}
tasks.matching { it.name == "ktlintCheck" }.configureEach {
enabled = project.hasProperty("runStaticAnalysis")
}
plugins.withId("io.gitlab.arturbosch.detekt") {
extensions.configure(DetektExtension::class.java) {
buildUponDefaultConfig = true
@@ -372,27 +381,36 @@ val dokkaAll =
tasks.register("dokkaAll") {
group = "documentation"
description = "Builds Dokka (V2) for all modules and aggregates outputs under build/dokka/all"
// Trigger Dokka generation in all subprojects that have the Dokka plugin
dependsOn(
// PERFORMANCE: Nur ausführen wenn explizit gefordert
enabled = project.hasProperty("runDokka")
// Capture required values for configuration cache
val rootBuildDir = layout.buildDirectory.get().asFile
val subprojectData =
subprojects
.filter { it.plugins.hasPlugin("org.jetbrains.dokka") }
.map { "${it.path}:dokkaGenerate" },
)
.map { p ->
Triple(p.path, p.name, p.layout.buildDirectory.get().asFile)
}
// Trigger Dokka generation in all subprojects that have the Dokka plugin
dependsOn(subprojectData.map { "${it.first}:dokkaGenerate" })
doLast {
val dest = layout.buildDirectory.dir("dokka/all").get().asFile
val dest = File(rootBuildDir, "dokka/all")
if (dest.exists()) dest.deleteRecursively()
dest.mkdirs()
val modules = mutableListOf<Pair<String, String>>()
subprojects.filter { it.plugins.hasPlugin("org.jetbrains.dokka") }.forEach { p ->
subprojectData.forEach { (pPath, pName, pBuildDir) ->
// Dokka V2 writes into build/dokka/html
val outHtml = p.layout.buildDirectory.dir("dokka/html").get().asFile
val outHtml = File(pBuildDir, "dokka/html")
if (outHtml.exists()) {
val modulePath = p.path.trimStart(':').replace(':', '/')
val modulePath = pPath.trimStart(':').replace(':', '/')
val targetDir = File(dest, modulePath)
outHtml.copyRecursively(targetDir, overwrite = true)
modules.add(p.name to modulePath)
modules.add(pName to modulePath)
}
}
@@ -462,6 +480,6 @@ tasks.withType<Exec>().configureEach {
}
tasks.wrapper {
gradleVersion = "9.4.1"
gradleVersion = "9.5.0"
distributionType = Wrapper.DistributionType.BIN
}
+20 -36
View File
@@ -1,62 +1,46 @@
# =============================================================================
# Conveyor Configuration for Meldestelle Desktop App
# =============================================================================
# Dieser Build-Weg ermöglicht das Cross-Packaging für Windows (MSI) auf Linux.
# Dokumentation: https://conveyor.hydraulic.dev/
# =============================================================================
include required("/stdlib/jdk/21/amazon.conf")
include required("https://raw.githubusercontent.com/hydraulic-software/conveyor/master/configs/jvm/extract-native-libraries.conf")
# Basis-Import der Gradle-Konfiguration (sofern das Plugin genutzt wird,
# aber wir definieren es hier explizit für maximale Kontrolle im CI/CD).
app {
# Anzeige-Name und Vendor
display-name = "Meldestelle"
rdns-name = "at.mocode.meldestelle"
vendor = "mo-code.at"
contact-email = "support@mo-code.at"
version = "1.0.1"
description = "ÖTO-konforme Turnier-Meldestelle Profi Desktop App"
# Version aus version.properties (Conveyor kann HOCON-Variablen nutzen)
# Für diesen Task hart codiert oder via CLI-Flag --variable übergeben.
version = "1.0.0"
# Ziel-Plattformen: Windows und Linux
machines = [ windows.amd64, linux.amd64.glibc ]
# Beschreibung
description = "ÖTO-konforme Turnier-Meldestelle Desktop App"
# Ziel-Plattformen
# Wir konzentrieren uns auf Windows, können aber Linux/Mac später ergänzen.
site.base-url = "localhost" # Später echte Update-URL
# Icons
site.base-url = "localhost"
# Icons werden im Ordner gesucht
icons = "frontend/shells/meldestelle-desktop/src/jvmMain/resources/icon.png"
# Einbetten der JRE (Temurin 21 wie in CI genutzt)
jvm {
gui {
main-class = "at.mocode.frontend.shell.desktop.MainKt"
}
# JVM-Argumente (analog build.gradle.kts)
jvm-options = [
"-Xms128m",
"-Xmx512m",
"-Dfile.encoding=UTF-8"
"-Xms256m",
"-Xmx1024m",
"-Dfile.encoding=UTF-8",
"--enable-native-access=ALL-UNNAMED"
]
}
# Input-Dateien: Hier ziehen wir die Uber-JAR oder die Gradle-Outputs.
# Da wir plattformunabhängig bleiben wollen, nutzen wir das Gradle-Output-Dir.
inputs += "frontend/shells/meldestelle-desktop/build/libs/meldestelle-desktop-jvm-*.jar"
# JARs aus dem Gradle-Build
inputs += "frontend/shells/meldestelle-desktop/build/libs/*.jar"
# Windows-spezifische Einstellungen
windows {
# Icon als .ico
icons = "frontend/shells/meldestelle-desktop/src/jvmMain/resources/icon.ico"
# GUID für Upgrades (muss stabil bleiben)
upgrade-uuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
# Menü-Eintrag
menu-group = "Meldestelle"
# Verknüpfung
desktop-shortcut = true
}
linux {
debian.control.depends = "libasound2, libgl1-mesa-glx, libx11-6"
}
}
conveyor.compatibility-level = 22
+20 -20
View File
@@ -12,8 +12,8 @@ services:
context: .
dockerfile: backend/infrastructure/gateway/Dockerfile
args:
GRADLE_VERSION: "${DOCKER_GRADLE_VERSION:-9.4.1}"
JAVA_VERSION: "${DOCKER_JAVA_VERSION:-25}"
GRADLE_VERSION: "${DOCKER_GRADLE_VERSION:-9.5.0}"
JAVA_VERSION: "${DOCKER_JAVA_VERSION:-25.0.2}"
VERSION: "${DOCKER_VERSION:-1.0.0-SNAPSHOT}"
BUILD_DATE: "${DOCKER_BUILD_DATE}"
labels:
@@ -102,8 +102,8 @@ services:
context: .
dockerfile: backend/services/ping/Dockerfile
args:
GRADLE_VERSION: "${DOCKER_GRADLE_VERSION:-9.4.1}"
JAVA_VERSION: "${DOCKER_JAVA_VERSION:-25}"
GRADLE_VERSION: "${DOCKER_GRADLE_VERSION:-9.5.0}"
JAVA_VERSION: "${DOCKER_JAVA_VERSION:-25.0.2}"
VERSION: "${DOCKER_VERSION:-1.0.0-SNAPSHOT}"
BUILD_DATE: "${DOCKER_BUILD_DATE}"
labels:
@@ -179,8 +179,8 @@ services:
context: .
dockerfile: backend/services/masterdata/Dockerfile
args:
GRADLE_VERSION: "${DOCKER_GRADLE_VERSION:-9.4.1}"
JAVA_VERSION: "${DOCKER_JAVA_VERSION:-25}"
GRADLE_VERSION: "${DOCKER_GRADLE_VERSION:-9.5.0}"
JAVA_VERSION: "${DOCKER_JAVA_VERSION:-25.0.2}"
VERSION: "${DOCKER_VERSION:-1.0.0-SNAPSHOT}"
BUILD_DATE: "${DOCKER_BUILD_DATE}"
labels:
@@ -256,8 +256,8 @@ services:
context: .
dockerfile: backend/services/events/Dockerfile
args:
GRADLE_VERSION: "${DOCKER_GRADLE_VERSION:-9.4.1}"
JAVA_VERSION: "${DOCKER_JAVA_VERSION:-25}"
GRADLE_VERSION: "${DOCKER_GRADLE_VERSION:-9.5.0}"
JAVA_VERSION: "${DOCKER_JAVA_VERSION:-25.0.2}"
VERSION: "${DOCKER_VERSION:-1.0.0-SNAPSHOT}"
BUILD_DATE: "${DOCKER_BUILD_DATE}"
labels:
@@ -331,8 +331,8 @@ services:
context: .
dockerfile: backend/services/zns-import/Dockerfile
args:
GRADLE_VERSION: "${DOCKER_GRADLE_VERSION:-9.4.1}"
JAVA_VERSION: "${DOCKER_JAVA_VERSION:-25}"
GRADLE_VERSION: "${DOCKER_GRADLE_VERSION:-9.5.0}"
JAVA_VERSION: "${DOCKER_JAVA_VERSION:-25.0.2}"
VERSION: "${DOCKER_VERSION:-1.0.0-SNAPSHOT}"
BUILD_DATE: "${DOCKER_BUILD_DATE}"
labels:
@@ -407,8 +407,8 @@ services:
context: .
dockerfile: backend/services/results/results-service/Dockerfile
args:
GRADLE_VERSION: "${DOCKER_GRADLE_VERSION:-9.4.1}"
JAVA_VERSION: "${DOCKER_JAVA_VERSION:-25}"
GRADLE_VERSION: "${DOCKER_GRADLE_VERSION:-9.5.0}"
JAVA_VERSION: "${DOCKER_JAVA_VERSION:-25.0.2}"
VERSION: "${DOCKER_VERSION:-1.0.0-SNAPSHOT}"
BUILD_DATE: "${DOCKER_BUILD_DATE}"
labels:
@@ -482,8 +482,8 @@ services:
context: .
dockerfile: backend/services/billing/billing-service/Dockerfile
args:
GRADLE_VERSION: "${DOCKER_GRADLE_VERSION:-9.4.1}"
JAVA_VERSION: "${DOCKER_JAVA_VERSION:-25}"
GRADLE_VERSION: "${DOCKER_GRADLE_VERSION:-9.5.0}"
JAVA_VERSION: "${DOCKER_JAVA_VERSION:-25.0.2}"
VERSION: "${DOCKER_VERSION:-1.0.0-SNAPSHOT}"
BUILD_DATE: "${DOCKER_BUILD_DATE}"
labels:
@@ -555,8 +555,8 @@ services:
context: .
dockerfile: backend/services/mail/Dockerfile
args:
GRADLE_VERSION: "${DOCKER_GRADLE_VERSION:-9.4.1}"
JAVA_VERSION: "${DOCKER_JAVA_VERSION:-25}"
GRADLE_VERSION: "${DOCKER_GRADLE_VERSION:-9.5.0}"
JAVA_VERSION: "${DOCKER_JAVA_VERSION:-25.0.2}"
VERSION: "${DOCKER_VERSION:-1.0.0-SNAPSHOT}"
BUILD_DATE: "${DOCKER_BUILD_DATE}"
labels:
@@ -627,8 +627,8 @@ services:
context: .
dockerfile: backend/services/scheduling/scheduling-service/Dockerfile
args:
GRADLE_VERSION: "${DOCKER_GRADLE_VERSION:-9.4.1}"
JAVA_VERSION: "${DOCKER_JAVA_VERSION:-25}"
GRADLE_VERSION: "${DOCKER_GRADLE_VERSION:-9.5.0}"
JAVA_VERSION: "${DOCKER_JAVA_VERSION:-25.0.2}"
VERSION: "${DOCKER_VERSION:-1.0.0-SNAPSHOT}"
BUILD_DATE: "${DOCKER_BUILD_DATE}"
labels:
@@ -700,8 +700,8 @@ services:
context: .
dockerfile: backend/services/series/series-service/Dockerfile
args:
GRADLE_VERSION: "${DOCKER_GRADLE_VERSION:-9.4.1}"
JAVA_VERSION: "${DOCKER_JAVA_VERSION:-25}"
GRADLE_VERSION: "${DOCKER_GRADLE_VERSION:-9.5.0}"
JAVA_VERSION: "${DOCKER_JAVA_VERSION:-25.0.2}"
VERSION: "${DOCKER_VERSION:-1.0.0-SNAPSHOT}"
BUILD_DATE: "${DOCKER_BUILD_DATE}"
labels:
+1
View File
@@ -88,6 +88,7 @@ Fokus: Physische Implementierung der Turnier-Hierarchie und technisches Onboardi
* [x] **Client-Konfiguration:** Master kann nun Clients in der UI hinzufügen und bearbeiten.
* [x] **Master-UX:** Konfiguration beim Start nicht mehr zwangsgesperrt.
* [x] **Cross-Packaging (Conveyor):** Windows-Build auf Linux-CI ermöglicht (x64-Abhängigkeit identifiziert).
* [x] **Build-Performance:** WASM standardmäßig deaktiviert, um Desktop-Build-Zeiten zu reduzieren (11.05.2026).
* [ ] **PoC Verifikation:** 🔴 **BLOCKIERT** (Log 483: ARM64-Runner inkompatibel mit Conveyor-Binary; Workflow auf
manuell gesetzt).
@@ -5,23 +5,23 @@ owner: Lead Architect
date: 2026-02-02
---
# Engineering Moderner Frontend-Architekturen: Kotlin 2.3.0, Compose Multiplatform 1.10.0 und Gradle 9.0 für Modulare Monolithen
# Engineering Moderner Frontend-Architekturen: Kotlin 2.3.21, Compose Multiplatform 1.10.0 und Gradle 9.5.0 für Modulare Monolithen
Der architektonische Übergang zu modularen Monolithen bietet Unternehmen die Möglichkeit, die Komplexität von
Microservices zu reduzieren und gleichzeitig eine klare Trennung der Domänenlogik beizubehalten. In Kombination mit
Kotlin Multiplatform (KMP) für Single Page Applications (SPAs) lässt sich Geschäftslogik effizient über den gesamten
Stack teilen. Die Einführung von Kotlin 2.3.0, Compose Multiplatform 1.10.0 und Gradle 9.0 stellt dabei neue Best
Stack teilen. Die Einführung von Kotlin 2.3.21, Compose Multiplatform 1.10.0 und Gradle 9.5.0 stellt dabei neue Best
Practices für Build-Performance und Deployment auf.
## 1. Gradle 9.x Optimierung in der CI/CD
Gradle 9.0 führt signifikante Änderungen ein, die speziell für große Multi-Modul-Projekte wie modulare Monolithen
Gradle 9.5.0 führt signifikante Änderungen ein, die speziell für große Multi-Modul-Projekte wie modulare Monolithen
optimiert sind.
- **Configuration Cache als Standard:** In Gradle 9.0 ist der Configuration Cache der bevorzugte Ausführungsmodus. Durch
- **Configuration Cache als Standard:** In Gradle 9.5.0 ist der Configuration Cache der bevorzugte Ausführungsmodus. Durch
das Caching des Task-Graphen werden nachfolgende Builds erheblich beschleunigt, da die Konfigurationsphase
übersprungen wird.
- **Kotlin DSL Script Compilation Avoidance:** Durch den Einsatz von ABI-Fingerprinting erkennt Gradle 9.0, ob
- **Kotlin DSL Script Compilation Avoidance:** Durch den Einsatz von ABI-Fingerprinting erkennt Gradle 9.5.0, ob
Änderungen an `.kts`-Dateien die Build-Logik tatsächlich beeinflussen. Nicht-relevante Änderungen (wie Kommentare)
führen nicht mehr zur Neukompilierung, was die Konfigurationszeit um bis zu 60 % reduzieren kann.
- **Parallel Configuration Store and Load:** Gradle 8.11 und 9.0 unterstützen das parallele Laden und Speichern von
@@ -74,14 +74,14 @@ Die Wahl des Webservers beeinflusst das Routing und die Performance der Compose
| Komponente | Empfehlung | Vorteil |
|---------------|-------------------------------------|--------------------------------------------------------|
| Build-Tool | Gradle 9.0 mit Configuration Cache | Extreme Verkürzung der Konfigurationsphase |
| Build-Tool | Gradle 9.5.0 mit Configuration Cache | Extreme Verkürzung der Konfigurationsphase |
| CI Caching | Remote Cache Action (Proxy) | Wiederverwendung von Task-Outputs auf frischen Runnern |
| Konfiguration | Runtime config.json Fetch | Ein Docker-Image für alle Umgebungen (Dev/Prod) |
| Webserver | Caddy oder Nginx | Optimiertes SPA-Routing und Web Cache Support |
## Fazit
Die Kombination aus Gradle 9.0 und Kotlin 2.3.0 ermöglicht hocheffiziente Build-Pipelines für modulare Monolithen. Durch
Die Kombination aus Gradle 9.5.0 und Kotlin 2.3.21 ermöglicht hocheffiziente Build-Pipelines für modulare Monolithen. Durch
den Einsatz von Multi-Stage Docker-Builds und dem `config.json`-Fetch-Muster wird eine moderne, skalierbare
Deployment-Strategie umgesetzt, die die neuen Performance-Features von Compose Multiplatform 1.10.0 optimal nutzt.
@@ -9,7 +9,7 @@ Hier ist der Quellcode des Berichts im Markdown-Format:
## Zusammenfassung
Die Softwareentwicklungslandschaft des Jahres 2026, geprägt durch die Veröffentlichung von Kotlin 2.3.0 und Gradle 9.1.0, bietet Entwicklern beispiellose Möglichkeiten zur Vereinheitlichung komplexer Geschäftslogik über Plattformgrenzen hinweg. Dieser Forschungsbericht analysiert detailliert die architektonischen Muster, Implementierungsstrategien und zugrundeliegenden Mechanismen, die für den Aufbau einer robusten, asynchronen Offline-First-Anwendung erforderlich sind. Der Fokus liegt hierbei auf der Integration von SQLDelight in einer Kotlin Multiplatform (KMP) Umgebung, die sowohl Desktop (JVM) als auch Web (Kotlin/JS) Ziele bedient, eingebettet in eine Mikro-Frontend-Architektur.
Die Softwareentwicklungslandschaft des Jahres 2026, geprägt durch die Veröffentlichung von Kotlin 2.3.21 und Gradle 9.5.0, bietet Entwicklern beispiellose Möglichkeiten zur Vereinheitlichung komplexer Geschäftslogik über Plattformgrenzen hinweg. Dieser Forschungsbericht analysiert detailliert die architektonischen Muster, Implementierungsstrategien und zugrundeliegenden Mechanismen, die für den Aufbau einer robusten, asynchronen Offline-First-Anwendung erforderlich sind. Der Fokus liegt hierbei auf der Integration von SQLDelight in einer Kotlin Multiplatform (KMP) Umgebung, die sowohl Desktop (JVM) als auch Web (Kotlin/JS) Ziele bedient, eingebettet in eine Mikro-Frontend-Architektur.
Ein zentraler Schwerpunkt dieser Arbeit ist die Überbrückung der Dichotomie zwischen der synchronen Natur klassischer JVM-Datenbanktreiber und der inhärent asynchronen, Event-Loop-basierten Umgebung des modernen Web (insbesondere unter Nutzung von Web Workern und OPFS). Darüber hinaus wird die fortgeschrittene Integration von Persistenzschichten in einem Mikro-Frontend-Ökosystem untersucht, um sicherzustellen, dass eine einzige Quelle der Wahrheit („Single Source of Truth“) über unabhängig bereitgestellte Frontend-Einheiten hinweg konsistent bleibt.
@@ -17,11 +17,11 @@ Ein zentraler Schwerpunkt dieser Arbeit ist die Überbrückung der Dichotomie zw
### 1.1 Die Evolution von Kotlin Multiplatform
Mit der Veröffentlichung von Kotlin 2.3.0 im Dezember 2025 hat sich das Ökosystem von einer experimentellen Technologie zu einem stabilen Standard für Enterprise-Architekturen entwickelt. Während frühere Versionen oft mit Inkonsistenzen zwischen den Compilern (JVM vs. JS/Native) zu kämpfen hatten, bietet der K2-Compiler in Version 2.3.0 eine vereinheitlichte Frontend-IR (Intermediate Representation), die eine robustere statische Analyse und performantere Kompilierung ermöglicht. Dies ist entscheidend für komplexe Multi-Modul-Projekte, wie sie in Mikro-Frontend-Architekturen üblich sind.
Mit der Veröffentlichung von Kotlin 2.3.21 im Dezember 2025 hat sich das Ökosystem von einer experimentellen Technologie zu einem stabilen Standard für Enterprise-Architekturen entwickelt. Während frühere Versionen oft mit Inkonsistenzen zwischen den Compilern (JVM vs. JS/Native) zu kämpfen hatten, bietet der K2-Compiler in Version 2.3.21 eine vereinheitlichte Frontend-IR (Intermediate Representation), die eine robustere statische Analyse und performantere Kompilierung ermöglicht. Dies ist entscheidend für komplexe Multi-Modul-Projekte, wie sie in Mikro-Frontend-Architekturen üblich sind.
### 1.2 Gradle 9.1.0: Die Build-Infrastruktur
### 1.2 Gradle 9.5.0: Die Build-Infrastruktur
Gradle 9.1.0, veröffentlicht im September 2025, hat die Art und Weise, wie KMP-Projekte konfiguriert werden, grundlegend verändert. Mit der vollständigen Unterstützung des „Configuration Cache“ und der strikten „Project Isolation“ zwingt es Entwickler zu sauberen Modulgrenzen. Für unser Szenario bedeutet dies, dass die Abhängigkeiten zwischen dem `shared`-Modul (Datenbank) und den konsumierenden Mikro-Frontends explizit und ohne Seiteneffekte definiert werden müssen, um die parallele Ausführung und inkrementelle Kompilierung nicht zu gefährden.
Gradle 9.5.0, veröffentlicht im September 2025, hat die Art und Weise, wie KMP-Projekte konfiguriert werden, grundlegend verändert. Mit der vollständigen Unterstützung des „Configuration Cache“ und der strikten „Project Isolation“ zwingt es Entwickler zu sauberen Modulgrenzen. Für unser Szenario bedeutet dies, dass die Abhängigkeiten zwischen dem `shared`-Modul (Datenbank) und den konsumierenden Mikro-Frontends explizit und ohne Seiteneffekte definiert werden müssen, um die parallele Ausführung und inkrementelle Kompilierung nicht zu gefährden.
### 1.3 Die Problemstellung: Synchron vs. Asynchron
@@ -38,12 +38,12 @@ SQLDelight 2.0+ adressiert dieses Problem mit der Konfiguration `generateAsync =
In einer Offline-First-Architektur fungiert die lokale Datenbank nicht als bloßer Cache, sondern als primäre Quelle der Wahrheit. Die Benutzeroberfläche (UI) kommuniziert niemals direkt mit dem Netzwerk.
| Konzept | Traditionelle Architektur | Offline-First Architektur |
| --- | --- | --- |
| **Datenquelle** | Remote API (REST/GraphQL) | Lokale Datenbank (SQLite) |
| **Lesepfad** | UI ruft Netzwerk auf -> Wartet -> Zeigt an | UI beobachtet Datenbank (Flow) -> Zeigt an |
| **Schreibpfad** | UI sendet an API -> Wartet auf OK -> Aktualisiert UI | UI schreibt in DB -> DB emittiert neue Daten -> Sync im Hintergrund |
| **Netzwerkstatus** | Voraussetzung für Funktionalität | Optional; beeinflusst nur Synchronisation |
| Konzept | Traditionelle Architektur | Offline-First Architektur |
|--------------------|------------------------------------------------------|---------------------------------------------------------------------|
| **Datenquelle** | Remote API (REST/GraphQL) | Lokale Datenbank (SQLite) |
| **Lesepfad** | UI ruft Netzwerk auf -> Wartet -> Zeigt an | UI beobachtet Datenbank (Flow) -> Zeigt an |
| **Schreibpfad** | UI sendet an API -> Wartet auf OK -> Aktualisiert UI | UI schreibt in DB -> DB emittiert neue Daten -> Sync im Hintergrund |
| **Netzwerkstatus** | Voraussetzung für Funktionalität | Optional; beeinflusst nur Synchronisation |
Dieses Prinzip der „Inversion of Control“ entkoppelt die User Experience von Netzwerklatenz und -verfügbarkeit. In SQLDelight wird dies durch Reactive Extensions realisiert, die SQL-Abfragen als `Flow<T>` exponieren, die sich bei Datenänderungen automatisch aktualisieren.
@@ -60,8 +60,8 @@ Das Fundament eines stabilen KMP-Projekts ist eine präzise Gradle-Konfiguration
### 3.1 Version Catalog (`gradle/libs.versions.toml`)toml
[versions]
kotlin = "2.3.0"
gradle = "9.1.0"
kotlin = "2.3.21"
gradle = "9.5.0"
sqldelight = "2.1.0"
coroutines = "1.10.1" # Hypothetische Version passend zu Kotlin 2.3
ktor = "3.1.0"
@@ -5,20 +5,20 @@ owner: Lead Architect
tags: [kotlin, java, configuration, setup]
---
# Tech-Stack Referenz: Kotlin 2.3.0 & Java 25 (KMP)
# Tech-Stack Referenz: Kotlin 2.3.21 & Java 25 (KMP)
**Kontext:** Dieses Dokument beschreibt die notwendigen Konfigurationen, um Kotlin 2.3.0 mit Java 25 in einem Kotlin Multiplatform (KMP) Projekt mit Gradle 9.x zu verwenden.
**Kontext:** Dieses Dokument beschreibt die notwendigen Konfigurationen, um Kotlin 2.3.21 mit Java 25 in einem Kotlin Multiplatform (KMP) Projekt mit Gradle 9.x zu verwenden.
---
### 1. Kern-Spezifikationen
| Komponente | Version | Status |
| --- |----------| --- |
| **Kotlin** | `2.3.0` | Stabil (K2 Compiler standardmäßig aktiv) |
| **Java (JDK)** | `25` | LTS (Long-Term Support) |
| **Gradle** | `9.2.1` | Erforderlich für JDK 25 Support |
| **Android Plugin (AGP)** | `8.8.0+` | Empfohlen für Gradle 9.x Kompatibilität |
| Komponente | Version | Status |
|--------------------------|----------|------------------------------------------|
| **Kotlin** | `2.3.21` | Stabil (K2 Compiler standardmäßig aktiv) |
| **Java (JDK)** | `25` | LTS (Long-Term Support) |
| **Gradle** | `9.5.0` | Erforderlich für JDK 25 Support |
| **Android Plugin (AGP)** | `8.8.0+` | Empfohlen für Gradle 9.x Kompatibilität |
---
@@ -28,7 +28,7 @@ Für ein **Kotlin Multiplatform (KMP)** Projekt ist die Java Toolchain-Konfigura
```kotlin
plugins {
kotlin("multiplatform") version "2.3.0"
kotlin("multiplatform") version "2.3.21"
id("com.android.library") version "8.8.0" // Falls Android Target genutzt wird
}
@@ -60,15 +60,15 @@ Damit das Projekt Java 25 erkennt, muss der Wrapper auf dem neuesten Stand sein:
**Terminal-Befehl:**
```bash
./gradlew wrapper --gradle-version 9.2.1 --distribution-type all
./gradlew wrapper --gradle-version 9.5.0 --distribution-type all
```
---
### 4. Wichtige Kompatibilitätshinweise
* **IDE-Version:** IntelliJ IDEA 2025.3 (oder neuer) wird für die volle Unterstützung von JDK 25 und dem Kotlin 2.3.0 Plugin empfohlen.
* **K2 Compiler:** Kotlin 2.3.0 nutzt den K2-Compiler.
* **IDE-Version:** IntelliJ IDEA 2025.3 (oder neuer) wird für die volle Unterstützung von JDK 25 und dem Kotlin 2.3.21 Plugin empfohlen.
* **K2 Compiler:** Kotlin 2.3.21 nutzt den K2-Compiler.
* **Bytecode:** Java 25 Bytecode wird nur generiert, wenn das `jvmTarget` explizit auf `25` gesetzt ist.
---
@@ -76,4 +76,4 @@ Damit das Projekt Java 25 erkennt, muss der Wrapper auf dem neuesten Stand sein:
### 5. Bekannte Features in diesem Setup
* **Java 25 Features:** Unterstützung für die finalen Versionen von *Scoped Values* und *Structured Concurrency*.
* **Kotlin 2.3.0 Features:** Nutzung von `explicit backing fields` und dem verbesserten `unused return value` Checker.
* **Kotlin 2.3.21 Features:** Nutzung von `explicit backing fields` und dem verbesserten `unused return value` Checker.
@@ -5,7 +5,7 @@ owner: Lead Architect
tags: [kotlin, release-notes, tech-stack]
---
# Was ist neu in Kotlin 2.3.0
# Was ist neu in Kotlin 2.3.21
**Quelle:** [Offizielle Kotlin-Dokumentation](https://kotlinlang.org/docs/whatsnew23.html)
**Datum des Dokuments:** 16. Dezember 2025
@@ -13,7 +13,7 @@ tags: [kotlin, release-notes, tech-stack]
---
Kotlin 2.3.0 ist erschienen! Hier sind die wichtigsten Highlights:
Kotlin 2.3.21 ist erschienen! Hier sind die wichtigsten Highlights:
* **Sprache:** Mehr stabile und standardmäßig aktivierte Features, Checker für ungenutzte Rückgabewerte, explizite Backing Fields und Änderungen bei der kontextsensitiven Auflösung.
* **Kotlin/JVM:** Unterstützung für Java 25.
@@ -26,7 +26,7 @@ Kotlin 2.3.0 ist erschienen! Hier sind die wichtigsten Highlights:
## Sprache
Kotlin 2.3.0 konzentriert sich auf die Stabilisierung von Features, führt einen neuen Mechanismus zur Erkennung ungenutzter Rückgabewerte ein und verbessert die kontextsensitive Auflösung.
Kotlin 2.3.21 konzentriert sich auf die Stabilisierung von Features, führt einen neuen Mechanismus zur Erkennung ungenutzter Rückgabewerte ein und verbessert die kontextsensitive Auflösung.
### Stabile Features
@@ -38,13 +38,13 @@ Folgende Features sind nun stabil:
* Unterstützung für `return`-Anweisungen in Ausdrucks-Bodies mit explizitem Rückgabetyp ist nun standardmäßig aktiviert.
### Experimentell: Checker für ungenutzte Rückgabewerte
Kotlin 2.3.0 führt den Checker für ungenutzte Rückgabewerte ein, um das versehentliche Ignorieren von Ergebnissen zu verhindern.
Kotlin 2.3.21 führt den Checker für ungenutzte Rückgabewerte ein, um das versehentliche Ignorieren von Ergebnissen zu verhindern.
### Experimentell: Explizite Backing Fields
Eine neue Syntax zur expliziten Deklaration des zugrundeliegenden Felds, das den Wert einer Property hält vereinfacht das verbreitete Backing-Properties-Muster.
## Kotlin/JVM: Unterstützung für Java 25
Ab Kotlin 2.3.0 kann der Compiler Klassen mit Java-25-Bytecode generieren.
Ab Kotlin 2.3.21 kann der Compiler Klassen mit Java-25-Bytecode generieren.
## Kotlin/Native
* **Verbesserter Swift-Export:** Direkte Zuordnung für native Enum-Klassen und variadische Funktionsparameter.
@@ -22,7 +22,7 @@ Microservices Backend** auf einer vollständig self-hosted Infrastruktur.
```
┌─────────────────────────────────────────────────────────────────┐
│ Frontend (KMP) │ Backend (Spring Boot / Kotlin JVM) │
│ Kotlin 2.3 / Compose │ Java 25 / Spring Boot 3.5.9 │
│ Kotlin 2.3.21 / Compose │ Java 25 / Spring Boot 3.5.9 │
│ JS + WASM (geplant) │ Microservices + API-Gateway │
├─────────────────────────────────────────────────────────────────┤
│ Infrastruktur (Self-Hosted auf Zora / Proxmox) │
@@ -37,10 +37,10 @@ Microservices Backend** auf einer vollständig self-hosted Infrastruktur.
| Komponente | Sprache / Runtime | Version |
|:-----------|:-----------------------|:--------|
| Backend | Kotlin (JVM) | 2.3.0 |
| Frontend | Kotlin (KMP / JS) | 2.3.0 |
| Backend | Kotlin (JVM) | 2.3.21 |
| Frontend | Kotlin (KMP / JS) | 2.3.21 |
| JVM | Java (Eclipse Temurin) | 25 (EA) |
| Build | Gradle (Kotlin DSL) | 9.3.1 |
| Build | Gradle (Kotlin DSL) | 9.5.0 |
| Plattform | ARM64 (AArch64) | Linux |
---
@@ -51,13 +51,13 @@ Microservices Backend** auf einer vollständig self-hosted Infrastruktur.
| Bibliothek | Version | Zweck |
|:----------------------|:--------|:---------------------------------|
| Kotlin Multiplatform | 2.3.0 | Cross-Platform-Basis (JS + WASM) |
| Kotlin Multiplatform | 2.3.21 | Cross-Platform-Basis (JS + WASM) |
| Compose Multiplatform | 1.10.0 | UI-Framework (Deklarativ) |
| Compose Hot Reload | 1.0.0 | Live-Reload im Dev-Modus |
| Koin (DI) | 4.1.1 | Dependency Injection |
| Koin Compose | 4.1.1 | DI-Integration für Compose |
| Ktor Client | 3.4.0 | HTTP-Client (Multiplatform) |
| Kotlin Serialization | 2.3.0 | JSON-Serialisierung |
| Kotlin Serialization | 2.3.21 | JSON-Serialisierung |
### 3.2 Persistenz (Offline-First)
@@ -105,7 +105,7 @@ frontend/
| Spring Data JPA | (Boot) | ORM-Layer |
| Spring Data Valkey | 0.2.0 | Cache-Integration (Valkey/Redis) |
| Spring WebFlux | (Boot) | Reaktive API (Gateway) |
| Kotlin Coroutines | 2.3.0 | Async/Non-blocking |
| Kotlin Coroutines | 2.3.21 | Async/Non-blocking |
### 4.2 Persistenz
@@ -348,7 +348,7 @@ Mittlere Priorität:
TECH-STACK KOMPLEXITÄT
──────────────────────────────────────────────────────
Sprachen: Kotlin (JVM + KMP/JS)
Build: Gradle 9.3.1 + Kotlin DSL + libs.versions.toml
Build: Gradle 9.5.0 + Kotlin DSL + libs.versions.toml
Frontend: Compose Multiplatform 1.10 + SQLDelight 2.2 + Koin 4.1
Backend: Spring Boot 3.5.9 + Spring Cloud 2025.0.1
Persistenz: PostgreSQL 16 + Exposed 1.0 + Flyway 11 + HikariCP 7
@@ -13,7 +13,7 @@ Feature-Implementierung an der verfeinerten DDD-Struktur (ADR-0014) sowie der De
### 🟢 Technische Stabilisierung
* **Kotlin 2.3.20:** Alle Module wurden auf Kotlin 2.3.20 migriert. Deprecation-Warnungen für `Clock` und `Instant`
* **Kotlin 2.3.21:** Alle Module wurden auf Kotlin 2.3.21 migriert. Deprecation-Warnungen für `Clock` und `Instant`
wurden durch Standardisierung auf `kotlin.time.*` behoben.
* **Zentralisierte Serialisierung:** Erstellung der `Serializers.kt` im `core-domain` Modul für `Uuid`, `Instant`,
`LocalDate`, `LocalDateTime` und `LocalTime`.
@@ -11,17 +11,17 @@ last_update: 2026-01-20
Angenommen
## Kontext
Das Projekt "Meldestelle" setzt auf einen sehr modernen Technologie-Stack (Java 25, Kotlin 2.3.0, Spring Boot 3.5.9). Eine Analyse im Januar 2026 hat jedoch kritische Versionskonflikte aufgedeckt, die die Stabilität des Builds und der Laufzeitumgebung gefährden.
Das Projekt "Meldestelle" setzt auf einen sehr modernen Technologie-Stack (Java 25, Kotlin 2.3.21, Spring Boot 3.5.9). Eine Analyse im Januar 2026 hat jedoch kritische Versionskonflikte aufgedeckt, die die Stabilität des Builds und der Laufzeitumgebung gefährden.
1. **Spring Cloud Konflikt:** Der Release Train `2025.1.0` (Oakwood) ist für Spring Boot 4.0 konzipiert und inkompatibel mit Spring Boot 3.5.9 (führt zu `NoSuchMethodError`).
2. **Compose Multiplatform:** Version `1.9.3` führt zu Compiler-Crashes in Verbindung mit Kotlin 2.3.0.
3. **Exposed:** Version `0.61.0` ist veraltet und inkompatibel mit Kotlin 2.3.0.
2. **Compose Multiplatform:** Version `1.9.3` führt zu Compiler-Crashes in Verbindung mit Kotlin 2.3.21.
3. **Exposed:** Version `0.61.0` ist veraltet und inkompatibel mit Kotlin 2.3.21.
## Entscheidung
Wir führen folgende Korrekturen am Tech-Stack durch, um eine stabile "Best Compatibility List" zu etablieren:
1. **Spring Cloud Downgrade:** Wechsel auf Release Train `2025.0.1` (Northfields), der offiziell für Spring Boot 3.5.x freigegeben ist.
2. **Compose Multiplatform Upgrade:** Wechsel auf `1.10.0-rc02` (oder stable), um volle Kotlin 2.3.0 Kompatibilität zu gewährleisten.
2. **Compose Multiplatform Upgrade:** Wechsel auf `1.10.0-rc02` (oder stable), um volle Kotlin 2.3.21 Kompatibilität zu gewährleisten.
3. **Exposed Upgrade:** Wechsel auf `1.0.0-rc-4` (oder neuer), um Bytecode-Inkompatibilitäten zu beheben.
4. **Micrometer Upgrade:** Explizites Setzen von Version `1.16.1` für verbesserten Java 25 (Virtual Threads) Support.
@@ -29,7 +29,7 @@ Wir führen folgende Korrekturen am Tech-Stack durch, um eine stabile "Best Comp
### Positiv
* **Stabilität:** Der Build und die Application Context Initialisierung sind wieder stabil.
* **Zukunftssicherheit:** Wir nutzen weiterhin die neuesten Features von Java 25 und Kotlin 2.3.0, aber in einer validierten Kombination.
* **Zukunftssicherheit:** Wir nutzen weiterhin die neuesten Features von Java 25 und Kotlin 2.3.21, aber in einer validierten Kombination.
* **Wartbarkeit:** Die `libs.versions.toml` spiegelt nun eine getestete Konfiguration wider.
### Negativ
@@ -0,0 +1,92 @@
# 🛠️ Guide: Conveyor Installation
Dieses Dokument beschreibt die Installation von **Hydraulic Conveyor** auf verschiedenen Linux-Distributionen (Ubuntu
26.04 und Fedora 44).
---
## 1. Ubuntu 26.04 (Debian-basiert)
Der am einfachsten Weg für Ubuntu ist der direkte Download des `.deb`-Pakets. Dieses konfiguriert bei der Installation
automatisch das APT-Repository für zukünftige Updates.
### Installation via .deb (Empfohlen)
```bash
# Aktuelles Paket herunterladen (Beispiel v12.0 - bitte Version ggf. anpassen)
VERSION="12.0"
curl -L https://downloads.hydraulic.dev/conveyor/conveyor_${VERSION}_amd64.deb -o conveyor.deb
# Installieren (konfiguriert auch das Repo automatisch)
sudo apt update
sudo apt install ./conveyor.deb
```
---
## 2. Fedora 44 (RPM-basiert)
Für Fedora wird die Installation via Tarball empfohlen, da Conveyor als autarkes Binary geliefert wird.
### Installation via Tarball (Systemweit)
Dies ist der zuverlässigste Weg für Fedora:
```bash
# Version definieren (Beispiel v12.0, bitte aktuelle Version prüfen)
VERSION="12.0"
curl -L https://downloads.hydraulic.dev/conveyor/conveyor-${VERSION}-linux-amd64.tar.gz -o conveyor.tar.gz
# Entpacken nach /opt
sudo tar -xzf conveyor.tar.gz -C /opt/
sudo ln -s /opt/conveyor-${VERSION}/bin/conveyor /usr/local/bin/conveyor
# Test
conveyor --version
```
### Installation via RPM (Falls verfügbar)
Prüfen Sie auf der Hydraulic Website, ob mittlerweile ein natives RPM-Repository existiert. Falls ja:
```bash
sudo dnf config-manager --add-repo https://conveyor.hydraulic.dev/rpm/conveyor.repo
sudo dnf install conveyor
```
---
## 3. Post-Installation & Verifikation
Nach der Installation sollten Sie den Pfad und die Version prüfen:
```bash
conveyor --version
```
### Root-Key Initialisierung
Beim ersten Ausführen von `conveyor` wird ein Root-Key generiert. **Sichern Sie diesen unbedingt!**
```bash
conveyor make site
```
*Folgen Sie den Anweisungen im Terminal zur Sicherung des Root-Keys.*
---
## 4. Troubleshooting
### Fehlende Bibliotheken (Fedora)
Falls Conveyor native Hilfe benötigt (z.B. für Icons oder Kompression):
```bash
sudo dnf install libX11 libXext libXrender
```
### Berechtigungen
Stellen Sie sicher, dass Ihr Benutzer in der Gruppe `docker` ist, falls Sie Conveyor innerhalb von Containern nutzen
oder Docker-basierte Inputs verwenden (für dieses Projekt primär lokal relevant).
+112
View File
@@ -0,0 +1,112 @@
# 📦 Guide: Desktop App Packaging (Conveyor & Gradle)
Dieses Dokument beschreibt den professionellen Packaging-Prozess für die Meldestelle Desktop App. Wir nutzen **Conveyor** als primäres Werkzeug für das Cross-Platform Packaging (Windows, Linux, macOS), da es stabile Installer inklusive signierter Updates und gebündelter JREs erzeugt.
---
## 1. Strategie: Conveyor vs. Gradle
| Feature | Conveyor (Empfohlen) | Gradle (Compose Plugin) |
| :--- | :--- | :--- |
| **Zielgruppe** | Endanwender (Produktion) | Entwickler (Lokaler Test) |
| **Plattformen** | Windows (.msix), Linux (.deb), macOS | Nur Host-OS (Linux auf Linux) |
| **Updates** | Automatisch integriert | Manuell |
| **JRE** | Amazon Corretto (isoliert) | System JRE oder Toolchain |
---
## 2. Cross-Packaging mit Conveyor
Conveyor ist so konfiguriert, dass es von Linux aus Pakete für alle Zielsysteme schnüren kann.
### Voraussetzungen
1. **JAR-Dateien:** Die App muss kompiliert sein:
```bash
./gradlew :frontend:shells:meldestelle-desktop:jvmJar
```
2. **Icons:** Das System sucht nach `icon.png` in `frontend/shells/meldestelle-desktop/src/jvmMain/resources/`.
### Pakete bauen
Führen Sie Conveyor im Projekt-Root aus:
```bash
# Komplette Release-Site (Windows & Linux)
conveyor make site
# Nur ein spezifisches Paket (schneller für Tests)
conveyor make debian-package # Linux .deb
conveyor make windows-msix # Windows .msix
```
Die Ergebnisse liegen im Ordner `output/`.
---
## 3. Konfiguration (`conveyor.conf`)
Wichtige Parameter der aktuellen Konfiguration (v1.0.1):
* **JDK:** Nutzt `Amazon Corretto 21` für maximale Cross-Platform Stabilität.
* **Heap-Size:** Erhöht auf `-Xmx1024m`, um auch große Stammdaten-Importe zu bewältigen.
* **Linux-Deps:** Automatische Installation von `libasound2`, `libgl1-mesa-glx` und `libx11-6`.
* **Native Access:** `--enable-native-access=ALL-UNNAMED` ist für Netty/SQLite aktiviert.
---
## 4. Netzwerk & Sicherheit (WICHTIG)
Damit die P2P-Funktionen (Chat, Discovery, Sync) nach der Installation funktionieren, müssen folgende Ports auf dem Host-System offen sein:
| Port | Protokoll | Funktion |
| :--- | :--- | :--- |
| **8080** | TCP | P2P Sync & Datenabgleich |
| **8090** | TCP | Veranstaltungs-Chat (WebSocket) |
| **5353** | UDP | mDNS Discovery (Geräte finden) |
### Firewall-Einrichtung (Linux)
Nutzen Sie das optimierte Setup-Script:
```bash
sudo ./setup-firewall-linux.sh
```
### Windows-Besonderheit
Beim ersten Start der `.msix` App wird Windows fragen, ob der Netzwerkzugriff erlaubt werden soll. **Wichtig:** Sowohl "Private" als auch "Öffentliche" Netzwerke anhaken, falls auf Turnieren oft Gast-WLANs oder Hotspots genutzt werden.
---
## 5. Troubleshooting
### Problem: "No main class specified"
**Lösung:** Stellen Sie sicher, dass in der `Main.kt` eine saubere Top-Level `fun main()` existiert und in der `conveyor.conf` auf `at.mocode.frontend.shell.desktop.MainKt` verwiesen wird.
### Problem: SQLite / Native Libs laden nicht
**Lösung:** Prüfen Sie, ob `extract-native-libraries.conf` in der `conveyor.conf` inkludiert ist.
### Problem: JmDNS findet keine Teilnehmer
**Lösung:** Prüfen Sie die Ports via `ss -tulpn`. Auf Linux blockieren oft Docker-Interfaces (`br-*`) den Broadcast. Die App filtert diese nun automatisch, aber ein aktives `setup-firewall-linux.sh` ist zwingend erforderlich.
## 6. Performance-Optimierung (Gradle)
Der Build-Prozess kann bei aktivierter Web-Kompilierung (WASM/JS) sehr lange dauern. Für die reine Desktop-Entwicklung
wurde WASM standardmäßig deaktiviert.
* **WASM aktivieren (z.B. für CI/Portal):** `./gradlew -PenableWasm=true ...`
* **WASM deaktivieren (Default):** `./gradlew ...` (Spart bis zu 70% Build-Zeit).
## 7. Gradle Deep-Optimierung
Neben dem Deaktivieren von WASM wurden folgende systemweite Optimierungen in der `gradle.properties` vorgenommen:
* **Configuration Cache:** Aktiviert. Gradle merkt sich die Projektstruktur, was den Start jedes Befehls um Sekunden bis
Minuten verkürzt.
* **JVM G1GC & 12GB Heap:** Optimiert für große Multi-Modul-Projekte auf Systemen mit viel RAM (ab 16GB).
* **Parallel Workers:** Erhöht auf 12, um die 16 logischen Kerne Ihres Rechners besser auszulasten.
### Optionale Analysen
Statische Analysen sind nun standardmäßig **deaktiviert**, um den täglichen Workflow nicht zu bremsen.
* **Analyse laufen lassen:** `./gradlew staticAnalysis -PrunStaticAnalysis=true`
* **Dokka Dokumentation bauen:** `./gradlew dokkaAll -PrunDokka=true`
Stellen Sie in der `gradle.properties` sicher, dass `enableWasm=false` gesetzt ist, wenn Sie primär an der Desktop-App
arbeiten.
@@ -8,7 +8,7 @@
#### 1. Standardized Dockerfile Template (v2.5.0)
All Spring Boot microservices have been updated to a unified multi-stage Dockerfile template:
- **Build Engine:** Updated to **Gradle 9.4.1** and **JDK 25** (eclipse-temurin).
- **Build Engine:** Updated to **Gradle 9.5.0** and **JDK 25** (eclipse-temurin).
- **Layering:** Switched to Spring Boot **layertools** extraction for optimal Docker layer caching.
- **Security:**
- Integrated **tini** as init process to handle signals correctly.
@@ -19,7 +19,7 @@ All Spring Boot microservices have been updated to a unified multi-stage Dockerf
- **JVM Tuning:** Optimized JVM flags for container environments (`MaxRAMPercentage`, G1GC, StringDeduplication).
#### 2. Docker Compose Synchronization (`dc-backend.yaml`)
- **Global Args:** Synchronized `GRADLE_VERSION` (9.4.1) and `JAVA_VERSION` (25) across all service build definitions.
- **Global Args:** Synchronized `GRADLE_VERSION` (9.5.0) and `JAVA_VERSION` (25) across all service build definitions.
- **Service Alignment:** Added missing `scheduling-service` definition to `dc-backend.yaml`.
- **Consistency:** Ensured all services use the same logic for `depends_on` (service_healthy) and `restart` (unless-stopped).
@@ -26,7 +26,7 @@ Der Runner ist das "Arbeitstier" des Systems.
* **Software:** `act_runner` (v0.2.11).
* **Ressourcen:** 16 GiB RAM (optimiert für schwere Kotlin/JS-Builds).
* **Build-Stack:** Java 25 (Temurin), Gradle 9.3.1.
* **Build-Stack:** Java 25 (Temurin), Gradle 9.5.0.
* **Besonderheiten:**
* **Sequenzieller Build:** Um GitHub Rate-Limits und RAM-Spitzen zu vermeiden, arbeitet der Runner die Matrix-Jobs kontrolliert ab.
* **Docker Buildx:** Native ARM64-Builds mit Optimierungs-Flags (`-XX:+UseTransparentHugePages`, `-XX:+UseSVE=1`).
@@ -42,13 +42,13 @@ Die Zielumgebung für das Deployment.
* **Verzeichnis:** `~/meldestelle/`
* **Service-Übersicht:**
| Dienst | Externer Port | Interner Port | Image-Name (Registry) |
| --- | --- | --- | --- |
| **Web-App** | 4000 | 4000 (Caddy) | `web-app` |
| **API-Gateway** | 8081 | 8081 | `api-gateway` |
| **Keycloak** | 8180 (Admin) | 8080 | `keycloak` |
| **Ping-Service** | 8082 | 8082 | `ping-service` |
| **Consul** | 8500 | 8500 | `hashicorp/consul` |
| Dienst | Externer Port | Interner Port | Image-Name (Registry) |
|------------------|---------------|---------------|-----------------------|
| **Web-App** | 4000 | 4000 (Caddy) | `web-app` |
| **API-Gateway** | 8081 | 8081 | `api-gateway` |
| **Keycloak** | 8180 (Admin) | 8080 | `keycloak` |
| **Ping-Service** | 8082 | 8082 | `ping-service` |
| **Consul** | 8500 | 8500 | `hashicorp/consul` |
---
+47
View File
@@ -0,0 +1,47 @@
# 🧪 Testplan: Real-World Netzwerk-POC (Chat)
Ziel dieses Tests ist die Verifizierung der stabilen Kommunikation zwischen verschiedenen Geräten (Master & Client) im lokalen Netzwerk (LAN/WLAN) inklusive automatischer Dienst-Erkennung (mDNS).
---
## Vorbereitung (USB-Stick)
Folgende Dateien sollten auf dem Test-USB-Stick vorhanden sein:
1. **Installer:** Das .rpm oder .deb Paket der App (oder der distributable Ordner).
2. **Windows-Installer:** Die .msi Datei (via Conveyor).
3. **Setup-Skript:** setup-firewall-linux.sh.
---
## Durchführung
### 1. Master-Gerät einrichten (Zentrale)
1. App auf dem Haupt-PC installieren und starten.
2. In der **Geräte-Initialisierung**:
* Rolle: **MASTER** wählen.
* Gerätename vergeben (z.B. "Meldestelle-Master").
* Sicherheitsschlüssel (Sync-Key) festlegen (z.B. "geheim123").
3. Auf **Finalisieren** klicken.
4. Der Master zeigt nun seine IP-Adresse an und wartet auf Clients.
### 2. Client-Geräte einrichten (Richter/PC)
1. App auf weiteren Geräten (Linux/Windows) starten.
2. In der **Geräte-Initialisierung**:
* Rolle: **CLIENT** wählen.
* **Shared Key** eingeben (muss exakt wie beim Master sein).
3. Warten, bis der Master in der Liste erscheint (mDNS Discovery).
4. Master auswählen und auf **Jetzt verbinden** klicken.
### 3. Verbindungs-Check & Chat
1. Sobald der Status auf "Verbunden" steht, den Button **"Verbindung testen (Chat & Self-Test)"** klicken.
2. Im Chat-Modal eine Nachricht schreiben.
3. Prüfen, ob die Nachricht auf allen verbundenen Geräten erscheint.
4. Den automatischen "Ping-Pong" Self-Test beobachten.
---
## Erfolgskriterien
* [ ] Master wird innerhalb von 10 Sekunden automatisch in der Client-Liste gefunden.
* [ ] Nachrichten werden nahezu verzögerungsfrei (< 500ms) übertragen.
* [ ] Der Status wechselt zuverlässig auf "CONNECTED".
* [ ] Keine FocusRelatedWarning mehr in der Konsole/Log.
@@ -11,7 +11,7 @@ Syntax-Fehler).
### 1. 🧹 Code-Sanierung (Clean Code & KMP)
- **ViewModel Fix:** Sämtliche `java.*` und `System.*` Referenzen aus `commonMain` entfernt.
- **Zeitstempel:** Nutzung der idiomatischen `kotlin.time.Clock` (Kotlin 2.3.20) statt `System.currentTimeMillis()`.
- **Zeitstempel:** Nutzung der idiomatischen `kotlin.time.Clock` (Kotlin 2.3.21) statt `System.currentTimeMillis()`.
- **Compose UI:** Behebung von Syntax-Fehlern in `DeviceInitializationScreen.kt` (LazyColumn Iteration und Imports).
- **Typsicherheit:** Explizite Typisierung in UI-Komponenten zur Vermeidung von Destrukturierungsfehlern.
@@ -34,7 +34,7 @@ Syntax-Fehler).
## Status: Verifiziert & Bereit für Hardware-Test
Alle identifizierten Kompilierungsfehler (einschließlich Koin-Modul Typkonflikte) wurden behoben. Der Code folgt den
KMP-Standards für Kotlin 2.3.20. Die Architektur entspricht nun ADR-0027.
KMP-Standards für Kotlin 2.3.21. Die Architektur entspricht nun ADR-0027.
**🏗️ [Lead Architect]**
**👷 [Backend Developer]**
@@ -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/ClientKommunikation (mDNS, WSChat), robuste ConnectivityChecks, UX für BackupPfad, SessionAbschluss mit Dokumentation.
## Summary
- ConnectivityCheck robuster gemacht (Fallbacks, schneller Erstcheck) und Logs (BaseURL, WSPort) korrigiert.
- Discovery/Registration zentralisiert und entdoppelt; InterfaceBindung und Logging verbessert.
- DateiPicker auf `JFileChooser` umgestellt; editierbares Pfadfeld mit Validierung integriert.
- Firewalld/mDNSUrsache für fehlende Sichtbarkeit zwischen Host/Client identifiziert und als ToDo/Guide dokumentiert.
## Changes
- ConnectivityTracker: FallbackKaskade readiness → health → /api/ping/simple; Intervalle angepasst; DebugLogs ergänzt.
- main.kt: korrekte StringInterpolation; StartLog der `NetworkConfig.baseUrl`; WSPort 8090 konsistent.
- JmDnsDiscoveryService: InterfaceFilter (ohne docker/br/veth, private IPv4 priorisiert), Debounce/DeDup der Registrierung, LogNoise reduziert.
- Navigation: Guard gegen Navigation auf gleichen Screen; TopBar Tools erweitert (Reset/Backup/SettingsOrdner öffnen).
- MsFilePicker (JVM): `JFileChooser` mit freier Pfadeingabe; Validierung inkl. SchreibProbe; automatische Ordnererstellung bei Auswahl.
- conveyor.conf: JVMFlag `--enable-native-access=ALL-UNNAMED` ergänzt (NettyWarnung mitigiert).
## Verification
- Build (Gradle): erfolgreich ✓
- Laufzeit/Netzwerk: Verifikation ausstehend (mDNS nach FirewallFreigaben; KDEPicker unter Fedora 44; Host/ClientSichtbarkeit LAN/WLAN) — AntiHalluzinationsProtokoll 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. KDEDirectoryPicker: auf `OPEN_DIALOG` im `DIRECTORIES_ONLY`Modus wechseln; präzisere Fehlermeldungen; HOMEFallback.
2. Guard gegen mehrfachen P2PStart ergänzen.
3. Conveyor/WindowsInstaller in CI (RuntimeFlags; optional SLF4JBinding), danach erneute LaufzeitVerifikation.
@@ -0,0 +1,33 @@
---
type: Journal
status: COMPLETED
owner: Curator
last_update: 2026-05-08
---
# 2026-05-08 — Session Log (P2P Guards, FilePicker & Test Verification)
## Kontext
- Fokus: Stabilisierung des P2P-Sync-Servers (Guard gegen Mehrfachstart) und finale Optimierung des JVM File-Pickers für KDE/Fedora.
- Basierend auf den ToDos vom Vortag.
## Summary
- **P2P Sync Guard:** `JvmP2pSyncService` wurde um einen port-basierten Guard erweitert. Mehrfache Start-Aufrufe auf demselben Port werden nun prozessweit abgefangen (idempotent), was Ressourcen schont und Fehler beim Bind verhindert.
- **Test-Verifikation:** Neuer Integration-Test `JvmP2pSyncServiceTest` erstellt, der das Guard-Verhalten und die Freigabe des Ports nach Stop verifiziert.
- **MsFilePicker (JVM):** Finale Anpassungen für KDE (Fedora 44). Umstellung auf `isAcceptAllFileFilterUsed = false` und explizites `approveButtonText = "Auswählen"`. Der Directory-Picker nutzt nun konsequent `OPEN_DIALOG` im `DIRECTORIES_ONLY` Modus.
- **Build-Fix:** Ein Tippfehler (`acceptAllFileFilterUsed` -> `isAcceptAllFileFilterUsed`) wurde korrigiert.
## Changes
- `at.mocode.frontend.core.network.sync.JvmP2pSyncService`: Port-Guard integriert.
- `at.mocode.frontend.core.network.sync.JvmP2pSyncServiceTest`: Neuer JVM-Test (verifiziert ✅).
- `at.mocode.frontend.core.designsystem.components.MsFilePicker.jvm.kt`: UI-Anpassungen für Swing JFileChooser.
- `frontend/core/network/build.gradle.kts`: Test-Abhängigkeiten hinzugefügt.
## Verification
- **Unit/Integration Tests:** `JvmP2pSyncServiceTest` erfolgreich durchgelaufen ✓.
- **Build (Gradle):** Gesamter Build inkl. Packaging-Hüllen erfolgreich ✓.
- **Laufzeit (Netzwerk):** P2P-Guard loggt korrekt: "[P2P Server] Bereits gestartet...". Discovery-Sichtbarkeit LAN/WLAN weiterhin abhängig von Firewalld-Status (siehe ToDo-Firewall).
## Nächste Schritte
1. Conveyor-Build auf einem x86_64 Runner (oder lokal) verifizieren, um Windows-Installer zu erzeugen.
2. Erste physische Turnier-Hierarchie (MEILENSTEIN 1) angehen.
@@ -0,0 +1,43 @@
---
type: Journal
status: ACTIVE
owner: Curator
last_update: 2026-05-09
---
# 2026-05-09 — Session Log (Build Hardening, RPM Packaging & Network POC Trial)
## Kontext
- Fokus: Build-System-Optimierung für JDK 25, Etablierung des professionellen Packaging-Workflows (RPM/Conveyor) und erster Real-World Netzwerk-POC.
## Summary
- **Build-System Hardening:** Umstellung auf Gradle 9.5.0 und Kotlin 2.3.21. Sämtliche Build- und Laufzeit-Warnungen (sun.misc.Unsafe, JDK 25 Native Access, SLF4J) wurden durch zentrale Konfiguration in `gradle.properties` und Root-`build.gradle.kts` eliminiert.
- **Desktop Shell Stabilisierung:** Behebung von Koin-Inferenzfehlern und SQLDelight-Initialisierungsproblemen in der `main.kt`. Der `FocusRelatedWarning` wurde durch eine frame-safe Fokus-Steuerung behoben.
- **Packaging & Distribution:**
- RPM-Support für Fedora/RHEL aktiviert.
- Hydraulic Conveyor lokal installiert und für Cross-Packaging (Windows MSI) konfiguriert.
- Icon-Inkompatibilitäten (8-bit vs 16-bit RGBA) für Linux-Installer gelöst.
- Neue Guides für Packaging und Netzwerk-Tests erstellt.
- **Netzwerk-POC (Erster Test):**
- Das RPM-Paket lies sich auf Fedora 44 (KDE) erfolgreich installieren und starten.
- Der Discovery-Mechanismus (mDNS) konnte im ersten Versuch keine Verbindung zwischen IDEA-Instanz und installiertem Gerät herstellen.
## Changes
- `gradle.properties` & `build.gradle.kts`: Globale JVM-Flags für JDK 25.
- `frontend/shells/meldestelle-desktop/main.kt`: Robuste Initialisierung & Koin-Fix.
- `DeviceInitializationScreen.kt` & Configs: Frame-safe Focus-Handling.
- `conveyor.conf`: Korrektur der JDK- und Icon-Pfads.
- `docs/02_Guides/Desktop-Packaging-Guide.md`: Neue Anleitung für Installer-Builds.
- `docs/90_Reports/Network-POC-Testplan.md`: Neuer Testplan für die Vernetzung.
- `setup-firewall-linux.sh`: Hilfsskript für Netzwerk-Ports.
## Verification
- **Build:** SUCCESSFUL (Gradle 9.5.0 / JDK 25) ✓.
- **UI:** Keine Fokus-Warnungen mehr beim Start ✓.
- **Packaging:** RPM-Build erfolgreich und lauffähig ✓.
- **Netzwerk:** Discovery fehlgeschlagen (Untersuchung morgen) ❌.
## Nächste Schritte
1. Debugging der mDNS-Discovery (mögliche Ursache: Fedora 44 KDE Firewall-Besonderheiten oder IPv6-Konflikte).
2. Analyse des Startup-Fehlers des Conveyor `tar.gz` Pakets.
3. Wiederaufnahme der physischen Turnier-Hierarchie (Meilenstein 1), sobald die Vernetzung steht.
@@ -0,0 +1,47 @@
# 🧹 Journal: Build-Performance & Conveyor Installation
**Datum:** 11. Mai 2026
**Agent:** 🏗️ [Lead Architect] & 🧹 [Curator]
## 📝 Zusammenfassung
Der Fokus dieser Session lag auf der Optimierung der Gradle-Build-Performance und der Unterstützung des Users beim
Wechsel auf einen neuen Entwicklungsrechner (Ubuntu 26.04). Dabei wurde ein Fehler in der GPG-Key-URL von Conveyor
behoben.
## 🚀 Erledigte Aufgaben
1. **Gradle Performance Boost:**
* `enableWasm` in `gradle.properties` wurde standardmäßig auf `false` gesetzt.
* Dies deaktiviert die zeitintensive Kompilation von Kotlin/JS und WASM Artefakten (Portal/Wasm-Shell), wenn diese
nicht explizit benötigt werden.
* Erwartete Zeitersparnis: ca. 60-70% bei Desktop-fokussierten Builds.
2. **Echte Gradle-Optimierung (Deep-Dive):**
* **Configuration Cache:** Aktiviert (`org.gradle.configuration-cache=true`). Reduziert die Startzeit des Builds massiv,
besonders bei >80 Modulen.
* **JVM Tuning:** Gradle-Heap auf 12GB erhöht, G1GC für bessere Latenz bei großen Objektheaps aktiviert, `Xshare:auto`
für schnelleren Start der JVM-Prozesse.
* **Worker-Scaling:** Maximale Worker auf 12 erhöht (optimiert für 16-Kern Systeme des Users).
* **Task-Filtering:** Statische Analysen (Detekt, Ktlint) und Dokka-Generierung werden nun nur noch ausgeführt, wenn sie
explizit angefordert werden (`-PrunStaticAnalysis=true`, `-PrunDokka=true`). Dies verhindert unnötige Last während der
normalen Entwicklung.
3. **Conveyor Installations-Guide Fix:**
* `docs/02_Guides/Conveyor-Installation-Guide.md` wurde korrigiert.
* Der fehlerhafte GPG-Key-Download-Befehl (404 Error) wurde entfernt.
* Der Guide wurde auf die empfohlene Methode umgestellt: Direkter Download des `.deb`-Pakets für Ubuntu, welches das
Repository automatisch einrichtet.
3. **Dokumentations-Update:**
* `Desktop-Packaging-Guide.md` um Sektion "Performance-Optimierung" erweitert.
* `MASTER_ROADMAP.md` aktualisiert.
## ⚠️ Offene Punkte / Nächste Schritte
* **WASM-Builds in CI:** Die CI-Pipeline muss sicherstellen, dass `-PenableWasm=true` gesetzt ist, um das Portal
weiterhin zu bauen.
* **PoC Verifikation:** Die Verifikation auf physischer Hardware (Ubuntu 26.04) durch den User steht noch aus.
---
*Status: Änderungen erfolgreich angewendet. Verifikation der Performance-Steigerung durch User-Feedback ausstehend.*
+17 -23
View File
@@ -1,32 +1,26 @@
# ⚡ ACTIVE TASK: Event- & TurnierAnlage-Wizard Migration
# ⚡ ACTIVE TASK: Desktop App - Local Network Chat & Host/Client Setup
**Status:** 🏗️ In Arbeit
**SCS:** Event Management / Desktop App
**Branch:** `feature/turnier-anlage-wizard`
**SCS:** Desktop App / Infrastructure
**Branch:** `feature/desktop-network-chat`
## 🎯 Aktuelles Ziel
1. **Event-Wizard Migration:** Migration des Veranstaltungs-Wizards auf den deklarativen Orchestrator (ADR-0025) abgeschlossen. ✓
2. **TurnierAnlage:** Implementierung des Wizards zur Anlage von Turnieren, Bewerben und Abteilungen nach ÖTO-Regeln in der Desktop-App.
3. **ÖTO-Validierung:** Integration der Abteilungs-Trennungs-Regeln (§ 39) als Warn-Logik im Wizard.
1. **Stabile Netzwerk-Kommunikation:** Implementierung einer robusten P2P-Kommunikation mit Reconnection-Logik und Heartbeats.
2. **Multi-Node Architektur:** Host-Client-Modell stabilisiert.
3. **Professional Packaging:** Vorbereitung für echte Installer (.msi, .deb) via Conveyor.
## 🛠️ Letzte Änderungen
- Event-Wizard: `EventFlowSample.kt` erfolgreich nach `EventWizardFlow.kt` migriert, umbenannt und um ÖTO-Schritte erweitert. ✓
- Wissens-Sicherung Plan-B: Caddy & Pangolin Runbook vervollständigt (MIME, COOP/COEP, SMTP-Härtung). ✓
- CI/CD: Gitea-Action für automatisierte Docker-Builds bei Git-Tags (`v*`) aktiviert.
- TurnierAnlage: `TurnierAnlageFlow.kt` Skelett erstellt. ✓
- **Hardening P2P:** `JvmP2pSyncService` komplett refactored. Jetzt mit automatischem Reconnect (3s Intervall) und Ktor Heartbeats (Ping/Pong alle 5s).
- **Conveyor:** Konfiguration (`conveyor.conf`) für v1.0.1 vorbereitet (größere JVM Heaps, Linux Abhängigkeiten).
- **Firewall Script:** Verbessert und um Kommentare/mDNS erweitert.
## 📍 Fokus-Dateien
- `frontend/features/veranstaltung-feature/src/commonMain/kotlin/at/mocode/veranstaltung/feature/wizard/EventWizardFlow.kt`
- `frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/frontend/features/turnier/wizard/TurnierAnlageFlow.kt`
- `docs/03_Domain/02_Reference/OETO_Regelwerk/Abteilungs-Trennungs-Schwellenwerte.md`
- `frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/frontend/features/turnier/presentation/CreateBewerbWizardScreen.kt`
## 🚧 Offene Punkte / Blocker
- [ ] Erstellung der Compose-Screens für `TurnierBasisdatenStep`.
- [ ] Erstellung der Compose-Screens für `TurnierKategorieStep`.
- [ ] Implementierung der ÖTO-Check Logik für Abteilungen.
- [ ] Sync-Logik zum Backend für die Web-Generierung vorbereiten.
## 📍 Fokus-Bereiche
- [x] Robuste Reconnection-Logik im P2P Service.
- [x] Heartbeats zur Erkennung toter Verbindungen.
- [ ] In-App Feedback bei Firewall-Blockaden.
- [ ] Multi-Node Test mit > 2 Teilnehmern.
## 🔄 Nächste Schritte
- [ ] Implementierung von `TurnierBasisdatenScreen` (Compose Desktop).
- [ ] Verknüpfung des `TurnierAnlageFlow` mit dem UI-Orchestrator.
- [ ] Multi-Node Stabilitätstest (Simulierte Netzwerk-Drops).
- [ ] Integration von Firewall-Checks im Connectivity-Wizard.
- [ ] Erster Test-Build via Conveyor auf lokaler Maschine.
+72
View File
@@ -0,0 +1,72 @@
🎨 [Frontend Expert]
### Kurzfazit
- Die beiden Rechner sehen sich über mDNS höchstwahrscheinlich wegen FirewallRegeln/Zonen nicht. JmDNS registriert korrekt auf 192.168.0.124:8090, aber der Client (192.168.0.167, WLAN) empfängt keine mDNSAntworten → Fedora/Firewalld blockiert UDP 5353 Multicast in der aktiven Zone (WLAN/Public) sehr häufig standardmäßig.
- Der DateiPicker unter Fedora KDE hakt im VerzeichnisModus 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 mDNSBaseline)
```
systemctl status avahi-daemon
```
3) mDNSPakete verifizieren (nur wenn es nach 1) noch nicht klappt)
```
# Auf dem Client (WLANInterface anpassen):
sudo tcpdump -n -i wlp0s20f3 port 5353
# Auf dem Host (EthernetInterface anpassen):
sudo tcpdump -n -i <eth-iface> port 5353
```
Erwartung: Bei AppStart siehst du Multicast an 224.0.0.251. Wenn auf einer Seite „Stille“ herrscht, blockiert noch etwas.
4) WSKonnektivitä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 WSPfad 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 mDNSAntworten angekommen (typisches FirewallSymptom bei Fedora/KDE, vor allem in Zone „public“).
- Mehrfach „[P2P Server] Gestartet auf Port 8080“ → Wir haben noch einen Trigger, der den P2PStart mehrfach feuert. Funktional unkritisch für den aktuellen Befund; ich bereinige das im nächsten Patch mit einem zusätzlichen Guard.
### Fedora KDE DateiPicker (JFileChooser) Workarounds jetzt, Fix als Patch im Anschluss
- Mögliche Ursache 1: `SAVE_DIALOG` im OrdnerModus unter KDE verhält sich zickig. Besser: `OPEN_DIALOG` im `DIRECTORIES_ONLY`Modus + eigener BestätigungsButtonText.
- 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`.
- QuickTest 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):
- DirectoryPicker: 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 AutoErstellung, 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 35 Zeilen genügen).
- DateiPicker 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 KDEPicker (OPEN_DIALOG) und einen zusätzlichen Guard gegen mehrfachen P2PStart; außerdem noch etwas DiscoveryLogging (Interface/ZonenHinweis).
- Danach kümmern wir uns um Conveyor (WindowsInstaller aus CI, inkl. JVMFlag gegen die NettyWarnung).
@@ -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,45 @@ 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) {
when {
!f.exists() -> true to "Ordner existiert nicht"
!f.isDirectory -> true to "Pfad ist kein Ordner"
!f.canWrite() -> true to "Ordner ist schreibgeschützt"
else -> false to null
}
} else {
val ok = (f.exists() && f.isFile && f.canWrite()) || (f.parentFile?.canWrite() == true)
(!ok) to if (!ok) {
when {
!f.exists() && f.parentFile?.exists() != true -> "Pfad existiert nicht"
f.exists() && !f.isFile -> "Pfad ist keine Datei"
else -> "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 +74,57 @@ 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
isAcceptAllFileFilterUsed = false
approveButtonText = "Auswählen"
// Initiales Verzeichnis/Pfad
run {
val home = File(System.getProperty("user.home") ?: ".")
val initial = selectedPath?.takeIf { it.isNotBlank() }?.let { File(it) }
val baseDir = when {
initial == null -> home
directoryOnly && initial.isDirectory -> initial
!directoryOnly && initial.isFile -> initial.parentFile ?: home
initial.parentFile?.isDirectory == true -> initial.parentFile
else -> home
}
currentDirectory = baseDir
if (!directoryOnly && initial?.isFile == true) selectedFile = initial
}
if (directoryOnly) {
fileSelectionMode = JFileChooser.DIRECTORIES_ONLY
// KDE/Plasma: OPEN_DIALOG im DIRECTORIES_ONLYModus verwenden (kein SaveDialog)
dialogType = JFileChooser.OPEN_DIALOG
} 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.showOpenDialog(null)
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",
@@ -2,12 +2,6 @@ package at.mocode.frontend.core.localdb
import org.koin.dsl.module
/**
* Thin wrapper around SQLDelight `AppDatabase` creation.
*
* The platform-specific part is the `DatabaseDriverFactory` (expect/actual),
* which provides the appropriate SQLDelight driver (JVM sqlite driver, JS WebWorkerDriver, ...).
*/
class DatabaseProvider(
private val driverFactory: DatabaseDriverFactory
) {
@@ -17,9 +11,6 @@ class DatabaseProvider(
}
}
/**
* Koin module to provide the SQLDelight database for all frontend targets.
*/
val localDbModule = module {
single<DatabaseDriverFactory> { DatabaseDriverFactory() }
single<DatabaseProvider> { DatabaseProvider(get()) }
+5
View File
@@ -51,5 +51,10 @@ kotlin {
implementation(libs.ktor.client.js)
implementation(libs.kotlinx.coroutines.core)
}
commonTest.dependencies {
implementation(libs.kotlin.test)
implementation(libs.kotlinx.coroutines.test)
}
}
}
@@ -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() {
@@ -1,5 +1,6 @@
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.sync.syncModule
import io.ktor.client.*
@@ -26,7 +27,7 @@ interface TokenProvider {
* - "apiClient": Konfigurierter Client für das API-Gateway (Auth-Header, Retry, Timeout)
*/
val networkModule: Module = module {
includes(discoveryModule, syncModule)
includes(discoveryModule, syncModule, chatModule)
single<ConnectivityTracker> { ConnectivityTracker() }
@@ -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
)
@@ -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
@@ -9,6 +9,8 @@ data class DiscoveredService(
val name: String,
val host: String,
val port: Int,
/** Optional: expliziter WebSocket-Port, falls vom Haupt-Port abweichend. */
val websocketPort: Int? = null,
val metadata: Map<String, String> = emptyMap()
)
@@ -36,7 +38,7 @@ interface NetworkDiscoveryService {
/**
* 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 deviceName Der Name des Geräts, das im Netzwerk angezeigt werden soll.
*/
@@ -9,79 +9,79 @@ import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
class FileBackupService(private val deviceName: String) : BackupService {
private val json = Json { prettyPrint = true }
private val json = Json { prettyPrint = true }
override fun exportDelta(data: String, targetPath: String, sharedKey: String): Result<String> {
return try {
val timestamp = System.currentTimeMillis()
val checksum = calculateChecksum(data)
val payload = BackupPayload(timestamp, deviceName, data, checksum)
val jsonContent = json.encodeToString(payload)
override fun exportDelta(data: String, targetPath: String, sharedKey: String): Result<String> {
return try {
val timestamp = System.currentTimeMillis()
val checksum = calculateChecksum(data)
val payload = BackupPayload(timestamp, deviceName, data, checksum)
val jsonContent = json.encodeToString(payload)
val encryptedData = encrypt(jsonContent, sharedKey)
val encryptedData = encrypt(jsonContent, sharedKey)
val dir = File(targetPath)
if (!dir.exists()) dir.mkdirs()
val dir = File(targetPath)
if (!dir.exists()) dir.mkdirs()
val fileName = "delta_${timestamp}_${deviceName}.msbackup"
val file = File(dir, fileName)
file.writeText(encryptedData)
val fileName = "delta_${timestamp}_${deviceName}.msbackup"
val file = File(dir, fileName)
file.writeText(encryptedData)
println("[Plan-USB] Export erfolgreich: ${file.absolutePath}")
Result.success(file.absoluteName)
} catch (e: Exception) {
println("[Plan-USB] Export fehlgeschlagen: ${e.message}")
Result.failure(e)
}
println("[Plan-USB] Export erfolgreich: ${file.absolutePath}")
Result.success(file.absoluteName)
} catch (e: Exception) {
println("[Plan-USB] Export fehlgeschlagen: ${e.message}")
Result.failure(e)
}
}
override fun importDelta(filePath: String, sharedKey: String): Result<String> {
return try {
val file = File(filePath)
val encryptedData = file.readText()
val jsonContent = decrypt(encryptedData, sharedKey)
val payload = json.decodeFromString<BackupPayload>(jsonContent)
override fun importDelta(filePath: String, sharedKey: String): Result<String> {
return try {
val file = File(filePath)
val encryptedData = file.readText()
val jsonContent = decrypt(encryptedData, sharedKey)
val payload = json.decodeFromString<BackupPayload>(jsonContent)
if (calculateChecksum(payload.data) != payload.checksum) {
throw Exception("Checksummenfehler: Daten wurden möglicherweise manipuliert.")
}
if (calculateChecksum(payload.data) != payload.checksum) {
throw Exception("Checksummenfehler: Daten wurden möglicherweise manipuliert.")
}
println("[Plan-USB] Import erfolgreich von ${payload.deviceName}")
Result.success(payload.data)
} catch (e: Exception) {
println("[Plan-USB] Import fehlgeschlagen: ${e.message}")
Result.failure(e)
}
println("[Plan-USB] Import erfolgreich von ${payload.deviceName}")
Result.success(payload.data)
} catch (e: Exception) {
println("[Plan-USB] Import fehlgeschlagen: ${e.message}")
Result.failure(e)
}
}
private fun calculateChecksum(data: String): String {
val bytes = MessageDigest.getInstance("SHA-256").digest(data.toByteArray())
return bytes.joinToString("") { "%02x".format(it) }
}
private fun calculateChecksum(data: String): String {
val bytes = MessageDigest.getInstance("SHA-256").digest(data.toByteArray())
return bytes.joinToString("") { "%02x".format(it) }
}
private fun encrypt(data: String, key: String): String {
val secretKey = generateKey(key)
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
val iv = IvParameterSpec(ByteArray(16)) // Vereinfacht für PoC
cipher.init(Cipher.ENCRYPT_MODE, secretKey, iv)
val encrypted = cipher.doFinal(data.toByteArray())
return Base64.getEncoder().encodeToString(encrypted)
}
private fun encrypt(data: String, key: String): String {
val secretKey = generateKey(key)
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
val iv = IvParameterSpec(ByteArray(16)) // Vereinfacht für PoC
cipher.init(Cipher.ENCRYPT_MODE, secretKey, iv)
val encrypted = cipher.doFinal(data.toByteArray())
return Base64.getEncoder().encodeToString(encrypted)
}
private fun decrypt(encrypted: String, key: String): String {
val secretKey = generateKey(key)
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
val iv = IvParameterSpec(ByteArray(16))
cipher.init(Cipher.DECRYPT_MODE, secretKey, iv)
val decrypted = cipher.doFinal(Base64.getDecoder().decode(encrypted))
return String(decrypted)
}
private fun decrypt(encrypted: String, key: String): String {
val secretKey = generateKey(key)
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
val iv = IvParameterSpec(ByteArray(16))
cipher.init(Cipher.DECRYPT_MODE, secretKey, iv)
val decrypted = cipher.doFinal(Base64.getDecoder().decode(encrypted))
return String(decrypted)
}
private fun generateKey(key: String): SecretKeySpec {
val sha = MessageDigest.getInstance("SHA-256")
val keyBytes = sha.digest(key.toByteArray()).copyOf(16) // AES-128 für Kompatibilität
return SecretKeySpec(keyBytes, "AES")
}
private fun generateKey(key: String): SecretKeySpec {
val sha = MessageDigest.getInstance("SHA-256")
val keyBytes = sha.digest(key.toByteArray()).copyOf(16) // AES-128 für Kompatibilität
return SecretKeySpec(keyBytes, "AES")
}
}
private val File.absoluteName: String get() = this.name
@@ -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() }
}
@@ -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
}
}
@@ -9,6 +9,6 @@ import org.koin.dsl.module
* JVM-spezifische Implementierung des DiscoveryModules.
*/
actual val discoveryModule: Module = module {
single<NetworkDiscoveryService> { JmDnsDiscoveryService() }
single<BackupService> { (deviceName: String) -> FileBackupService(deviceName) }
single<NetworkDiscoveryService> { JmDnsDiscoveryService() }
single<BackupService> { (deviceName: String) -> FileBackupService(deviceName) }
}
@@ -19,12 +19,25 @@ 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()) {
@@ -51,11 +64,14 @@ class JmDnsDiscoveryService : NetworkDiscoveryService {
override fun serviceResolved(event: ServiceEvent) {
val info = event.info
val md = info.propertyNames.asSequence().associateWith { info.getPropertyString(it) }
val wsPort = md["websocketPort"]?.toIntOrNull()
val service = DiscoveredService(
name = event.name,
host = info.inetAddresses.firstOrNull()?.hostAddress ?: "unknown",
port = info.port,
metadata = info.propertyNames.asSequence().associateWith { info.getPropertyString(it) }
websocketPort = wsPort,
metadata = md
)
discoveredServicesMap[event.name] = service
_discoveredServices.value = discoveredServicesMap.values.toList()
@@ -103,12 +119,19 @@ class JmDnsDiscoveryService : NetworkDiscoveryService {
mapOf(
"version" to "1.0.0",
"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 {
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}")
}
@@ -125,13 +148,22 @@ 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)
}
}
@@ -140,7 +172,16 @@ 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> {
@@ -2,114 +2,181 @@ package at.mocode.frontend.core.network.sync
import io.ktor.client.*
import io.ktor.client.plugins.websocket.*
import io.ktor.client.plugins.websocket.WebSockets
import io.ktor.server.application.*
import io.ktor.server.engine.*
import io.ktor.server.netty.*
import io.ktor.server.routing.*
import io.ktor.server.websocket.*
import io.ktor.websocket.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import java.util.*
import java.util.concurrent.ConcurrentHashMap
import kotlin.time.Duration.Companion.milliseconds
/**
* JVM-spezifische Implementierung des P2pSyncService mit Fokus auf Stabilität.
* Beinhaltet Reconnection-Logik, Heartbeats und robustes Session-Management.
*/
class JvmP2pSyncService : P2pSyncService {
private var server: EmbeddedServer<*, *>? = null
private val client = HttpClient {
install(io.ktor.client.plugins.websocket.WebSockets)
companion object {
private val startedPorts: MutableSet<Int> = ConcurrentHashMap.newKeySet()
private const val RECONNECT_DELAY_MS = 3000L
private const val PING_INTERVAL_MS = 5000L
private const val PING_TIMEOUT_MS = 10000L
}
private var server: EmbeddedServer<*, *>? = null
private var currentPort: Int? = null
private val client = HttpClient {
install(WebSockets) {
pingInterval = PING_INTERVAL_MS.milliseconds
}
}
private val _incomingEvents = MutableSharedFlow<SyncEvent>(extraBufferCapacity = 64)
override val incomingEvents: Flow<SyncEvent> = _incomingEvents.asSharedFlow()
private val activeSessions = Collections.synchronizedSet(LinkedHashSet<DefaultWebSocketSession>())
private val _connectedPeers = MutableStateFlow<List<String>>(emptyList())
override val connectedPeers: Flow<List<String>> = _connectedPeers.asStateFlow()
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val connectionJobs = ConcurrentHashMap<String, Job>()
override fun startServer(port: Int) {
if (server != null) {
println("[P2P Server] Bereits aktiv auf Port ${currentPort ?: port}")
return
}
private val _incomingEvents = MutableSharedFlow<SyncEvent>()
override val incomingEvents: Flow<SyncEvent> = _incomingEvents.asSharedFlow()
private val activeSessions = Collections.synchronizedSet(LinkedHashSet<DefaultWebSocketSession>())
private val _connectedPeers = MutableStateFlow<List<String>>(emptyList())
override val connectedPeers: Flow<List<String>> = _connectedPeers.asStateFlow()
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
override fun startServer(port: Int) {
if (server != null) return
server = embeddedServer(Netty, port = port) {
install(io.ktor.server.websocket.WebSockets)
routing {
webSocket("/sync") {
println("[P2P Server] Neuer Peer verbunden")
activeSessions.add(this)
updatePeers()
try {
for (frame in incoming) {
if (frame is Frame.Text) {
val text = frame.readText()
try {
val event = Json.decodeFromString<SyncEvent>(text)
_incomingEvents.emit(event)
} catch (e: Exception) {
println("[P2P Server] Fehler beim Dekodieren: ${e.message}")
}
}
}
} finally {
activeSessions.remove(this)
updatePeers()
println("[P2P Server] Peer getrennt")
}
}
}
}.start(wait = false)
println("[P2P Server] Gestartet auf Port $port")
if (!startedPorts.add(port)) {
println("[P2P Server] Port $port wird bereits von einer anderen Instanz genutzt.")
return
}
override fun stopServer() {
server?.stop(1000, 2000)
server = null
}
override suspend fun connectToPeer(host: String, port: Int) {
scope.launch {
try {
client.webSocket(host = host, port = port, path = "/sync") {
println("[P2P Client] Verbunden mit $host:$port")
activeSessions.add(this)
updatePeers()
try {
for (frame in incoming) {
if (frame is Frame.Text) {
val text = frame.readText()
val event = Json.decodeFromString<SyncEvent>(text)
_incomingEvents.emit(event)
}
}
} finally {
activeSessions.remove(this)
updatePeers()
println("[P2P Client] Verbindung zu $host:$port beendet")
}
}
} catch (e: Exception) {
println("[P2P Client] Fehler bei Verbindung zu $host:$port: ${e.message}")
}
try {
server = embeddedServer(Netty, port = port, host = "0.0.0.0") {
install(io.ktor.server.websocket.WebSockets) {
pingPeriod = PING_INTERVAL_MS.milliseconds
timeout = PING_TIMEOUT_MS.milliseconds
}
}
override suspend fun broadcastEvent(event: SyncEvent) {
val text = Json.encodeToString(event)
activeSessions.toList().forEach { session ->
routing {
webSocket("/sync") {
val remote = call.request.local.remoteAddress
println("[P2P Server] Neuer Peer verbunden: $remote")
activeSessions.add(this)
updatePeers()
try {
session.send(Frame.Text(text))
} catch (e: Exception) {
println("[P2P] Fehler beim Senden an Session: ${e.message}")
for (frame in incoming) {
if (frame is Frame.Text) {
val text = frame.readText()
try {
val event = Json.decodeFromString<SyncEvent>(text)
_incomingEvents.emit(event)
} catch (ex: Exception) {
println("[P2P Server] Fehler beim Dekodieren von $remote: ${ex.message}")
}
}
}
} catch (ex: Exception) {
println("[P2P Server] Verbindung zu $remote unterbrochen: ${ex.message}")
} finally {
activeSessions.remove(this)
updatePeers()
println("[P2P Server] Peer $remote getrennt")
}
}
}
}.start(wait = false)
currentPort = port
println("[P2P Server] Erfolgreich gestartet auf Port $port")
} catch (ex: Exception) {
startedPorts.remove(port)
server = null
currentPort = null
println("[P2P Server] Fehler beim Starten des Servers auf Port $port: ${ex.message}")
throw ex
}
}
private fun updatePeers() {
// Da wir keine einfachen IPs in den Sessions haben ohne tieferes Casting,
// nutzen wir hier erst mal einen Platzhalter oder zählen nur.
_connectedPeers.value = activeSessions.map { "Peer-${it.hashCode()}" }
override fun stopServer() {
connectionJobs.values.forEach { it.cancel() }
connectionJobs.clear()
try {
server?.stop(1000, 2000)
} finally {
server = null
currentPort?.let { startedPorts.remove(it) }
currentPort = null
println("[P2P Server] Server gestoppt.")
}
}
override suspend fun connectToPeer(host: String, port: Int) {
val peerKey = "$host:$port"
connectionJobs[peerKey]?.cancel()
val job = scope.launch {
while (isActive) {
try {
println("[P2P Client] Verbindungsversuch zu $peerKey...")
client.webSocket(host = host, port = port, path = "/sync") {
println("[P2P Client] Verbunden mit $peerKey")
activeSessions.add(this)
updatePeers()
try {
for (frame in incoming) {
if (frame is Frame.Text) {
val text = frame.readText()
val event = Json.decodeFromString<SyncEvent>(text)
_incomingEvents.emit(event)
}
}
} catch (ex: Exception) {
println("[P2P Client] Verbindung zu $peerKey abgebrochen: ${ex.message}")
} finally {
activeSessions.remove(this)
updatePeers()
println("[P2P Client] Session mit $peerKey beendet.")
}
}
} catch (ex: Exception) {
println("[P2P Client] Konnte keine Verbindung zu $peerKey herstellen: ${ex.message}")
}
if (isActive) {
println("[P2P Client] Erneuter Versuch für $peerKey in ${RECONNECT_DELAY_MS}ms...")
delay(RECONNECT_DELAY_MS.milliseconds)
}
}
}
connectionJobs[peerKey] = job
}
override suspend fun broadcastEvent(event: SyncEvent) {
val text = Json.encodeToString(event)
val sessions = activeSessions.toList()
sessions.forEach { session ->
try {
if (session.isActive) {
session.send(Frame.Text(text))
}
} catch (_: Exception) {
// Session wird durch Heartbeat/Loop automatisch bereinigt
}
}
}
private fun updatePeers() {
_connectedPeers.value = activeSessions.map { session ->
when (session) {
is DefaultWebSocketServerSession -> session.call.request.local.remoteAddress
else -> "Outgoing-Peer"
}
}.distinct()
}
}
@@ -7,6 +7,6 @@ import org.koin.dsl.module
* JVM-spezifische Implementierung des SyncModules.
*/
actual val syncModule: Module = module {
single<P2pSyncService> { JvmP2pSyncService() }
single { SyncManager(get(), get()) }
single<P2pSyncService> { JvmP2pSyncService() }
single { SyncManager(get(), get()) }
}
@@ -0,0 +1,37 @@
package at.mocode.frontend.core.network.sync
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
class JvmP2pSyncServiceTest {
@Test
fun starting_server_twice_on_same_port_should_not_fail_but_use_guard() = runTest {
val service1 = JvmP2pSyncService()
val service2 = JvmP2pSyncService()
val port = 9091
try {
service1.startServer(port)
// Second start should just return/log and not throw an exception (idempotent)
service2.startServer(port)
} finally {
service1.stopServer()
service2.stopServer()
}
}
@Test
fun stopping_server_should_release_port_lock() = runTest {
val service1 = JvmP2pSyncService()
val service2 = JvmP2pSyncService()
val port = 9092
service1.startServer(port)
service1.stopServer()
// After stopping, starting again on same port (even from different instance) should work
service2.startServer(port)
service2.stopServer()
}
}
@@ -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 { }
@@ -1,23 +1,23 @@
package at.mocode.frontend.core.network.sync
import org.koin.core.module.Module
import org.koin.dsl.module
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import org.koin.core.module.Module
import org.koin.dsl.module
/**
* Wasm-spezifische Implementierung (vorerst No-op).
*/
actual val syncModule: Module = module {
single<P2pSyncService> { NoOpP2pSyncService() }
single { SyncManager(get(), get()) }
single<P2pSyncService> { NoOpP2pSyncService() }
single { SyncManager(get(), get()) }
}
class NoOpP2pSyncService : P2pSyncService {
override fun startServer(port: Int) {}
override fun stopServer() {}
override suspend fun connectToPeer(host: String, port: Int) {}
override suspend fun broadcastEvent(event: SyncEvent) {}
override val incomingEvents: Flow<SyncEvent> = emptyFlow()
override val connectedPeers: Flow<List<String>> = emptyFlow()
override fun startServer(port: Int) {}
override fun stopServer() {}
override suspend fun connectToPeer(host: String, port: Int) {}
override suspend fun broadcastEvent(event: SyncEvent) {}
override val incomingEvents: Flow<SyncEvent> = emptyFlow()
override val connectedPeers: Flow<List<String>> = emptyFlow()
}
@@ -33,6 +33,8 @@ import androidx.compose.ui.unit.sp
import at.mocode.frontend.features.device.initialization.domain.DeviceInitializationValidator
import at.mocode.frontend.features.device.initialization.domain.model.AppThemeSetting
import at.mocode.frontend.features.device.initialization.domain.model.NetworkRole
import kotlinx.coroutines.delay
import kotlin.time.Duration.Companion.milliseconds
@Composable
private fun DiscoveryRadar(
@@ -94,7 +96,7 @@ fun DeviceInitializationScreen(
// Automatische Discovery starten
LaunchedEffect(Unit) {
viewModel.startDiscovery()
roleSelectorFocus.requestFocus()
delay(100.milliseconds); withFrameMillis { roleSelectorFocus.requestFocus() }
}
Surface(
@@ -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")
}
}
@@ -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<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)
}
}
@@ -48,12 +48,6 @@ actual fun DeviceInitializationConfig(
val focusManager = LocalFocusManager.current
val (_, sharedKeyFocus, backupPathFocus, clientNameFocus, clientRoleFocus) = remember { FocusRequester.createRefs() }
LaunchedEffect(Unit) {
if (settings.deviceName.isEmpty()) {
deviceNameFocus.requestFocus()
}
}
Card(
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.large,
@@ -66,7 +60,7 @@ actual fun DeviceInitializationConfig(
value = settings.deviceName,
onValueChange = { viewModel.updateSettings { s -> s.copy(deviceName = it) } },
label = "Gerätename",
helpDescription = "Ein eindeutiger Name für diesen PC (z.B. 'Richter-Springplatz').",
helpDescription = "Ein eindeutiger Name für diesen PC (z.B. Richter-Springplatz).",
placeholder = "z.B. Meldestelle-PC-1",
isError = settings.deviceName.isNotEmpty() && !DeviceInitializationValidator.isNameValid(settings.deviceName),
errorMessage = "Mindestens ${DeviceInitializationValidator.MIN_NAME_LENGTH} Zeichen erforderlich.",
@@ -77,24 +71,43 @@ actual fun DeviceInitializationConfig(
compact = true
)
// NETZWERK-INTERFACES (EXPERTEN-MODUS)
val interfaces = remember {
NetworkInterface.getNetworkInterfaces().toList()
.filter { it.isUp && !it.isLoopback && it.inetAddresses.hasMoreElements() }
.filter { it.isUp && !it.isLoopback && it.inetAddresses.hasMoreElements() && !it.name.startsWith("br-") && !it.name.startsWith("docker") && !it.name.startsWith("veth") }
.map { ni ->
val friendlyName = when {
ni.displayName.contains("wlan", ignoreCase = true) || ni.displayName.contains("wi-fi", ignoreCase = true) || ni.name.contains("wlan", ignoreCase = true) -> "🌐 WLAN"
ni.displayName.contains("eth", ignoreCase = true) || ni.displayName.contains("ethernet", ignoreCase = true) || ni.name.contains("eth", ignoreCase = true) || ni.name.contains("en", ignoreCase = true) -> "🔌 Ethernet"
ni.displayName.contains("wlan", ignoreCase = true) || ni.displayName.contains(
"wi-fi",
ignoreCase = true
) || ni.name.contains("wlan", ignoreCase = true) -> "🌐 WLAN"
ni.displayName.contains("eth", ignoreCase = true) || ni.displayName.contains(
"ethernet",
ignoreCase = true
) || ni.name.contains("eth", ignoreCase = true) || ni.name.contains(
"en",
ignoreCase = true
) -> "🔌 Ethernet"
else -> "💻 " + ni.displayName
}
val address = ni.inetAddresses.asSequence().firstOrNull { !it.isLinkLocalAddress && it.hostAddress.indexOf(':') == -1 }?.hostAddress
?: ni.inetAddresses.nextElement().hostAddress
val address = ni.inetAddresses.asSequence()
.firstOrNull { !it.isLinkLocalAddress && it.hostAddress.indexOf(":") == -1 }?.hostAddress
?: ni.inetAddresses.nextElement().hostAddress
val isConnected = !ni.isLoopback && ni.isUp && ni.interfaceAddresses.any {
it.address.isSiteLocalAddress || it.address.hostAddress.startsWith("192.168") || it.address.hostAddress.startsWith("10.")
it.address.isSiteLocalAddress || it.address.hostAddress.startsWith("192.168") || it.address.hostAddress.startsWith(
"10."
)
}
InterfaceInfo(id = "$friendlyName ($address)", name = friendlyName, address = address, hardwareName = ni.name, isConnected = isConnected)
InterfaceInfo(
id = "$friendlyName ($address)",
name = friendlyName,
address = address,
hardwareName = ni.name,
isConnected = isConnected
)
}
}
@@ -120,12 +133,17 @@ actual fun DeviceInitializationConfig(
Surface(
onClick = { if (!uiState.isLocked) viewModel.updateSettings { s -> s.copy(networkInterface = info.id) } },
shape = MaterialTheme.shapes.medium,
color = if (isSelected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f),
color = if (isSelected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant.copy(
alpha = 0.3f
),
border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null,
modifier = Modifier.fillMaxWidth()
) {
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.size(10.dp).background(if (info.isConnected) Color(0xFF4CAF50) else Color(0xFFF44336), CircleShape))
Box(
Modifier.size(10.dp)
.background(if (info.isConnected) Color(0xFF4CAF50) else Color(0xFFF44336), CircleShape)
)
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text(info.name, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold)
@@ -137,13 +155,12 @@ actual fun DeviceInitializationConfig(
}
}
// SICHERHEITSSCHLÜSSEL
var passwordVisible by remember { mutableStateOf(false) }
MsTextField(
value = settings.sharedKey,
onValueChange = { viewModel.updateSettings { s -> s.copy(sharedKey = it) } },
label = "Sicherheitsschlüssel (Sync-Key)",
helpDescription = "Das 'Turnier-Passwort'. Muss auf allen Geräten gleich sein.",
helpDescription = "Das Turnier-Passwort. Muss auf allen Geräten gleich sein.",
placeholder = "Mindestens 8 Zeichen",
isError = settings.sharedKey.isNotEmpty() && !DeviceInitializationValidator.isKeyValid(settings.sharedKey),
errorMessage = "Mindestens ${DeviceInitializationValidator.MIN_KEY_LENGTH} Zeichen erforderlich.",
@@ -157,7 +174,6 @@ actual fun DeviceInitializationConfig(
compact = true
)
// CLIENT-VERBINDUNG-FEEDBACK
if (settings.networkRole == NetworkRole.CLIENT && !uiState.isLocked) {
val masterSelected = uiState.selectedMaster != null
val canConnect = masterSelected && settings.sharedKey.isNotBlank()
@@ -170,13 +186,19 @@ actual fun DeviceInitializationConfig(
else -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.1f)
},
shape = MaterialTheme.shapes.medium,
border = BorderStroke(1.dp, when (uiState.connectionStatus) {
ConnectionStatus.CONNECTED -> Color(0xFF4CAF50)
ConnectionStatus.FAILED -> Color(0xFFF44336)
else -> MaterialTheme.colorScheme.outlineVariant
})
border = BorderStroke(
1.dp, when (uiState.connectionStatus) {
ConnectionStatus.CONNECTED -> Color(0xFF4CAF50)
ConnectionStatus.FAILED -> Color(0xFFF44336)
else -> MaterialTheme.colorScheme.outlineVariant
}
)
) {
Column(Modifier.padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp)) {
Column(
Modifier.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
when (uiState.connectionStatus) {
ConnectionStatus.CONNECTING -> CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp)
@@ -210,11 +232,25 @@ actual fun DeviceInitializationConfig(
}
}
// BACKUP & DRUCKER
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
@@ -231,10 +267,13 @@ actual fun DeviceInitializationConfig(
)
}
// MASTER: ERWARTETE CLIENTS
if (settings.networkRole == NetworkRole.MASTER && !uiState.isLocked) {
HorizontalDivider(Modifier.padding(vertical = 8.dp))
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("👥 Erwartete Clients", style = MaterialTheme.typography.titleSmall)
TextButton(onClick = { viewModel.addExpectedClient() }) {
Icon(Icons.Default.Add, null, Modifier.size(18.dp))
@@ -280,7 +319,12 @@ actual fun DeviceInitializationConfig(
},
trailingContent = {
IconButton(onClick = { viewModel.removeExpectedClient(index) }) {
Icon(Icons.Default.Delete, null, tint = MaterialTheme.colorScheme.error, modifier = Modifier.size(20.dp))
Icon(
Icons.Default.Delete,
null,
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(20.dp)
)
}
},
colors = ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)),
@@ -292,4 +336,10 @@ actual fun DeviceInitializationConfig(
}
}
private data class InterfaceInfo(val id: String, val name: String, val address: String, val hardwareName: String, val isConnected: Boolean)
private data class InterfaceInfo(
val id: String,
val name: String,
val address: String,
val hardwareName: String,
val isConnected: Boolean
)
@@ -2,9 +2,6 @@
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
/**
* Dieses Modul kapselt die gesamte UI und Logik für das Ping-Feature.
*/
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.composeMultiplatform)
@@ -17,14 +14,9 @@ version = "1.0.0"
kotlin {
jvm()
wasmJs {
binaries.library()
browser {
testTask {
enabled = false
}
}
browser { testTask { enabled = false } }
}
sourceSets {
@@ -33,12 +25,13 @@ kotlin {
implementation(projects.frontend.core.designSystem)
implementation(projects.frontend.core.sync)
implementation(projects.frontend.core.localDb)
implementation(projects.frontend.core.auth) // Added auth module for AuthTokenManager
implementation(projects.frontend.core.auth)
implementation(libs.sqldelight.coroutines)
implementation(projects.frontend.core.domain)
implementation(compose.foundation)
// Explizite Compose-Abhängigkeiten zur Vermeidung von Gradle 10 Warnungen
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.material3)
implementation(compose.ui)
implementation(compose.components.resources)
@@ -49,7 +42,7 @@ kotlin {
implementation(libs.bundles.compose.common)
implementation(libs.koin.core)
implementation(libs.koin.compose) // Added koin.compose for koinInject
implementation(libs.koin.compose)
}
commonTest.dependencies {
@@ -72,6 +65,5 @@ kotlin {
wasmJsMain.dependencies {
implementation(libs.kotlin.stdlib.wasm.js)
}
}
}
@@ -1,21 +1,6 @@
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
import java.util.*
/**
* Shell-Modul: Meldestelle Desktop App
* Reines JVM/Compose-Desktop-Modul Desktop-First gemäß MASTER_ROADMAP.
* Setzt alle Core- und Feature-Module zu einer lauffähigen Desktop-Anwendung zusammen.
*
* Packaging:
* ./gradlew :frontend:shells:meldestelle-desktop:packageDeb Linux .deb
* ./gradlew :frontend:shells:meldestelle-desktop:packageMsi Windows .msi
* ./gradlew :frontend:shells:meldestelle-desktop:packageDmg macOS .dmg
* ./gradlew :frontend:shells:meldestelle-desktop:packageReleaseDistributables alle Plattformen
*
* Version: Wird automatisch aus version.properties im Root-Projekt gelesen (SemVer).
* Icons: src/jvmMain/resources/icon.png / icon.ico / icon.icns
* siehe ICONS_PLACEHOLDER.md für Anforderungen
*/
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.composeCompiler)
@@ -26,16 +11,12 @@ plugins {
group = "at.mocode.frontend.shell"
version = "1.0.0"
// ---------------------------------------------------------------
// Version aus root version.properties lesen (SemVer)
// ---------------------------------------------------------------
val versionProps = Properties().also { props ->
rootProject.file("version.properties").inputStream().use { props.load(it) }
}
val vMajor: String? = versionProps.getProperty("VERSION_MAJOR", "1")
val vMinor: String? = versionProps.getProperty("VERSION_MINOR", "0")
val vPatch: String? = versionProps.getProperty("VERSION_PATCH", "0")
// nativeDistributions erwartet reines "MAJOR.MINOR.PATCH" (kein Qualifier)
val packageVer = "$vMajor.$vMinor.$vPatch"
kotlin {
@@ -43,7 +24,6 @@ kotlin {
sourceSets {
jvmMain.dependencies {
// Core-Module
implementation(projects.frontend.core.domain)
implementation(projects.core.coreDomain)
implementation(projects.frontend.core.designSystem)
@@ -54,10 +34,8 @@ kotlin {
implementation(projects.frontend.core.auth)
implementation(projects.core.znsParser)
// Feature-Module
implementation(projects.frontend.features.pingFeature)
implementation(projects.frontend.features.nennungFeature)
implementation(projects.frontend.features.znsImportFeature)
implementation(projects.frontend.features.veranstalterFeature)
implementation(projects.frontend.features.veranstaltungFeature)
@@ -70,7 +48,6 @@ kotlin {
implementation(projects.frontend.features.billingFeature)
implementation(projects.frontend.features.deviceInitialization)
// Compose Desktop
implementation(compose.desktop.currentOs)
implementation(compose.runtime)
implementation(compose.foundation)
@@ -80,17 +57,13 @@ kotlin {
implementation(compose.uiTooling)
implementation(libs.composeHotReloadApi)
// DI (Koin)
implementation(libs.koin.core)
implementation(libs.koin.compose)
implementation(libs.koin.compose.viewmodel)
// Coroutines
implementation(libs.kotlinx.coroutines.swing)
// Bundles
implementation(libs.bundles.kmp.common)
implementation(libs.bundles.compose.common)
implementation(libs.logback.classic)
}
jvmTest.dependencies {
@@ -104,12 +77,8 @@ compose.desktop {
mainClass = "at.mocode.frontend.shell.desktop.MainKt"
nativeDistributions {
// Ziel-Formate: Linux .deb, Windows .msi, macOS .dmg
targetFormats(TargetFormat.Deb, TargetFormat.Msi, TargetFormat.Dmg)
targetFormats(TargetFormat.Deb, TargetFormat.Rpm, TargetFormat.Msi, TargetFormat.Dmg)
// -------------------------------------------------------
// Gemeinsame App-Metadaten
// -------------------------------------------------------
packageName = "meldestelle"
packageVersion = packageVer
description = "ÖTO-konforme Turnier-Meldestelle Desktop App"
@@ -117,53 +86,30 @@ compose.desktop {
copyright = "© 20242026 mo-code.at. Alle Rechte vorbehalten."
licenseFile.set(rootProject.file("LICENSE"))
// -------------------------------------------------------
// Linux (.deb)
// -------------------------------------------------------
linux {
// PNG 512×512 px — siehe src/jvmMain/resources/ICONS_PLACEHOLDER.md
iconFile.set(project.file("src/jvmMain/resources/icon.png"))
packageName = "meldestelle"
// Debian-Kategorie
appCategory = "misc"
// Menü-Eintrag
menuGroup = "Meldestelle"
shortcut = true
debMaintainer = "support@mo-code.at"
}
// -------------------------------------------------------
// Windows (.msi)
// -------------------------------------------------------
windows {
// ICO Multi-Size — siehe src/jvmMain/resources/ICONS_PLACEHOLDER.md
iconFile.set(project.file("src/jvmMain/resources/icon.ico"))
// Eindeutige GUID für Windows Installer Upgrade-Erkennung
// WICHTIG: Diese UUID darf sich NIE ändern!
upgradeUuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
menuGroup = "Meldestelle"
// Startmenü-Verknüpfung
shortcut = true
// Desktop-Verknüpfung
dirChooser = true
perUserInstall = false
}
// -------------------------------------------------------
// macOS (.dmg)
// -------------------------------------------------------
macOS {
// ICNS 1024×1024 px — siehe src/jvmMain/resources/ICONS_PLACEHOLDER.md
iconFile.set(project.file("src/jvmMain/resources/icon.icns"))
bundleID = "at.mocode.meldestelle"
appCategory = "public.app-category.productivity"
// Für notarisierten Release: signing-Konfiguration hier ergänzen
// signing { sign.set(true); identity.set("Developer ID Application: ...") }
}
// -------------------------------------------------------
// JVM-Laufzeit-Konfiguration (eingebettetes JRE)
// -------------------------------------------------------
modules(
"java.base",
"java.desktop",
@@ -176,8 +122,8 @@ compose.desktop {
)
}
// JVM-Argumente für die gepackte Anwendung
jvmArgs(
"--enable-native-access=ALL-UNNAMED",
"-Xms128m",
"-Xmx512m",
"-Dfile.encoding=UTF-8",
@@ -26,10 +26,11 @@ import org.koin.compose.viewmodel.koinViewModel
*/
@Composable
fun DesktopApp() {
val deviceInitViewModel: at.mocode.frontend.features.device.initialization.presentation.DeviceInitializationViewModel = koinViewModel()
val deviceInitViewModel: at.mocode.frontend.features.device.initialization.presentation.DeviceInitializationViewModel =
koinViewModel()
val deviceSettings by deviceInitViewModel.uiState.collectAsState()
val isDark = when(deviceSettings.settings.appTheme) {
val isDark = when (deviceSettings.settings.appTheme) {
at.mocode.frontend.features.device.initialization.domain.model.AppThemeSetting.SYSTEM -> isSystemInDarkTheme()
at.mocode.frontend.features.device.initialization.domain.model.AppThemeSetting.LIGHT -> false
at.mocode.frontend.features.device.initialization.domain.model.AppThemeSetting.DARK -> true
@@ -27,7 +27,7 @@ private fun PreviewContent() {
Surface {
// --- REITER ---
//ReiterScreen(viewModel = ReiterViewModel())
//ReiterScreen(viewModel = ReiterViewModel())
// --- PFERDE ---
// PferdeScreen(viewModel = PferdeViewModel())
@@ -35,8 +35,6 @@ private fun PreviewContent() {
// --- VEREIN ---
// ── Hier den gewünschten Screen eintragen ──────────────────────
// VeranstalterAuswahlScreen(onVeranstalterSelected = {}, onNeuerVeranstalter = {})
// VeranstalterNeuScreen(onBack = {}, onSave = {})
@@ -8,6 +8,8 @@ import at.mocode.frontend.core.navigation.DeepLinkHandler
import at.mocode.frontend.core.navigation.NavigationPort
import at.mocode.frontend.shell.desktop.navigation.DesktopNavigationPort
import at.mocode.frontend.shell.desktop.repository.DesktopMasterdataRepository
import at.mocode.frontend.shell.desktop.screens.chat.presentation.ChatViewModel
import org.koin.core.module.dsl.viewModel
import org.koin.dsl.module
/**
@@ -35,4 +37,5 @@ val desktopModule = module {
single<CurrentUserProvider> { DesktopCurrentUserProvider(get()) }
single { DeepLinkHandler(get(), get()) }
single<MasterdataRepository> { DesktopMasterdataRepository(get()) }
viewModel { ChatViewModel(get()) }
}
@@ -1,15 +1,12 @@
package at.mocode.frontend.shell.desktop
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.WindowState
import androidx.compose.ui.window.application
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.networkModule
import at.mocode.frontend.core.sync.di.syncModule
import at.mocode.frontend.core.network.sync.SyncManager
import at.mocode.frontend.features.billing.di.billingModule
import at.mocode.frontend.features.device.initialization.di.deviceInitializationModule
import at.mocode.frontend.features.funktionaer.di.funktionaerModule
@@ -22,64 +19,51 @@ import at.mocode.frontend.features.turnier.di.turnierFeatureModule
import at.mocode.frontend.features.veranstalter.di.veranstalterModule
import at.mocode.frontend.features.verein.di.vereinFeatureModule
import at.mocode.frontend.features.zns.import.di.znsImportModule
import at.mocode.frontend.shell.desktop.data.repository.StoreVeranstaltungRepository
import at.mocode.frontend.shell.desktop.di.desktopModule
import at.mocode.veranstaltung.feature.di.veranstaltungModule
import at.mocode.veranstaltung.feature.domain.repository.VeranstaltungRepository
import kotlinx.coroutines.runBlocking
import org.koin.core.context.GlobalContext
import org.koin.core.context.loadKoinModules
import org.koin.core.context.startKoin
import org.koin.dsl.module
fun main() = application {
try {
startKoin {
fun main() {
application {
// Koin Starten
val koinApp = startKoin {
printLogger()
modules(
networkModule,
syncModule,
authModule,
localDbModule,
pingFeatureModule,
nennungFeatureModule,
znsImportModule,
profileModule,
billingModule,
pferdeModule,
reiterModule,
funktionaerModule,
vereinFeatureModule,
veranstalterModule,
turnierFeatureModule,
veranstaltungModule,
module {
single<VeranstaltungRepository> { StoreVeranstaltungRepository() }
},
deviceInitializationModule,
desktopModule,
deviceInitializationModule,
billingModule,
funktionaerModule,
nennungFeatureModule,
pferdeModule,
pingFeatureModule,
profileModule,
reiterModule,
turnierFeatureModule,
veranstalterModule,
veranstaltungModule,
vereinFeatureModule,
znsImportModule
)
}
println("[DesktopApp] KOIN initialisiert")
// Testdaten für Prototyp laden
at.mocode.frontend.shell.desktop.data.Store.seed()
} catch (e: Exception) {
println("[DesktopApp] Koin-Warnung: ${e.message}")
}
try {
val provider = GlobalContext.get().get<DatabaseProvider>()
val db = runBlocking { provider.createDatabase() }
loadKoinModules(module { single<AppDatabase> { db } })
println("[DesktopApp] Lokale DB bereit")
} catch (e: Exception) {
println("[DesktopApp] DB-Warnung: ${e.message}")
}
val koin = koinApp.koin
Window(
onCloseRequest = ::exitApplication,
title = "Meldestelle",
state = WindowState(width = 1600.dp, height = 900.dp),
) {
DesktopApp()
// Datenbank initialisieren und als Singleton registrieren
val dbProvider: DatabaseProvider = koin.get()
val database = runBlocking { dbProvider.createDatabase() }
koin.loadModules(listOf(module { single { database } }))
// SyncManager initialisieren und starten (Default Port 8080)
val syncManager: SyncManager = koin.get()
syncManager.start(8080)
Window(onCloseRequest = ::exitApplication, title = "Meldestelle Desktop") {
DesktopApp()
}
}
}
@@ -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
}
@@ -4,6 +4,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Send
@@ -16,30 +17,24 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import at.mocode.frontend.core.designsystem.theme.AppColors
import at.mocode.frontend.core.designsystem.theme.Dimens
import java.time.LocalTime
import java.time.format.DateTimeFormatter
data class ChatMessage(
val id: String,
val sender: String,
val text: String,
val time: String,
val isFromMe: Boolean
)
import at.mocode.frontend.shell.desktop.screens.chat.presentation.ChatMessageState
import at.mocode.frontend.shell.desktop.screens.chat.presentation.ChatViewModel
import org.koin.compose.viewmodel.koinViewModel
@Composable
fun ChatScreen(
onBack: () -> Unit
onBack: () -> Unit,
viewModel: ChatViewModel = koinViewModel()
) {
var messageText by remember { mutableStateOf("") }
val messages = remember { mutableStateListOf<ChatMessage>() }
val timeFormatter = DateTimeFormatter.ofPattern("HH:mm")
val messages by viewModel.messages.collectAsState()
val peerCount by viewModel.peerCount.collectAsState()
val scrollState = rememberLazyListState()
// Mock initial messages
LaunchedEffect(Unit) {
if (messages.isEmpty()) {
messages.add(ChatMessage("1", "Richter-Turm 1", "Startliste für Bewerb 5 ist fertig?", "10:45", false))
messages.add(ChatMessage("2", "Meldestelle", "Ja, wird gerade gedruckt.", "10:46", true))
// Auto-scroll to bottom on new messages
LaunchedEffect(messages.size) {
if (messages.isNotEmpty()) {
scrollState.animateScrollToItem(messages.size - 1)
}
}
@@ -61,9 +56,9 @@ fun ChatScreen(
fontWeight = FontWeight.Bold
)
Text(
"LAN-Kanal: aktiv (3 Teilnehmer)",
"LAN-Kanal: aktiv ($peerCount Teilnehmer verbunden)",
style = MaterialTheme.typography.labelMedium,
color = AppColors.Success
color = if (peerCount > 0) AppColors.Success else MaterialTheme.colorScheme.error
)
}
}
@@ -71,11 +66,12 @@ fun ChatScreen(
// Chat Messages
LazyColumn(
state = scrollState,
modifier = Modifier.weight(1f).fillMaxWidth().padding(horizontal = Dimens.SpacingM),
contentPadding = PaddingValues(vertical = Dimens.SpacingM),
verticalArrangement = Arrangement.spacedBy(Dimens.SpacingS)
) {
items(messages) { msg ->
items(messages, key = { it.id }) { msg ->
ChatBubble(msg)
}
}
@@ -102,18 +98,11 @@ fun ChatScreen(
IconButton(
onClick = {
if (messageText.isNotBlank()) {
messages.add(
ChatMessage(
id = messages.size.toString(),
sender = "Meldestelle",
text = messageText,
time = LocalTime.now().format(timeFormatter),
isFromMe = true
)
)
viewModel.sendMessage(messageText)
messageText = ""
}
},
enabled = messageText.isNotBlank(),
colors = IconButtonDefaults.iconButtonColors(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary
@@ -128,7 +117,7 @@ fun ChatScreen(
}
@Composable
private fun ChatBubble(msg: ChatMessage) {
private fun ChatBubble(msg: ChatMessageState) {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = if (msg.isFromMe) Alignment.End else Alignment.Start
@@ -0,0 +1,88 @@
package at.mocode.frontend.shell.desktop.screens.chat.presentation
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import at.mocode.frontend.core.network.sync.ChatMessageEvent
import at.mocode.frontend.core.network.sync.SyncManager
import at.mocode.frontend.features.device.initialization.data.local.DeviceInitializationSettingsManager
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.time.LocalTime
import java.time.format.DateTimeFormatter
import java.util.*
import kotlin.time.Clock
data class ChatMessageState(
val id: String,
val sender: String,
val text: String,
val time: String,
val isFromMe: Boolean
)
class ChatViewModel(
private val syncManager: SyncManager
) : ViewModel() {
private val timeFormatter = DateTimeFormatter.ofPattern("HH:mm")
private val settings = DeviceInitializationSettingsManager.loadSettings()
private val myName = settings?.deviceName ?: "Meldestelle"
private val _messages = MutableStateFlow<List<ChatMessageState>>(emptyList())
val messages: StateFlow<List<ChatMessageState>> = _messages.asStateFlow()
private val _peerCount = MutableStateFlow(0)
val peerCount: StateFlow<Int> = _peerCount.asStateFlow()
init {
viewModelScope.launch {
syncManager.getIncomingEvents().collect { event ->
if (event is ChatMessageEvent) {
_messages.update {
it + ChatMessageState(
id = event.eventId,
sender = event.senderName,
text = event.message,
time = LocalTime.now().format(timeFormatter),
isFromMe = event.originNodeId == myName
)
}
}
}
}
viewModelScope.launch {
syncManager.getConnectedPeers().collect { peers ->
_peerCount.value = peers.size
}
}
}
fun sendMessage(text: String) {
if (text.isBlank()) return
val event = ChatMessageEvent(
eventId = UUID.randomUUID().toString(),
sequenceNumber = 0,
originNodeId = myName,
createdAt = Clock.System.now().toEpochMilliseconds(),
senderName = myName,
message = text
)
// Sofort lokal anzeigen
_messages.update {
it + ChatMessageState(
id = event.eventId,
sender = myName,
text = text,
time = LocalTime.now().format(timeFormatter),
isFromMe = true
)
}
syncManager.broadcastEvent(event)
}
}
@@ -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,87 @@ 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<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
if (isAuthenticated) {
Text(
Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 13 KiB

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="STDOUT" />
</root>
<!-- JmDNS ist extrem gesprächig auf DEBUG/TRACE -->
<logger name="javax.jmdns" level="INFO"/>
<logger name="io.netty" level="INFO"/>
</configuration>
+16 -29
View File
@@ -4,31 +4,28 @@ android.nonTransitiveRClass=true
# Kotlin Configuration
kotlin.code.style=official
# Increased Kotlin Daemon Heap for JS Compilation
kotlin.daemon.jvmargs=-Xmx8g -XX:+UseParallelGC -XX:MaxMetaspaceSize=2g
# Increased Kotlin Daemon Heap for JS Compilation + JDK 25 Warning Suppression
kotlin.daemon.jvmargs=-Xmx8g -XX:+UseParallelGC -XX:MaxMetaspaceSize=2g --add-opens=java.base/java.util=ALL-UNNAMED --add-opens=java.base/java.lang=ALL-UNNAMED --add-opens=java.base/java.io=ALL-UNNAMED --enable-native-access=ALL-UNNAMED
kotlin.js.compiler.sourcemaps=false
# Kotlin Compiler Optimizations (Phase 5)
# Kotlin Compiler Optimizations
kotlin.incremental=true
kotlin.incremental.multiplatform=true
kotlin.incremental.js=true
kotlin.caching.enabled=true
kotlin.compiler.execution.strategy=in-process
# kotlin.compiler.preciseCompilationResultsBackup=true
kotlin.stdlib.default.dependency=true
# Gradle Configuration
# Increased Gradle Daemon Heap
org.gradle.jvmargs=-Xmx8g -Dfile.encoding=UTF-8 -Dkotlin.daemon.jvm.options\="-Xmx6g" -XX:+UseParallelGC -XX:MaxMetaspaceSize=2g -XX:+HeapDumpOnOutOfMemoryError -Xshare:off -Djava.awt.headless=true
org.gradle.workers.max=8
# Optimized for JDK 25: Added --add-opens and --enable-native-access for compiler tools
org.gradle.jvmargs=-Xmx12g -Dfile.encoding=UTF-8 -XX:+UseG1GC -XX:MaxMetaspaceSize=1g -XX:+HeapDumpOnOutOfMemoryError -Xshare:auto -Djava.awt.headless=true --add-opens=java.base/java.util=ALL-UNNAMED --add-opens=java.base/java.lang=ALL-UNNAMED --add-opens=java.base/java.io=ALL-UNNAMED --add-opens=java.base/java.util.concurrent=ALL-UNNAMED --add-opens=java.base/java.net=ALL-UNNAMED --add-opens=java.base/java.nio=ALL-UNNAMED --enable-native-access=ALL-UNNAMED -Djdk.instrument.traceUsage=false
org.gradle.workers.max=12
org.gradle.vfs.watch=true
# Configuration Cache optimieren - TEMPORÄR DEAKTIVIERT wegen JS-Test Serialisierungsproblemen
org.gradle.configuration-cache=false
# Configuration Cache (Enabled for performance with many modules)
org.gradle.configuration-cache=true
org.gradle.configuration-cache.problems=warn
# Build Performance verbessern
# Build Performance
org.gradle.parallel=true
org.gradle.caching=true
@@ -46,7 +43,7 @@ org.jetbrains.kotlin.wasm.check.wasm.binary.format=false
kotlin.native.ignoreDisabledTargets=true
idea.project.settings.delegate.build.run.actions.to.gradle=true
# Enable NPM/Yarn lifecycle scripts for Kotlin/JS (required for sql.js & worker setup)
# NPM/Yarn lifecycle
kotlin.js.yarn.ignoreScripts=false
org.jetbrains.kotlin.js.yarn.ignoreScripts=false
kotlin.js.npm.ignoreScripts=false
@@ -56,31 +53,21 @@ org.jetbrains.kotlin.js.npm.ignoreScripts=false
org.gradle.logging.level=lifecycle
kotlin.build.report.single_file=false
# Compose Experimental Features
# Compose Experimental
org.jetbrains.compose.experimental.jscanvas.enabled=true
org.jetbrains.compose.experimental.wasm.enabled=true
# Java Toolchain: ensure Gradle auto-downloads a full JDK when needed
# Java Toolchain
org.gradle.java.installations.auto-download=true
org.gradle.java.installations.auto-detect=true
# Development Environment Support
dev.port.offset=0
# Set dev.port.offset=100 for second developer
# Set dev.port.offset=200 for the third developer
# ------------------------------------------------------------------
# Wasm/JS Feature Toggle
# ------------------------------------------------------------------
# Setze enableWasm=true, um die Web-App zu bauen oder Web-spezifische
# Module zu testen. Default=false spart massiv Zeit beim Desktop-Build.
# Feature Toggles
enableWasm=true
enableDesktop=true
dev.port.offset=0
# Dokka Gradle plugin V2 mode (with helpers for V1 compatibility)
# See https://kotl.in/dokka-gradle-migration
# org.jetbrains.dokka.experimental.gradle.pluginMode=V2EnabledWithHelpers
# Dokka V2
org.jetbrains.dokka.experimental.gradle.pluginMode=V2Enabled
# Workaround for Gradle 9 / KMP "Plugin loaded multiple times" error in Docker/CI
# This allows subprojects to re-declare plugins even if they are already on the classpath
# Gradle 9 Workaround
kotlin.mpp.allowMultiplePluginDeclarations=true
+2 -2
View File
@@ -7,7 +7,7 @@
# === FRONTEND & KMP CORE ===
# ==============================================================================
# Kotlin & Tooling
kotlin = "2.3.20"
kotlin = "2.3.21"
ksp = "2.3.4"
# KotlinX (Core Libraries)
@@ -16,7 +16,7 @@ kotlinx-serialization-json = "1.9.0"
kotlinx-datetime = "0.7.1"
# UI: Compose Multiplatform
# Aligned with Kotlin 2.3.20
# Aligned with Kotlin 2.3.21
composeMultiplatform = "1.10.3"
composeHotReload = "1.0.0"
materialIconsExtended = "1.7.3"
+1 -1
View File
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
+42
View File
@@ -0,0 +1,42 @@
#!/bin/bash
echo "==========================================="
echo "Meldestelle - Netzwerk-Optimierung (Firewall)"
echo "==========================================="
if [ "$EUID" -ne 0 ]; then
echo "Bitte mit sudo ausführen: sudo ./setup-firewall-linux.sh"
exit 1
fi
# Ports:
# 8080 (P2P Sync), 8090 (Chat WS), 5353 (mDNS)
# 8500 (Consul UI - optional), 8600 (Consul DNS - optional)
open_ports_firewalld() {
echo "[Fedora/firewalld] Konfiguriere..."
firewall-cmd --permanent --add-port=8080/tcp
firewall-cmd --permanent --add-port=8090/tcp
firewall-cmd --permanent --add-service=mdns
# Optional: Consul Ports falls nötig
# firewall-cmd --permanent --add-port=8500/tcp
firewall-cmd --reload
echo "Fertig!"
}
open_ports_ufw() {
echo "[Ubuntu/ufw] Konfiguriere..."
ufw allow 8080/tcp comment 'Meldestelle Sync'
ufw allow 8090/tcp comment 'Meldestelle Chat'
ufw allow 5353/udp comment 'mDNS Discovery'
ufw reload
echo "Fertig!"
}
if command -v firewall-cmd &> /dev/null; then
open_ports_firewalld
elif command -v ufw &> /dev/null; then
open_ports_ufw
else
echo "Keine unterstützte Firewall (ufw/firewalld) gefunden."
echo "Bitte öffnen Sie manuell: 8080/tcp, 8090/tcp und 5353/udp."
fi