34 Commits

Author SHA1 Message Date
stefan 1690da3fab chore/ai-guardrails-centralization
Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
2026-06-02 13:32:00 +02:00
stefan cb6e0103e7 chore(ai): centralize guardrail scripts under .ai; add resolve_repo_root; add shims for .junie/.nolik; fix gitea contexts in release.yml
Co-authored-by: Junie <junie@jetbrains.com>
2026-06-02 13:27:23 +02:00
stefan 98d0bf0c7b docs: Turnier-Dokumente für 2026 hinzugefügt
Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
2026-05-28 13:25:59 +02:00
stefan 0a90b57c2a docs: Ergänzung der Simka Core Server Dokumentation
Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
2026-05-22 13:13:24 +02:00
stefan 0ab62a2752 docs: README-Testbeschreibung aktualisiert
Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
2026-05-22 11:36:34 +02:00
stefan 6070709bf2 docs: README-Testbeschreibung aktualisiert
Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
2026-05-22 11:34:17 +02:00
stefan 763c2a9157 Test 2. Versuch zu committen
Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
2026-05-20 10:10:30 +00:00
StefanMo 4f715d10bb refactor: extrahiere ReiterLizenz in core-domain, aktualisiere Abhängigkeiten und behebe Windows-SQLite-Temp-Verzeichnisproblem 2026-05-12 23:33:48 +02:00
stefan 0b830eb675 feat: integriere VeranstaltungRepository und syncModule in Desktop-App
Signed-off-by: StefanMoCoAt <stefan.mo.co@gmail.com>
2026-05-12 19:29:51 +02:00
stefan 4c37ecb952 refactor(build): redundante Variable im Gradle-Skript entfernt
Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
2026-05-12 15:27:02 +02:00
stefan c25ef17a4a refactor(build): Typen in Gradle-Skript explizit hinzugefügt
Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
2026-05-12 15:23:52 +02:00
stefan e5e3b4cfec refactor(build): Plugin-Anwendung in Gradle-Konfiguration vereinfacht
Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
2026-05-12 15:17:47 +02:00
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
stefan 99cbfeef11 fix: aktualisiere Logs und dokumentiere Workflow-Änderungen
Signed-off-by: StefanMoCoAt <stefan.mo.co@gmail.com>
2026-05-06 23:03:30 +02:00
stefan ed1cb507cf chore: entferne überflüssigen Abschnitt aus README
Signed-off-by: StefanMoCoAt <stefan.mo.co@gmail.com>
2026-05-06 22:40:01 +02:00
stefan f4fab93a6c fix: setze Windows-Build-Workflow auf manuell und dokumentiere ARM64-Blockade
Signed-off-by: StefanMoCoAt <stefan.mo.co@gmail.com>
2026-05-06 22:37:04 +02:00
stefan 9b9f60a071 fix: stabilisiere CI-Workflow und passe JAR-Namensmuster an
Feature Build — Windows MSI (via Conveyor) / 📦 Windows .msi Packaging (push) Failing after 2m3s
Signed-off-by: StefanMoCoAt <stefan.mo.co@gmail.com>
2026-05-06 22:29:17 +02:00
stefan d219176609 fix: stabilisiere CI-Workflow und passe JAR-Namensmuster an
Feature Build — Windows MSI (via Conveyor) / 📦 Windows .msi Packaging (push) Failing after 1m19s
Signed-off-by: StefanMoCoAt <stefan.mo.co@gmail.com>
2026-05-06 22:24:15 +02:00
stefan 7411038b3b fix: entferne CI-Blockade und aktualisiere Conveyor-Konfiguration
Feature Build — Windows MSI (via Conveyor) / 📦 Windows .msi Packaging (push) Failing after 8m16s
Signed-off-by: StefanMoCoAt <stefan.mo.co@gmail.com>
2026-05-06 21:36:28 +02:00
stefan 77ee608094 feat: implementiere Cross-Packaging für Windows-MSI via Conveyor auf Linux
Feature Build — Windows MSI (via Conveyor) / 📦 Windows .msi Packaging (push) Has been skipped
Signed-off-by: StefanMoCoAt <stefan.mo.co@gmail.com>
2026-05-06 21:26:39 +02:00
107 changed files with 2346 additions and 849 deletions
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)"
# shellcheck disable=SC1091
source "$SCRIPT_DIR/lib/common.sh"
REPO_ROOT="$(resolve_repo_root)"
cd "$REPO_ROOT"
# check-docs-drift.sh
# Zweck: sehr schlanke Drift-Checks gegen die neue Doku-Struktur.
# - Kein Guidelines-System mehr.
# - Single Source of Truth: `docs/`
err=0
has() { grep -q "$2" "$1" || { echo "[DRIFT] '$2' fehlt in $1"; err=1; }; }
miss() { grep -q "$2" "$1" && { echo "[DRIFT] Veralteter Begriff '$2' in $1"; err=1; }; }
# Harte Altlast-Pfade dürfen nicht mehr vorkommen
if git grep -n "docs/00_Domain/" -- docs >/dev/null 2>&1; then
echo "[DRIFT] Veralteter Pfad 'docs/00_Domain/' in docs/* gefunden"
err=1
fi
if git grep -n "docs/adr/" -- docs >/dev/null 2>&1; then
echo "[DRIFT] Veralteter Pfad 'docs/adr/' in docs/* gefunden"
err=1
fi
if git grep -n "docs/c4/" -- docs >/dev/null 2>&1; then
echo "[DRIFT] Veralteter Pfad 'docs/c4/' in docs/* gefunden"
err=1
fi
if git grep -n "docs/how-to/" -- docs >/dev/null 2>&1; then
echo "[DRIFT] Veralteter Pfad 'docs/how-to/' in docs/* gefunden"
err=1
fi
if git grep -n "docs/reference/" -- docs >/dev/null 2>&1; then
echo "[DRIFT] Veralteter Pfad 'docs/reference/' in docs/* gefunden"
err=1
fi
# Quelle der Wahrheit: Gateway-Technologie (sollte in Architektur/ADRs/C4 konsistent sein)
has docs/01_Architecture/ARCHITECTURE.md "Spring Cloud Gateway"
has docs/01_Architecture/adr/0007-api-gateway-pattern-de.md "Spring Cloud Gateway"
miss docs/01_Architecture/adr/0007-api-gateway-pattern-de.md "Ktor"
has docs/01_Architecture/c4/02-container-de.puml "Spring Cloud Gateway"
miss docs/01_Architecture/c4/02-container-de.puml "Ktor"
exit $err
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env bash
set -euo pipefail
# Common helpers for AI guardrail scripts
# Robustly resolve the repository root directory.
# Strategy: prefer Git; fallback to marker search upwards; last resort: current dir.
resolve_repo_root() {
local start
start="${1:-$(cd -- "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)}"
if command -v git >/dev/null 2>&1; then
if git -C "$start" rev-parse --show-toplevel >/dev/null 2>&1; then
git -C "$start" rev-parse --show-toplevel
return 0
fi
fi
local dir
dir="$(cd "$start" && pwd)"
while [ "$dir" != "/" ]; do
if [ -f "$dir/gradlew" ] || [ -f "$dir/settings.gradle.kts" ] || [ -d "$dir/.git" ]; then
echo "$dir"
return 0
fi
dir="$(dirname "$dir")"
done
pwd
}
+16
View File
@@ -0,0 +1,16 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)"
# shellcheck disable=SC1091
source "$SCRIPT_DIR/lib/common.sh"
REPO_ROOT="$(resolve_repo_root)"
cd "$REPO_ROOT"
mkdir -p build/diagrams
shopt -s nullglob
for f in docs/architecture/c4/*.puml; do
docker run --rm -v "$PWD":/data plantuml/plantuml -tsvg "/data/$f" -o "/data/build/diagrams"
echo "Rendered build/diagrams/$(basename "${f%.puml}").svg"
done
+127
View File
@@ -0,0 +1,127 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)"
# shellcheck disable=SC1091
source "$SCRIPT_DIR/lib/common.sh"
REPO_ROOT="$(resolve_repo_root)"
cd "$REPO_ROOT"
QUICK_MODE=false
while [[ $# -gt 0 ]]; do
case $1 in
--quick)
QUICK_MODE=true
shift
;;
--help|-h)
cat << 'EOF'
Docs Link-Validierung
USAGE:
./.ai/scripts/validate-links.sh [--quick]
BESCHREIBUNG:
Prüft Markdown-Links in `docs/**/*.md` auf gebrochene relative Pfade.
Ignoriert externe Links (http/https/mailto) sowie reine Anchors (#...).
OPTIONEN:
--quick Führt nur eine Teilmenge der Prüfungen durch (aktuell nicht implementiert).
EOF
exit 0
;;
*)
echo "[ERROR] Unbekannter Parameter: $1" >&2
exit 2
;;
esac
done
python3 - <<'PY'
import re
import sys
from pathlib import Path
from urllib.parse import unquote
root = Path.cwd()
docs_dir = root / "docs"
if not docs_dir.is_dir():
print(f"[ERROR] docs-Verzeichnis nicht gefunden: {docs_dir}", file=sys.stderr)
sys.exit(2)
# Veraltete Pfad-Prüfungen wurden entfernt; Fokus auf Link-Integrität.
FORBIDDEN_SUBSTRINGS = []
md_files = sorted(docs_dir.rglob("*.md"))
link_pattern = re.compile(r"\]\(([^)]+)\)")
errors = 0
def is_external(target: str) -> bool:
t = target.lower()
return t.startswith("http://") or t.startswith("https://") or t.startswith("mailto:")
def strip_fragment_and_query(target: str) -> str:
target = target.split("#", 1)[0]
target = target.split("?", 1)[0]
return target
for f in md_files:
text = f.read_text(encoding="utf-8", errors="replace")
for forbidden in FORBIDDEN_SUBSTRINGS:
if forbidden in text:
print(f"[ERROR] Veralteter Pfad '{forbidden}' in {f}")
errors += 1
for match in link_pattern.finditer(text):
target = match.group(1).strip()
if not target:
continue
if is_external(target):
continue
if target.startswith("#"):
continue
if target.startswith("<") and target.endswith(">"):
target = target[1:-1]
target = unquote(strip_fragment_and_query(target))
if target.startswith("/"):
continue
if ":" in target.split("/", 1)[0]:
# z.B. "vscode:..."
continue
resolved = (f.parent / target).resolve()
try:
resolved.relative_to(root.resolve())
except ValueError:
print(f"[ERROR] Link zeigt außerhalb des Repos: {f} -> {target}")
errors += 1
continue
if resolved.is_dir():
if not (resolved / "README.md").is_file():
print(f"[ERROR] Verlinktes Verzeichnis ohne README.md: {f} -> {target}")
errors += 1
continue
if not resolved.exists():
print(f"[ERROR] Broken link: {f} -> {target}")
errors += 1
if errors:
print(f"[ERROR] Link-Validierung fehlgeschlagen: {errors} Fehler")
sys.exit(1)
print(f"[OK] Link-Validierung erfolgreich: {len(md_files)} Markdown-Dateien geprüft")
PY
+1 -1
View File
@@ -193,7 +193,7 @@ secrets/
# ===================================================================
TODO*.md
NOTES*.md
**/.junie/
.junie/
# ===================================================================
# Keep essential files (override exclusions)
+1 -1
View File
@@ -18,7 +18,7 @@ jobs:
desktop-tests:
# Komplett deaktivierbar über Repo-Variable: Settings → Variables → DESKTOP_CI_ENABLED=true
# Zusätzlich: Für PlanBBuilds überspringen, wenn Commit-Message [planb] enthält
if: ${{ vars.DESKTOP_CI_ENABLED == 'true' && !contains(github.event.head_commit.message, '[planb]') }}
if: ${{ vars.DESKTOP_CI_ENABLED == 'true' && !contains(gitea.event.head_commit.message, '[planb]') }}
name: Compose Desktop — Tests (headless) & Build
runs-on: ubuntu-latest
+5 -5
View File
@@ -1,5 +1,5 @@
name: Build and Publish Docker Images
run-name: Build & Publish by @${{ github.actor }}
run-name: Build & Publish by @${{ gitea.actor }}
on:
push:
@@ -117,8 +117,8 @@ jobs:
images: ${{ env.REGISTRY_INTERNAL }}/${{ env.IMAGE_PREFIX }}/${{ matrix.image }}
tags: |
type=ref,event=tag
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
type=sha,format=long,enable=${{ github.ref == 'refs/heads/main' }}
type=raw,value=latest,enable=${{ gitea.ref == 'refs/heads/main' }}
type=sha,format=long,enable=${{ gitea.ref == 'refs/heads/main' }}
- name: Build and push Docker image
uses: docker/build-push-action@v6
@@ -132,5 +132,5 @@ jobs:
provenance: false
sbom: false
build-args: |
BUILD_DATE=${{ github.event.head_commit.timestamp || 'unknown' }}
VERSION=${{ github.sha }}
BUILD_DATE=${{ gitea.event.head_commit.timestamp || 'unknown' }}
VERSION=${{ gitea.sha }}
+35 -23
View File
@@ -1,14 +1,14 @@
name: Feature Build — Windows MSI
name: Feature Build — Windows MSI (via Conveyor)
on:
push:
branches: [ "feature/*" ] # Reagiert auf alle Feature-Branches
workflow_dispatch: # Nur noch manueller Start möglich, da ARM64-Runner inkompatibel
# push:
# branches: [ "feature/*" ] # Deaktiviert wegen ARM64 Exec Format Error
jobs:
package-windows:
name: 📦 Windows .msi Packaging
# Nur ausführen, wenn Desktop-CI explizit aktiviert ist
if: ${{ vars.DESKTOP_CI_ENABLED == 'true' }}
runs-on: windows-latest
# Desktop-CI ist nun via Conveyor auf Linux möglich
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -18,27 +18,39 @@ jobs:
with:
distribution: 'temurin'
java-version: '21'
cache: gradle
- name: Gradle cache
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
.gradle
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', 'gradle.properties') }}
restore-keys: |
${{ runner.os }}-gradle-
- name: Windows .msi bauen
env:
_JAVA_OPTIONS: -Djava.awt.headless=true
- name: Gradle Build (Uber-JAR)
run: |
./gradlew :frontend:shells:meldestelle-desktop:packageMsi --stacktrace --no-daemon
./gradlew :frontend:shells:meldestelle-desktop:jvmJar --no-daemon
ls -lh frontend/shells/meldestelle-desktop/build/libs/
- name: Setup Conveyor
run: |
# Conveyor-Installation via Debian-Paket (stabiler in CI)
sudo apt-get update && sudo apt-get install -y curl
# Wir nutzen die offizielle Empfehlung für Debian-basierte Systeme
curl -L https://conveyor.hydraulic.dev/install.sh -o install-conveyor.sh
# Validierung: Wenn es kein Shell-Skript ist (sondern HTML), abbrechen
if grep -q "<!DOCTYPE HTML" install-conveyor.sh; then
echo "Fehler: Download-URL lieferte HTML statt Skript. Nutze npm-Fallback."
npm install -g @hydraulic/conveyor
else
chmod +x install-conveyor.sh
./install-conveyor.sh
fi
echo "$HOME/.conveyor/bin" >> $GITEA_PATH
- name: Windows .msi mit Conveyor bauen
run: |
# HINWEIS: Erfordert aktuell einen x64-Linux-Runner.
# Schlägt auf ARM64 (Zora) mit 'Exec format error' fehl.
CONVEYOR_BIN=$(which conveyor || echo "$HOME/.conveyor/bin/conveyor")
$CONVEYOR_BIN make windows-msi
- name: .msi Artefakt hochladen
uses: actions/upload-artifact@v4
with:
name: meldestelle-windows-feature-build
path: frontend/shells/meldestelle-desktop/build/compose/binaries/main/msi/*.msi
if-no-files-found: warn
path: output/*.msi
if-no-files-found: error
+1 -1
View File
@@ -5,7 +5,7 @@ on:
jobs:
no-hardcoded-versions:
# Für Plan-B-Builds überspringen: Commit-Message enthält [planb]
if: ${{ !contains(github.event.head_commit.message, '[planb]') }}
if: ${{ !contains(gitea.event.head_commit.message, '[planb]') }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
+16 -16
View File
@@ -23,7 +23,7 @@ jobs:
tag-release:
name: 🏷️ Git-Tag setzen
# Für Plan-B-Builds überspringen: Commit-Message enthält [planb]
if: ${{ !contains(github.event.head_commit.message, '[planb]') }}
if: ${{ !contains(gitea.event.head_commit.message, '[planb]') }}
runs-on: ubuntu-latest
outputs:
version: ${{ steps.read-version.outputs.version }}
@@ -64,7 +64,7 @@ jobs:
fi
- name: Git-Tag erstellen & pushen
if: steps.check-tag.outputs.already_tagged == 'false' && github.event.inputs.dry_run != 'true'
if: steps.check-tag.outputs.already_tagged == 'false' && gitea.event.inputs.dry_run != 'true'
run: |
TAG="${{ steps.read-version.outputs.tag }}"
VERSION="${{ steps.read-version.outputs.version }}"
@@ -80,7 +80,7 @@ jobs:
package-linux:
name: 📦 Linux .deb Packaging
# Nur ausführen, wenn Desktop-CI explizit aktiviert ist UND kein PlanB Commit
if: ${{ vars.DESKTOP_CI_ENABLED == 'true' && !contains(github.event.head_commit.message, '[planb]') }}
if: ${{ vars.DESKTOP_CI_ENABLED == 'true' && !contains(gitea.event.head_commit.message, '[planb]') }}
runs-on: ubuntu-latest
needs: tag-release
@@ -88,11 +88,11 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Setup JDK 21 (Temurin)
- name: Setup JDK 25 (Temurin)
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '21'
java-version: '25'
- name: Gradle cache
uses: actions/cache@v4
@@ -128,7 +128,7 @@ jobs:
package-windows:
name: 📦 Windows .msi Packaging
# Nur ausführen, wenn Desktop-CI explizit aktiviert ist UND kein PlanB Commit
if: ${{ vars.DESKTOP_CI_ENABLED == 'true' && !contains(github.event.head_commit.message, '[planb]') }}
if: ${{ vars.DESKTOP_CI_ENABLED == 'true' && !contains(gitea.event.head_commit.message, '[planb]') }}
runs-on: windows-latest
needs: tag-release
@@ -136,11 +136,11 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Setup JDK 21 (Temurin)
- name: Setup JDK 25 (Temurin)
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '21'
java-version: '25'
- name: Gradle cache
uses: actions/cache@v4
@@ -179,11 +179,11 @@ jobs:
steps:
- name: Summary ausgeben
run: |
echo "## 🚀 Release ${{ needs.tag-release.outputs.version }}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Artefakt | Status |" >> $GITHUB_STEP_SUMMARY
echo "|----------|--------|" >> $GITHUB_STEP_SUMMARY
echo "| Linux .deb | ${{ needs.package-linux.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| Windows .msi | ${{ needs.package-windows.result }} |" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Git-Tag:** \`${{ needs.tag-release.outputs.tag }}\`" >> $GITHUB_STEP_SUMMARY
echo "## 🚀 Release ${{ needs.tag-release.outputs.version }}" >> $GITEA_STEP_SUMMARY
echo "" >> $GITEA_STEP_SUMMARY
echo "| Artefakt | Status |" >> $GITEA_STEP_SUMMARY
echo "|----------|--------|" >> $GITEA_STEP_SUMMARY
echo "| Linux .deb | ${{ needs.package-linux.result }} |" >> $GITEA_STEP_SUMMARY
echo "| Windows .msi | ${{ needs.package-windows.result }} |" >> $GITEA_STEP_SUMMARY
echo "" >> $GITEA_STEP_SUMMARY
echo "**Git-Tag:** \`${{ needs.tag-release.outputs.tag }}\`" >> $GITEA_STEP_SUMMARY
+4
View File
@@ -56,3 +56,7 @@ desktop.ini
docs/temp/
docs/Bin/
docs/_archive/
# Conveyor
conveyor.rootkey
output/
+4 -40
View File
@@ -1,43 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail
# check-docs-drift.sh
# Zweck: sehr schlanke Drift-Checks gegen die neue Doku-Struktur.
# - Kein Guidelines-System mehr.
# - Single Source of Truth: `docs/`
err=0
has() { grep -q "$2" "$1" || { echo "[DRIFT] '$2' fehlt in $1"; err=1; }; }
miss() { grep -q "$2" "$1" && { echo "[DRIFT] Veralteter Begriff '$2' in $1"; err=1; }; }
# Harte Altlast-Pfade dürfen nicht mehr vorkommen
if git grep -n "docs/00_Domain/" -- docs >/dev/null 2>&1; then
echo "[DRIFT] Veralteter Pfad 'docs/00_Domain/' in docs/* gefunden"
err=1
fi
if git grep -n "docs/adr/" -- docs >/dev/null 2>&1; then
echo "[DRIFT] Veralteter Pfad 'docs/adr/' in docs/* gefunden"
err=1
fi
if git grep -n "docs/c4/" -- docs >/dev/null 2>&1; then
echo "[DRIFT] Veralteter Pfad 'docs/c4/' in docs/* gefunden"
err=1
fi
if git grep -n "docs/how-to/" -- docs >/dev/null 2>&1; then
echo "[DRIFT] Veralteter Pfad 'docs/how-to/' in docs/* gefunden"
err=1
fi
if git grep -n "docs/reference/" -- docs >/dev/null 2>&1; then
echo "[DRIFT] Veralteter Pfad 'docs/reference/' in docs/* gefunden"
err=1
fi
# Quelle der Wahrheit: Gateway-Technologie (sollte in Architektur/ADRs/C4 konsistent sein)
has docs/01_Architecture/ARCHITECTURE.md "Spring Cloud Gateway"
has docs/01_Architecture/adr/0007-api-gateway-pattern-de.md "Spring Cloud Gateway"
miss docs/01_Architecture/adr/0007-api-gateway-pattern-de.md "Ktor"
has docs/01_Architecture/c4/02-container-de.puml "Spring Cloud Gateway"
miss docs/01_Architecture/c4/02-container-de.puml "Ktor"
exit $err
# Shim: Weiterleitung auf zentrale Guardrail in .ai/
SCRIPT_DIR="$(cd -- "$(dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd)"
ROOT_DIR="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel 2>/dev/null || echo "$SCRIPT_DIR")"
exec "$ROOT_DIR/.ai/scripts/check-docs-drift.sh" "$@"
+4 -6
View File
@@ -1,9 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail
mkdir -p build/diagrams
shopt -s nullglob
for f in docs/architecture/c4/*.puml; do
docker run --rm -v "$PWD":/data plantuml/plantuml -tsvg "/data/$f" -o "/data/build/diagrams"
echo "Rendered build/diagrams/$(basename "${f%.puml}").svg"
done
# Shim: Weiterleitung auf zentrale Guardrail in .ai/
SCRIPT_DIR="$(cd -- "$(dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd)"
ROOT_DIR="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel 2>/dev/null || echo "$SCRIPT_DIR")"
exec "$ROOT_DIR/.ai/scripts/render-plantuml.sh" "$@"
+4 -133
View File
@@ -1,136 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail
# validate-links.sh - Link-Validierung für Projektdokumentation (`docs/**`).
# Zweck: Guardrail für die "Docs-as-Code"-Strategie.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
cd "$PROJECT_ROOT"
QUICK_MODE=false
while [[ $# -gt 0 ]]; do
case $1 in
--quick)
QUICK_MODE=true
shift
;;
--help|-h)
cat << 'EOF'
Docs Link-Validierung
USAGE:
./.junie/scripts/validate-links.sh [--quick]
BESCHREIBUNG:
Prüft Markdown-Links in `docs/**/*.md` auf gebrochene relative Pfade.
Ignoriert externe Links (http/https/mailto) sowie reine Anchors (#...).
OPTIONEN:
--quick Führt nur eine Teilmenge der Prüfungen durch (aktuell nicht implementiert).
EOF
exit 0
;;
*)
echo "[ERROR] Unbekannter Parameter: $1" >&2
exit 2
;;
esac
done
python3 - <<'PY'
import os
import re
import sys
from pathlib import Path
from urllib.parse import unquote
root = Path.cwd()
docs_dir = root / "docs"
if not docs_dir.is_dir():
print(f"[ERROR] docs-Verzeichnis nicht gefunden: {docs_dir}", file=sys.stderr)
sys.exit(2)
# Veraltete Pfad-Prüfungen wurden entfernt, da sie zu wartungsintensiv waren.
# Das Skript konzentriert sich nun auf die Validierung der Link-Integrität.
FORBIDDEN_SUBSTRINGS = []
md_files = sorted(docs_dir.rglob("*.md"))
link_pattern = re.compile(r"\]\(([^)]+)\)")
errors = 0
def is_external(target: str) -> bool:
t = target.lower()
return t.startswith("http://") or t.startswith("https://") or t.startswith("mailto:")
def strip_fragment_and_query(target: str) -> str:
# remove fragment and query parts
target = target.split("#", 1)[0]
target = target.split("?", 1)[0]
return target
for f in md_files:
text = f.read_text(encoding="utf-8", errors="replace")
for forbidden in FORBIDDEN_SUBSTRINGS:
if forbidden in text:
print(f"[ERROR] Veralteter Pfad '{forbidden}' in {f}")
errors += 1
for match in link_pattern.finditer(text):
target = match.group(1).strip()
if not target:
continue
if is_external(target):
continue
if target.startswith("#"):
continue
# drop angle brackets <...> used in markdown for urls with spaces
if target.startswith("<") and target.endswith(">"):
target = target[1:-1]
target = unquote(strip_fragment_and_query(target))
# ignore absolute paths in the repo (we treat them as doc-style links; validate only if relative)
if target.startswith("/"):
continue
# ignore non-file targets (e.g. empty or protocol-less anchors)
if ":" in target.split("/", 1)[0]:
# things like "vscode:..." etc.
continue
# treat as file path relative to markdown file
resolved = (f.parent / target).resolve()
# keep validation within repo
try:
resolved.relative_to(root.resolve())
except ValueError:
print(f"[ERROR] Link zeigt außerhalb des Repos: {f} -> {target}")
errors += 1
continue
# allow directories if they contain README.md
if resolved.is_dir():
if not (resolved / "README.md").is_file():
print(f"[ERROR] Verlinktes Verzeichnis ohne README.md: {f} -> {target}")
errors += 1
continue
if not resolved.exists():
print(f"[ERROR] Broken link: {f} -> {target}")
errors += 1
if errors:
print(f"[ERROR] Link-Validierung fehlgeschlagen: {errors} Fehler")
sys.exit(1)
print(f"[OK] Link-Validierung erfolgreich: {len(md_files)} Markdown-Dateien geprüft")
PY
# Shim: Weiterleitung auf zentrale Guardrail in .ai/
SCRIPT_DIR="$(cd -- "$(dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd)"
ROOT_DIR="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel 2>/dev/null || echo "$SCRIPT_DIR")"
exec "$ROOT_DIR/.ai/scripts/validate-links.sh" "$@"
+21
View File
@@ -0,0 +1,21 @@
# .aiignore - Verhindert Token-Waste für Nolik
# Abhängigkeiten & Binaries
build/
.gradle/
*.jar
*.deb
*.msi
# Sensible Daten (auch lokal!)
.env
.env.*
config/docker/certs/
*.pem
*.jks
postgres-data/
valkey-data/
# Doku-Builds (Nolik soll die Source-Files in docs/ lesen, nicht die HTML-Exporte)
build/dokka/
docs/Neumarkt2026/*.pdf
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail
# Shim: Weiterleitung auf zentrale Guardrail in .ai/
SCRIPT_DIR="$(cd -- "$(dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd)"
ROOT_DIR="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel 2>/dev/null || echo "$SCRIPT_DIR")"
exec "$ROOT_DIR/.ai/scripts/check-docs-drift.sh" "$@"
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail
# Shim: Weiterleitung auf zentrale Guardrail in .ai/
SCRIPT_DIR="$(cd -- "$(dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd)"
ROOT_DIR="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel 2>/dev/null || echo "$SCRIPT_DIR")"
exec "$ROOT_DIR/.ai/scripts/validate-links.sh" "$@"
+6 -2
View File
@@ -20,7 +20,7 @@ Die gesamte Projektdokumentation (Architektur, Fachdomäne, Entwickler-Anleitung
| [03_Domain](./docs/03_Domain) | Fachlichkeit, Turnierregeln, Entities |
| [07_Infrastructure](./docs/07_Infrastructure) | Docker, Keycloak, CI/CD, Zora-Infrastruktur |
Wesentliche Architektur-Referenz: [OfflineFirst Desktop & Backend (Kurzkonzept)](./docs/01_Architecture/konzept-offline-first-desktop-backend-de.md)
Wesentliche Architektur-Referenz: [OfflineFirst Desktop & Backend (Kurzkonzept)](./docs/01_Architecture/Concepts/konzept-offline-first-desktop-backend-de.md)
---
@@ -114,4 +114,8 @@ Beiträge sind willkommen. Bitte lies zunächst die Entwickler-Guides unter [`do
Dieses Projekt steht unter der [MIT License](LICENSE).
## Gitea - Test
---
## Test
Das ist der 2. Versuch über Remote zu Committen
@@ -8,7 +8,7 @@ import io.valkey.springframework.data.valkey.core.ValkeyTemplate
import io.valkey.springframework.data.valkey.serializer.StringValkeySerializer
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.testcontainers.containers.GenericContainer
@@ -70,7 +70,7 @@ class ValkeyDistributedCachePerformanceTest {
}
@Test
fun `test cache performance with high concurrent access`() = runTest {
fun `test cache performance with high concurrent access`() = runBlocking {
logger.info { "Starting concurrent access test" }
val numberOfCoroutines = 100
val operationsPerCoroutine = 50
+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,6 +2,7 @@
package at.mocode.zns.importer
import at.mocode.core.domain.model.ReiterLizenz
import at.mocode.masterdata.domain.repository.*
import at.mocode.zns.parser.ZnsFunktionaerParser
import at.mocode.zns.parser.ZnsPferdParser
@@ -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
@@ -3,6 +3,7 @@
package at.mocode.masterdata.domain.model
import at.mocode.core.domain.model.DatenQuelleE
import at.mocode.core.domain.model.ReiterLizenz
import at.mocode.core.domain.model.ReiterLizenzKlasseE
import at.mocode.core.domain.serialization.InstantSerializer
import at.mocode.core.domain.serialization.LocalDateSerializer
@@ -14,16 +15,6 @@ import kotlin.time.Clock
import kotlin.time.Instant
import kotlin.uuid.Uuid
@Serializable
data class ReiterLizenz(
@Serializable(with = UuidSerializer::class)
val lizenzId: Uuid = Uuid.random(),
val lizenzTyp: String, // STARTKARTE, REITERLIZENZ, FAHRLIZENZ
val kuerzel: String,
@Serializable(with = LocalDateSerializer::class)
val gueltigBis: LocalDate? = null
)
/**
* Domain model representing a rider (Reiter) in the actor-context.
*
@@ -4,10 +4,10 @@ package at.mocode.masterdata.infrastructure.persistence.reiter
import at.mocode.core.domain.model.DatenQuelleE
import at.mocode.core.domain.model.ReiterAltersKlasseE
import at.mocode.core.domain.model.ReiterLizenz
import at.mocode.core.domain.model.ReiterLizenzKlasseE
import at.mocode.core.utils.database.DatabaseFactory
import at.mocode.masterdata.domain.model.Reiter
import at.mocode.masterdata.domain.model.ReiterLizenz
import at.mocode.masterdata.domain.repository.ReiterRepository
import org.jetbrains.exposed.v1.core.ResultRow
import org.jetbrains.exposed.v1.core.and
+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
+51 -30
View File
@@ -38,7 +38,7 @@ plugins {
// ### ALLPROJECTS CONFIGURATION ###
// ##################################################################
val isWasmEnabled = findProperty("enableWasm")?.toString()?.toBoolean() ?: false
val isWasmEnabled: Boolean = findProperty("enableWasm")?.toString()?.toBoolean() ?: false
// ---------------------------------------------------------------
// Zentrale Versionierung — liest version.properties (SemVer)
@@ -47,10 +47,10 @@ val versionProps =
java.util.Properties().also { props ->
rootProject.file("version.properties").inputStream().use { props.load(it) }
}
val vMajor = versionProps.getProperty("VERSION_MAJOR", "1")
val vMinor = versionProps.getProperty("VERSION_MINOR", "0")
val vPatch = versionProps.getProperty("VERSION_PATCH", "0")
val vQualifier = versionProps.getProperty("VERSION_QUALIFIER", "").trim()
val vMajor: String = versionProps.getProperty("VERSION_MAJOR", "1")
val vMinor: String = versionProps.getProperty("VERSION_MINOR", "0")
val vPatch: String = versionProps.getProperty("VERSION_PATCH", "0")
val vQualifier: String = versionProps.getProperty("VERSION_QUALIFIER", "").trim()
val semVer = if (vQualifier.isBlank()) "$vMajor.$vMinor.$vPatch" else "$vMajor.$vMinor.$vPatch-$vQualifier"
allprojects {
@@ -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
@@ -113,7 +113,7 @@ subprojects {
// (A) Source map configuration is handled via `gradle.properties` (global Kotlin/JS settings)
// to avoid compiler-flag incompatibilities across toolchains.
// (B) Conditional Wasm/JS Target handling based on `enableWasm` property
// (B) Conditional Wasm/JS Target handling based on the ` enableWasm ` property
// This significantly reduces build times during Desktop development.
// Flag is defined at the beginning of the script.
@@ -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")
}
@@ -175,20 +176,30 @@ subprojects {
// Applies to all Exec-based tasks (covers Yarn/NPM invocations used by Kotlin JS plugin)
tasks.withType<Exec>().configureEach {
// Merge existing NODE_OPTIONS with --no-deprecation
val current = (environment["NODE_OPTIONS"] as String?) ?: System.getenv("NODE_OPTIONS")
val merged = if (current.isNullOrBlank()) "--no-deprecation" else "$current --no-deprecation"
val current: String? = (environment["NODE_OPTIONS"] as String?) ?: System.getenv("NODE_OPTIONS")
val merged: String = if (current.isNullOrBlank()) "--no-deprecation" else "$current --no-deprecation"
environment("NODE_OPTIONS", merged)
// Also set the legacy switch to silence warnings entirely
environment("NODE_NO_WARNINGS", "1")
// Set a Chrome binary path to avoid snap permission issues
environment("CHROME_BIN", "/usr/bin/google-chrome-stable")
environment("CHROMIUM_BIN", "/usr/bin/chromium")
environment("PUPPETEER_EXECUTABLE_PATH", "/usr/bin/chromium")
if (System.getProperty("os.name").contains("Linux", ignoreCase = true)) {
environment("CHROME_BIN", "/usr/bin/google-chrome-stable")
environment("CHROMIUM_BIN", "/usr/bin/chromium")
environment("PUPPETEER_EXECUTABLE_PATH", "/usr/bin/chromium")
}
}
// ------------------------------
// 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
@@ -267,7 +278,6 @@ tasks.register("checkBundleBudget") {
}
shells.forEach { shell ->
val key = shell.path.trimStart(':').replace(':', '/') // or use a colon form for budgets keys below
val colonKey = shell.path.trimStart(':').replace('/', ':').trim() // ensure ":a:b:c"
// Budgets are keyed by a Gradle path with colons but without leading colon in config for readability
val budgetKeyCandidates =
@@ -362,8 +372,8 @@ tasks.register("staticAnalysis") {
// Apply Dokka (V2) automatically to Kotlin subprojects
subprojects {
plugins.withId("org.jetbrains.kotlin.jvm") { apply(plugin = "org.jetbrains.dokka") }
plugins.withId("org.jetbrains.kotlin.multiplatform") { apply(plugin = "org.jetbrains.dokka") }
plugins.withId("org.jetbrains.kotlin.jvm") { pluginManager.apply("org.jetbrains.dokka") }
plugins.withId("org.jetbrains.kotlin.multiplatform") { pluginManager.apply("org.jetbrains.dokka") }
}
// Aggregate tasks to build multi-module docs in Markdown (GFM) and HTML
@@ -372,27 +382,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)
}
}
@@ -451,17 +470,19 @@ tasks.register("docs") {
// Apply Node warning suppression on root project Exec tasks as well
// Ensures aggregated Kotlin/JS tasks created at root (e.g., kotlinNpmInstall) inherit the env
tasks.withType<Exec>().configureEach {
val current = (environment["NODE_OPTIONS"] as String?) ?: System.getenv("NODE_OPTIONS")
val merged = if (current.isNullOrBlank()) "--no-deprecation" else "$current --no-deprecation"
val current: String? = (environment["NODE_OPTIONS"] as String?) ?: System.getenv("NODE_OPTIONS")
val merged: String = if (current.isNullOrBlank()) "--no-deprecation" else "$current --no-deprecation"
environment("NODE_OPTIONS", merged)
environment("NODE_NO_WARNINGS", "1")
// Set a Chrome binary path to avoid snap permission issues
environment("CHROME_BIN", "/usr/bin/google-chrome-stable")
environment("CHROMIUM_BIN", "/usr/bin/chromium")
environment("PUPPETEER_EXECUTABLE_PATH", "/usr/bin/chromium")
if (System.getProperty("os.name").contains("Linux", ignoreCase = true)) {
environment("CHROME_BIN", "/usr/bin/google-chrome-stable")
environment("CHROMIUM_BIN", "/usr/bin/chromium")
environment("PUPPETEER_EXECUTABLE_PATH", "/usr/bin/chromium")
}
}
tasks.wrapper {
gradleVersion = "9.4.1"
gradleVersion = "9.5.0"
distributionType = Wrapper.DistributionType.BIN
}
+46
View File
@@ -0,0 +1,46 @@
include required("/stdlib/jdk/21/amazon.conf")
include required("https://raw.githubusercontent.com/hydraulic-software/conveyor/master/configs/jvm/extract-native-libraries.conf")
app {
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"
# Ziel-Plattformen: Windows und Linux
machines = [ windows.amd64, linux.amd64.glibc ]
site.base-url = "localhost"
# Icons werden im Ordner gesucht
icons = "frontend/shells/meldestelle-desktop/src/jvmMain/resources/icon.png"
jvm {
gui {
main-class = "at.mocode.frontend.shell.desktop.MainKt"
}
jvm-options = [
"-Xms256m",
"-Xmx1024m",
"-Dfile.encoding=UTF-8",
"--enable-native-access=ALL-UNNAMED"
]
}
# JARs aus dem Gradle-Build
inputs += "frontend/shells/meldestelle-desktop/build/libs/*.jar"
windows {
upgrade-uuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
menu-group = "Meldestelle"
desktop-shortcut = true
}
linux {
debian.control.depends = "libasound2, libgl1-mesa-glx, libx11-6"
}
}
conveyor.compatibility-level = 22
@@ -0,0 +1,19 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.core.domain.model
import at.mocode.core.domain.serialization.LocalDateSerializer
import at.mocode.core.domain.serialization.UuidSerializer
import kotlinx.datetime.LocalDate
import kotlinx.serialization.Serializable
import kotlin.uuid.Uuid
@Serializable
data class ReiterLizenz(
@Serializable(with = UuidSerializer::class)
val lizenzId: Uuid = Uuid.random(),
val lizenzTyp: String, // STARTKARTE, REITERLIZENZ, FAHRLIZENZ
val kuerzel: String,
@Serializable(with = LocalDateSerializer::class)
val gueltigBis: LocalDate? = null
)
@@ -1,10 +1,10 @@
package at.mocode.zns.parser
import at.mocode.core.domain.model.DatenQuelleE
import at.mocode.core.domain.model.ReiterLizenz
import at.mocode.core.domain.model.ReiterLizenzKlasseE
import at.mocode.core.utils.parser.FixedWidthLineReader
import at.mocode.masterdata.domain.model.Reiter
import at.mocode.masterdata.domain.model.ReiterLizenz
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
+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:
+5 -2
View File
@@ -2,7 +2,7 @@
type: Roadmap
status: ACTIVE
owner: Lead Architect
last_update: 2026-04-30
last_update: 2026-05-06
---
# MASTER ROADMAP: Meldestelle
@@ -87,7 +87,10 @@ Fokus: Physische Implementierung der Turnier-Hierarchie und technisches Onboardi
* [x] **Handshake-Feedback:** Visuelle Signalisierung des Verbindungsstatus (Grün/Rot).
* [x] **Client-Konfiguration:** Master kann nun Clients in der UI hinzufügen und bearbeiten.
* [x] **Master-UX:** Konfiguration beim Start nicht mehr zwangsgesperrt.
* [ ] **PoC Verifikation:** 🔴 **FEHLGESCHLAGEN** (Hardware-Test durch User nicht erfolgreich - Analyse für Abend-Session erforderlich).
* [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).
### MEILENSTEIN 1: Die Basis-Hierarchie (Prio 1) ⚪ GEPLANT
@@ -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,46 @@
# Journal-Eintrag: 06.05.2026 - Windows Cross-Packaging mit Conveyor
## Kontext
Der Windows-Build (.msi) war bisher blockiert, da unser Gitea-Runner ("Zora") auf ARM64-Linux läuft und das
Standard-Compose-Plugin zwingend eine Windows-Umgebung mit WiX Toolset für MSI-Pakete benötigt. Dies führte zu
ständigen "Roten Kreuzen" in der CI.
## Durchgeführte Arbeiten
### 1. 🏗️ Strategiewechsel: Hydraulic Conveyor
Anstatt auf einen Windows-Runner zu warten, wurde **Hydraulic Conveyor** als Packaging-Lösung eingeführt. Conveyor
erlaubt den Bau von Windows-MSI-Paketen direkt auf Linux, indem es eigene Toolchains mitbringt.
- **`conveyor.conf` erstellt:** Zentrale Konfiguration für die Desktop-App (Icons, JVM-Argumente, Windows-spezifische
GUIDs).
- **Eingangsquelle:** Nutzt das JVM-JAR des Desktop-Shell-Moduls als Input.
### 2. 🐧 Gitea-Workflow Update
Der Workflow `.gitea/workflows/feature-build.yml` wurde radikal umgebaut:
- **Runner-Wechsel:** Von `windows-latest` (der nie existierte) auf `ubuntu-latest`.
- **Build-Schritte:**
1. Gradle `jvmJar` erstellt die Plattform-unabhängige JAR.
2. Installation von Conveyor via CLI.
3. `conveyor make windows-msi` erzeugt das Paket.
- **Artefakte:** Die resultierende `.msi`-Datei wird nun korrekt in der Gitea-UI hochgeladen.
## Status: In Arbeit (Verifikation ausstehend)
- **CI-Update:** Die Blockade durch die Variable `DESKTOP_CI_ENABLED` wurde entfernt. Der Workflow läuft nun bei jedem
Push auf einen Feature-Branch.
- **Input-Fix:** Die `conveyor.conf` wurde auf das spezifische JAR-Namensmuster (`meldestelle-desktop-jvm-*.jar`)
angepasst.
- **Workflow-Stabilisierung:** Das Installations-Skript wurde um eine HTML-Validierung und einen **npm-Fallback**
erweitert.
- **Architektur-Blockade:** Analyse von Log #483 zeigt, dass Hydraulic Conveyor aktuell keine ARM64-Linux Binaries
bereitstellt (`Exec format error`).
- **Entscheidung:** Der Workflow wurde auf `manual` (`workflow_dispatch`) gesetzt, um Gitea-Rauschen zu vermeiden. Der
Windows-Build erfordert
einen x64-Runner oder muss weiterhin lokal beim User erfolgen.
**🏗️ [Lead Architect]**
**🐧 [DevOps Engineer]**
@@ -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.
+202
View File
@@ -0,0 +1,202 @@
# System-Dokumentation: Simka Core Server
## 1. Wer oder was ist Simka?
**Simka** ist einer der beiden zentralen Pfeiler deiner hochperformanten, selbstgehosteten **Keller-Server-Landschaft** (neben dem zweiten Server *Zora*). Während Zora (dein Minisforum MS-R1 ARM64-Arbeitstier) primär für Core-Dienste und Datenhoheit zuständig ist, fungiert Simka als das eigentliche **Performance- und Rechenzentrum** im Keller.
### Core-Spezifikationen & Hardware-Zustand (Stand: Mai 2026)
* **Architektur:** x86_64 High-Performance Mini-PC / Server.
* **Betriebssystem / Hypervisor:** **Proxmox VE 9.2** (aktuell auf Kernel/PVE-Stand 9.2.2).
* **Kühlung & Wartung:** Frisch saniert im Mai 2026 mit **ARCTIC MX-7** Wärmeleitpaste auf der CPU und **ARCTIC TP-4** High-Performance Wärmeleitpads für optimale thermische Stabilität unter Volllast.
* **Speichermedium:** Ultraschnelle NVMe-SSDs für die lokale Container- und VM-Ausführung.
### Das anstehende "Monster-Upgrade" (Lieferung bis Ende der Woche)
Simka wird in den nächsten Tagen hardwareseitig massiv aufgerüstet, um für lokale KI-Workloads und rechenintensive Pipelines bereitzuhoben:
1. **Arbeitsspeicher:** Erweiterung auf **64 GB DDR5 4800Mhz RAM** (maximale Kapazität für massives Multitasking und In-Memory-Datenbanken).
2. **Grafikkarte / GPU:** **PNY NVIDIA RTX 2000E Ada Generation**
* *Formfaktor:* Single-Slot & Low-Profile (perfekt für kompakte Servergehäuse, zieht Strom rein über den PCIe-Steckplatz).
* *Leistung:* Extrem energieeffiziente Ada-Lovelace-Architektur mit maximal **50W TDP** im Peak.
* *VRAM:* **16 GB Videospeicher mit ECC-Fehlerkorrektur** das absolute Fundament für den ausfallsicheren 24/7-Betrieb von lokalen LLMs (Large Language Models).
---
## 2. Netzwerk-Konfiguration in der Keller-Landschaft
Simka ist netzwerktechnisch perfekt redundant und isoliert aufgestellt. Die Kommunikation teilt sich auf zwei dedizierte Leitungen auf:
```
[ Erdgeschoss / Internet ]
│ (A1-Router / Fritzbox)
[ Keller-Switch ]
│ │
│ (10.0.0.20)
│ ▼
│ ┌───────────┐
│ │ ZORA │◄──────┐
│ └───────────┘ │
│ (10.0.0.40) │ Der exklusive
└──────►┌───────────┐ │ Keller-Highway
│ SIMKA │ │ (192.168.99.X)
└───────────┘ │ Latenz: ~0.3 ms
▲ │
└───────────┘
```
### A. Der primäre Uplink (`vmbr0`)
* **Physisches Interface:** `nic0`
* **Lokale IP:** `10.0.0.40/24`
* **Gateway:** `10.0.0.138` (A1-Router / Fritzbox)
* **Zweck:** Dieser Port verbindet Simka mit dem Hausnetzwerk und dem Internet. Über diesen Pfad greifst du via **Pangolin-Tunnel** (z. B. unter `https://fotos.mo-home.at`) sicher von außen (Remote von der Arbeit) auf deine Dienste zu.
### B. Der exklusive "Keller-Highway" (`vmbr1`) *Neu eingerichtet!*
* **Physisches Interface:** `nic1` (Direktverbindung per Standard-Netzwerkkabel zu Zoras Interface `enp49s0`).
* **Lokale IP:** `192.168.99.2/24`
* **Gateway / DNS:** *Keines* (isoliertes Subnetz).
* **Status:** Aktiviert mit **Autostart** bei Boot.
* **Performance:** Saubere **0,33 ms Latenz** im Ping-Test, 0% Paketverlust.
* **Zweck:** Blitzschnelle, vom restlichen Heimnetzwerk komplett isolierte Direktverbindung zwischen Zora und Simka. Hierüber laufen zukünftig automatisierte Backup-Pipelines, CI/CD-Prozesse und der interne Datenaustausch, ohne das normale LAN oder die Router-CPU zu belasten.
---
## 3. Container & Dienste: Wer läuft wo?
Aktuell ist die Bereitstellung auf Simka modular und ressourceneffizient über Proxmox-Strukturen gelöst. Das Herzstück bildet die frisch migrierte Fotosammlung.
### LXC-Container: `simka-immich`
* **Umgebung:** `/opt/immich`
* **Konfiguration:** Docker Compose Stack.
* **Dienste im Stack:**
* `immich_server`: Das Hauptsystem der App (erreichbar lokal unter `http://10.0.0.40:2283`).
* `immich_machine_learning`: Zuständig für Objekterkennung und CLIP-Inferenz.
* `immich_postgres`: Die relationale Datenbank (PostgreSQL 14 mit `pgvector`/`vectorchord` Erweiterung).
* `immich_redis`: In-Memory-Datenstruktur-Store für Caching und Job-Queues.
* **Daten-Zustand:**
* **Datenbank:** Erfolgreich importiert aus der `immich-db-backup.sql` (**610 MB** an Metadaten, Pfaden und Strukturen). Die alte, leere Auto-Datenbank wurde zuvor sauber via `dropdb` gelöscht.
* **Bilderpool:** Satte **~780 GB an Daten (131.578 Fotos/Videos)** liegen direkt lokal auf Simkas schneller NVMe-SSD.
* **Aktueller Betriebsmodus:** Reine **CPU-Ausführung** (x86_64). Die Mitternachts-Jobs und Worker-Anzahlen für Video-Transcoding sind CPU-schonend konfiguriert (max. 12 Worker), um den Server bis zum Wochenende nicht zu überlasten.
* **Zugriff:** Voll funktionsfähig lokal sowie mobil über die Android-App via Pangolin-Tunnel (Plattform-SSO wurde für den reibungslosen API-Sync der App deaktiviert).
---
## 4. Die Roadmap für das kommende Wochenende 🚀
Sobald die Hardware geliefert wurde, wird Simka schrittweise zum KI-Kraftpaket ausgebaut:
1. **Hardware-Injektion:** Einbau der 64 GB DDR5 RAM und der NVIDIA RTX 2000E Ada.
2. **Die "Simka Core AI" VM:** Erstellung einer dedizierten virtuellen Maschine (z. B. Ubuntu/Fedora Server) in Proxmox.
* *PCIe-Passthrough:* Die RTX 2000E wird exklusiv direkt in diese KI-VM durchgereicht.
* *LLM-Infrastruktur:* Installation von **Ollama**. Dank der 16 GB ECC-VRAM laden wir dort mächtige Coding-Modelle wie `qwen2.5-coder:7b` (oder quantisierte 14b-Versionen) für blitzschnelle Code-Generierung in deiner IntelliJ-IDE auf deinem Ubuntu-Entwicklungs-PC.
3. **Immich-Beschleunigung:** Umstellung des `simka-immich` Docker-Stacks auf GPU-Inferenz. Die 88 Tensor-Kerne der RTX übernehmen dann die Gesichtserkennung und das Video-Transcoding in Millisekunden, wodurch die CPU komplett entlastet wird.
4. **CI/CD-Ausbau:** Migration des Gitea-Runners für dein Projekt **Meldestelle** aus der alten VM-Infrastruktur in einen schlanken, nativen LXC-Container auf Simka, der über den 0,3-ms-Highway direkt an dein Gitea auf Zora andockt.
---
## 5. Architektur- und Betriebs-Einschätzung (Ablage)
🏗️ [Lead Architect]
### Einschätzung der Architektur
- Starker, sinnvoller Plattform-Split: Zora = Core/Control-Plane, Simka = Compute/Throughput. Das entspricht einer sauberen Trennung von „Authority“ und „Acceleration“ und passt zur Desktop-/OfflineFirst Strategie der Meldestelle.
- Netzwerk-Topologie mit dediziertem „Keller-Highway“ (vmbr1, 192.168.99.0/24) ist genau richtig für Replikation, CI/CD und Bulk-Daten. Minimiert Latenz/Jitter und entkoppelt vom Heimnetz.
- Hardware-Entscheidung: RTX 2000E Ada (16 GB ECC, 50 W) ist ein hocheffizienter, 24/7tauglicher InferenzBeschleuniger. Für LLMs bis 7B (FP16) und 14B (ggf. quantisiert) sowie NVENC/Transcoding ist sie sehr passend. Gegenüber „dickeren“ Karten (z. B. 4090/6000 Ada) verlierst du PeakThroughput, gewinnst aber Effizienz, Geräusch, thermische Stabilität und ECC.
### Architektur-Empfehlungen (kurz & konkret)
- Datenpfade klar trennen:
- „Hot“ AI/Transcode scratch auf lokaler NVMe (Simka)
- „Warm“ Artifacts/Backups via vmbr1 zu Zora
- Standards definieren (DokuasCode):
- ADR: „GPUPassthrough für ComputeVMs“ (Begründung, Alternativen, Tradeoffs)
- ADR: „LXC vs. VM für Runner/Builds“ (siehe DevOpsTeil)
- Observability als Querschnitt: Einheitliche MetrikNamen/Labels für Hosts, VMs, LXC (Node exporter/Nvidia exporter), damit Kapazitätsplanung reproduzierbar ist.
---
🐧 [DevOps Engineer]
### Was ist gut gelaufen
- Proxmox VE 9.2 auf aktuellem Stand, dedizierter LXCStack für Immich, PingLatenz ~0,33 ms auf vmbr1: sehr solide Grundlage.
- Saubere Migration der ImmichDatenbank und großer Medienpool lokal auf NVMe → I/OBottlenecks minimiert.
### Konkreter WochenendPlan (Checkliste)
1) HardwareEinbau und Firmware
- RAM auf 64 GB einsetzen, Memtest (mind. 1 Pass) → Verifikation erforderlich.
- GPU einsetzen; BIOS/UEFI:
- Primäre Anzeige auf iGPU/Onboard setzen (damit dGPU frei für Passthrough bleibt)
- Resizable BAR an (falls angeboten), SRIOV/IOMMU aktiv.
2) Proxmox: NVIDIA GPUPassthrough für KIVM
- Kernel/Boot: IOMMU aktivieren (`amd_iommu=on`), Neustart; prüfen, dass GPU in eigenem IOMMUGroup landet.
- Host: `vfio-pci` binden, Nouveau/NVIDIAHosttreiber nicht laden (Host soll die GPU nicht claimen).
- VM anlegen (Ubuntu Server LTS oder Fedora Server), PCIeGerät hinzufügen (GPU + ggf. GPUAudio Function), MSIInterrupts aktivieren.
- InGuest: NVIDIA Treiber + CUDA/CuDNN installieren; `nvidia-smi` muss die Karte stabil zeigen.
3) „Simka Core AI“ VM: Modelle & Runtime
- Ollama + optional OpenWebUI installieren.
- Modelle (Beispiele mit 16 GB VRAM):
- qwen2.5coder:7b (FP16) oder 14b quantisiert (Q4_K_M/Q6_K)
- DeepSeekcoder 6.7B, Llama3.1/3.2 8B Instruct quantisiert für Tools/Chat
- Policies:
- Max. Parallelität und KVCache Limits definieren, um OOM zu vermeiden.
- Watchdog/ServiceUnit für automatischen Neustart bei TreiberResets.
4) Immich auf GPU beschleunigen
- Auf dem LXCHost NVIDIA Container Toolkit bereitstellen oder, falls LXC zu restriktiv ist: Immich in eine leichte VM oder in einen privilegierten LXC migrieren.
- dockercompose Anpassungen:
- `immich_machine_learning` mit `--gpus=all`/`runtime: nvidia`
- FFmpeg HWAccel aktivieren: `-hwaccel cuda -hwaccel_output_format cuda -c:v h264_nvenc/hevc_nvenc`
- WorkerLimits neu justieren (mehr TranscodeJobs, wenn GPU an Bord)
- Verifikation: Testtranscode und EmbeddingJob messen (Metriken/Logs sichern).
5) Gitea Runner Migration (Meldestelle CI/CD)
- Entscheidung: LXC vs. VM
- LXC (privileged, `nesting=1`, `keyctl=1`) ist ressourcenschonend, aber DockerinLXC erfordert Sorgfalt.
- Alternativ: kleine „RunnerVM“ mit Docker oft robuster bei komplexen BuildNeeds (z. B. Android/Compose Desktop Toolchains).
- Netzwerk: Runner nutzt vmbr1, verbindet sich mit Gitea auf Zora, DNS via Hostfile/Static Route, kein DefaultGW nötig.
- Caching: Maven/Gradle Cache persistent auf separatem Volume; ArtefaktUpload über vmbr1.
6) NetzwerkFeinschliff
- vmbr1 ohne Gateway belassen; auf Hosts `metric`/PolicyRouting setzen, damit Traffic für 192.168.99.0/24 strikt lokal bleibt.
- Optional Jumbo Frames (MTU 9000) testen, wenn NICs/Switch/DirectLink es stabil können.
- ProxmoxFirewall:
- vmbr0 restriktiv (nur notwendige IngressPorts)
- vmbr1 nur HostzuHost/Backup/CI erlauben
7) Backup & Wiederherstellung
- Immich: DB Dumps (pg_dump) + ObjektSpeicher Sync via vmbr1 zu Zora (z. B. borg/restic/ZFS sendrecv).
- Proxmox: regelmäßige VM/LXC Snapshots + OffhostKopie.
- RecoveryDrills: 1x/Quartal Wiederherstellung in TestVM durchführen → „Verifikation ausstehend“ bis Log vorhanden.
8) Observability & Betrieb
- Node Exporter + NVIDIA DCGM/Exporter auf Simka; Prometheus/Grafana auf Zora aggregiert.
- Alarme: NVMe WearLevel, GPU ECC Errors, Temp, OOMKills, Docker/Container Restarts.
- Energie: Optional `nvidia-smi -pl` PowerLimit dokumentieren (wenn thermisch nötig), persistent via Systemd Unit.
### Risiken/Watchouts
- LXC + GPU ist möglich, aber distributions/kernelabhängig; wenn TreiberBindung hakelt, früh auf VM umschwenken.
- RTX 2000E hat 16 GB VRAM: bei mehreren gleichzeitigen LLMSessions aggressiv quantisieren oder serielle Ausführung planen.
- PangolinTunnel: Secrets/Token sicher hinterlegen und Rotation terminieren; 2FA erzwingen.
---
🧹 [Curator]
### Dokumentations- und AbschlussTasks
- Dieses „Simka Core Server“ Dokument ist ein sehr guter Start. Ergänze bitte:
- Abschnitt „Verifikation/Beweise“ mit Links/Screenshots/Logs zu: Memtest, `nvidia-smi`, ImmichGPUTranscodeProbe, Ollama InferenzBenchmark, CIRunner „connected“ Status.
- ADREinträge unter `docs/01_Architecture/ADRs/`:
- ADR00X „Dedizierter KellerHighway (vmbr1) für Bulk/CI/Backup“
- ADR00X „GPUPassthrough für KIVM auf Proxmox“
- ADR00X „Runner: LXC (privileged) vs. kleine VM Entscheidung & Gründe“
- Runbook „Simka Operations“: BootReihenfolge, HealthChecks, Troubleshooting (GPU Reset, TreiberReinstall, ContainerRestart).
- AntiHalluzinationsProtokoll anwenden:
- Kein „erledigt“ ohne Build/TestBeweis; markiere alle neuen Punkte als „Verifikation ausstehend“, bis Logs/Artefakte abgelegt sind.
- Inventar & Versionen pflegen:
- BIOS/UEFIVersion, ProxmoxKernel, NVIDIATreiber, Docker Compose Hash, Immich Version, DB Schema Version.
### Verifikation/Beweise (Platzhalter Verifikation ausstehend)
- [ ] Memtest86+ Log (mind. 1 Pass, fehlerfrei)
- [ ] `nvidia-smi` Ausgabe in der KIVM (GPU erkannt, ECC aktiv, TreiberVersion)
- [ ] Immich: GPUbeschleunigter TranscodeTest (Log + Metriken)
- [ ] Ollama: InferenzBenchmark (Modell + Prompt + Zeit + VRAMAuslastung)
- [ ] Gitea Runner: „connected“ Status + BeispielBuildLog über vmbr1
+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).
Binary file not shown.
@@ -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",
+10
View File
@@ -60,6 +60,16 @@ sqldelight {
create("AppDatabase") {
packageName.set("at.mocode.frontend.core.localdb")
generateAsync.set(true)
// Workaround für SQLite-Temp-Verzeichnis Issue auf Windows
verifyMigrations.set(false)
}
}
}
// Workaround für SQLite-Temp-Verzeichnis Issue auf Windows. Das Plugin generiert dynamisch Tasks.
// lazy task configuration avoids cache issues and intercepts dynamic tasks
tasks.configureEach {
if (name.contains("verify", ignoreCase = true) && name.contains("Migration", ignoreCase = true)) {
enabled = false
}
}
@@ -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 = {})
@@ -6,8 +6,12 @@ import at.mocode.frontend.core.domain.repository.MasterdataRepository
import at.mocode.frontend.core.navigation.CurrentUserProvider
import at.mocode.frontend.core.navigation.DeepLinkHandler
import at.mocode.frontend.core.navigation.NavigationPort
import at.mocode.frontend.shell.desktop.data.repository.StoreVeranstaltungRepository
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 at.mocode.veranstaltung.feature.domain.repository.VeranstaltungRepository
import org.koin.core.module.dsl.viewModel
import org.koin.dsl.module
/**
@@ -35,4 +39,6 @@ val desktopModule = module {
single<CurrentUserProvider> { DesktopCurrentUserProvider(get()) }
single { DeepLinkHandler(get(), get()) }
single<MasterdataRepository> { DesktopMasterdataRepository(get()) }
single<VeranstaltungRepository> { StoreVeranstaltungRepository() }
viewModel { ChatViewModel(get()) }
}
@@ -1,14 +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.network.sync.SyncManager
import at.mocode.frontend.core.sync.di.syncModule
import at.mocode.frontend.features.billing.di.billingModule
import at.mocode.frontend.features.device.initialization.di.deviceInitializationModule
@@ -22,64 +20,52 @@ 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)
}
}

Some files were not shown because too many files have changed in this diff Show More