19 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
65 changed files with 1520 additions and 759 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 }}
+1 -1
View File
@@ -39,7 +39,7 @@ jobs:
chmod +x install-conveyor.sh
./install-conveyor.sh
fi
echo "$HOME/.conveyor/bin" >> $GITHUB_PATH
echo "$HOME/.conveyor/bin" >> $GITEA_PATH
- name: Windows .msi mit Conveyor bauen
run: |
+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" "$@"
+7 -1
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)
---
@@ -113,3 +113,9 @@ Beiträge sind willkommen. Bitte lies zunächst die Entwickler-Guides unter [`do
## 📜 Lizenz
Dieses Projekt steht unter der [MIT License](LICENSE).
---
## 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,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
@@ -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
+50 -29
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,14 +470,16 @@ 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 {
+18 -35
View File
@@ -1,63 +1,46 @@
# =============================================================================
# Conveyor Configuration for Meldestelle Desktop App
# =============================================================================
# Dieser Build-Weg ermöglicht das Cross-Packaging für Windows (MSI) auf Linux.
# Dokumentation: https://conveyor.hydraulic.dev/
# =============================================================================
include required("/stdlib/jdk/21/amazon.conf")
include required("https://raw.githubusercontent.com/hydraulic-software/conveyor/master/configs/jvm/extract-native-libraries.conf")
# Basis-Import der Gradle-Konfiguration (sofern das Plugin genutzt wird,
# aber wir definieren es hier explizit für maximale Kontrolle im CI/CD).
app {
# Anzeige-Name und Vendor
display-name = "Meldestelle"
rdns-name = "at.mocode.meldestelle"
vendor = "mo-code.at"
contact-email = "support@mo-code.at"
version = "1.0.1"
description = "ÖTO-konforme Turnier-Meldestelle Profi Desktop App"
# Version aus version.properties (Conveyor kann HOCON-Variablen nutzen)
# Für diesen Task hart codiert oder via CLI-Flag --variable übergeben.
version = "1.0.0"
# Ziel-Plattformen: Windows und Linux
machines = [ windows.amd64, linux.amd64.glibc ]
# Beschreibung
description = "ÖTO-konforme Turnier-Meldestelle Desktop App"
# Ziel-Plattformen
# Wir konzentrieren uns auf Windows, können aber Linux/Mac später ergänzen.
site.base-url = "localhost" # Später echte Update-URL
# Icons
site.base-url = "localhost"
# Icons werden im Ordner gesucht
icons = "frontend/shells/meldestelle-desktop/src/jvmMain/resources/icon.png"
# Einbetten der JRE (Temurin 21 wie in CI genutzt)
jvm {
gui {
main-class = "at.mocode.frontend.shell.desktop.MainKt"
}
# JVM-Argumente (analog build.gradle.kts)
jvm-options = [
"-Xms128m",
"-Xmx512m",
"-Xms256m",
"-Xmx1024m",
"-Dfile.encoding=UTF-8",
"--enable-native-access=ALL-UNNAMED"
]
}
# Input-Dateien: Hier ziehen wir die Uber-JAR oder die Gradle-Outputs.
# Da wir plattformunabhängig bleiben wollen, nutzen wir das Gradle-Output-Dir.
inputs += "frontend/shells/meldestelle-desktop/build/libs/meldestelle-desktop-jvm-*.jar"
# JARs aus dem Gradle-Build
inputs += "frontend/shells/meldestelle-desktop/build/libs/*.jar"
# Windows-spezifische Einstellungen
windows {
# Icon als .ico
icons = "frontend/shells/meldestelle-desktop/src/jvmMain/resources/icon.ico"
# GUID für Upgrades (muss stabil bleiben)
upgrade-uuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
# Menü-Eintrag
menu-group = "Meldestelle"
# Verknüpfung
desktop-shortcut = true
}
linux {
debian.control.depends = "libasound2, libgl1-mesa-glx, libx11-6"
}
}
conveyor.compatibility-level = 22
@@ -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
+1
View File
@@ -88,6 +88,7 @@ Fokus: Physische Implementierung der Turnier-Hierarchie und technisches Onboardi
* [x] **Client-Konfiguration:** Master kann nun Clients in der UI hinzufügen und bearbeiten.
* [x] **Master-UX:** Konfiguration beim Start nicht mehr zwangsgesperrt.
* [x] **Cross-Packaging (Conveyor):** Windows-Build auf Linux-CI ermöglicht (x64-Abhängigkeit identifiziert).
* [x] **Build-Performance:** WASM standardmäßig deaktiviert, um Desktop-Build-Zeiten zu reduzieren (11.05.2026).
* [ ] **PoC Verifikation:** 🔴 **BLOCKIERT** (Log 483: ARM64-Runner inkompatibel mit Conveyor-Binary; Workflow auf
manuell gesetzt).
@@ -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.
+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.
@@ -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.*
+14 -16
View File
@@ -2,27 +2,25 @@
**Status:** 🏗️ In Arbeit
**SCS:** Desktop App / Infrastructure
**Branch:** `feature/desktop-network-chat` (neuer Branch, erstellt ausgehend von `feature/turnier-anlage-wizard`)
**Branch:** `feature/desktop-network-chat`
## 🎯 Aktuelles Ziel
1. **Netzwerk-Kommunikation (Chat POC):** Implementierung einer simplen Chat-Funktion für die Desktop-App, die im lokalen Netzwerk funktioniert (Verbindungstest).
2. **Multi-Node Architektur:** Host-Client-Modell (1..n Hosts, 1..n Clients) vorbereiten. Hosts und Clients müssen in einem lokalen Netzwerk (LAN/WLAN) plattformunabhängig (Windows, Mac, Linux) stabil kommunizieren können.
3. **Conveyor Build (Pausiert):** Lauffähiger Build der Desktop-App via Conveyor für Windows (.msi/.exe) und Linux. Bereitstellung über Web-App. Wird nach dem Netzwerk-Proof-of-Concept in Angriff genommen.
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
- Fokus auf Netzwerk- & Offline-Fähigkeiten gelegt. Turnier-Anlage-Wizard pausiert.
- Neuer Branch `feature/desktop-network-chat` für die anstehenden Arbeiten.
- **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-Bereiche
- Lokale Netzwerk-Discovery (z.B. Ktor, UDP Broadcast, mDNS).
- P2P oder Client-Server Chat-Kommunikation im lokalen Netzwerk für den Verbindungs-Check.
- KMP Desktop-Modul.
## 🚧 Offene Punkte / Blocker
- [ ] Konzept für Host/Client-Discovery im lokalen Netz umsetzen.
- [ ] Implementierung eines lokalen Chat-Moduls in der Desktop-App (Linux/Desktop-Test).
- [ ] Erfolgreicher Conveyor Build für Windows & Linux (Später).
- [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
- [ ] Architektur-Entscheidung (ADR) für lokale Netzwerk-Discovery und Kommunikation treffen (Ktor Sockets, UDP, etc.).
- [ ] Erste Implementierung des Discovery-Mechanismus.
- [ ] 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
Binary file not shown.
+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()) }
@@ -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
@@ -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) }
}
@@ -22,8 +22,10 @@ class JmDnsDiscoveryService : NetworkDiscoveryService {
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
@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()
@@ -149,7 +151,10 @@ class JmDnsDiscoveryService : NetworkDiscoveryService {
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
if (name.startsWith("br-") || name.startsWith("docker") || name.startsWith("veth") || name.contains("vmnet") || name.contains(
"virbr"
)
) continue
val inetAddresses = iface.inetAddresses
while (inetAddresses.hasMoreElements()) {
@@ -172,7 +177,8 @@ class JmDnsDiscoveryService : NetworkDiscoveryService {
// 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 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 })
@@ -2,145 +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 {
companion object {
// Prozessweiter, portbasierter Guard gegen Mehrfachstart
private val startedPorts: MutableSet<Int> = ConcurrentHashMap.newKeySet()
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 var server: EmbeddedServer<*, *>? = null
private var currentPort: Int? = null
private val client = HttpClient {
install(io.ktor.client.plugins.websocket.WebSockets)
}
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) {
// Instanz-Guard (gleiche Instanz)
if (server != null) {
println("[P2P Server] Bereits gestartet (Instanz) auf Port ${currentPort ?: port} idempotent")
return
}
// Prozessweiter, portbasierter Guard
if (!startedPorts.add(port)) {
println("[P2P Server] Bereits gestartet (Prozess) auf Port $port idempotent, kein neuer Bind")
return
}
try {
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)
currentPort = port
println("[P2P Server] Gestartet auf Port $port")
} catch (e: Exception) {
// Start fehlgeschlagen -> Port-Lock wieder freigeben
startedPorts.remove(port)
server = null
currentPort = null
println("[P2P Server] Start auf Port $port fehlgeschlagen: ${e.message}")
throw e
}
if (!startedPorts.add(port)) {
println("[P2P Server] Port $port wird bereits von einer anderen Instanz genutzt.")
return
}
override fun stopServer() {
try {
server?.stop(1000, 2000)
} finally {
server = null
currentPort?.let { startedPorts.remove(it) }
currentPort = null
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 connectToPeer(host: String, port: Int) {
scope.launch {
routing {
webSocket("/sync") {
val remote = call.request.local.remoteAddress
println("[P2P Server] Neuer Peer verbunden: $remote")
activeSessions.add(this)
updatePeers()
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")
}
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 (e: Exception) {
println("[P2P Client] Fehler bei Verbindung zu $host:$port: ${e.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
}
}
override suspend fun broadcastEvent(event: SyncEvent) {
val text = Json.encodeToString(event)
activeSessions.toList().forEach { session ->
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 {
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()
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}")
}
}
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()}" }
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()) }
}
@@ -5,33 +5,33 @@ 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
@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()
}
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
@Test
fun stopping_server_should_release_port_lock() = runTest {
val service1 = JvmP2pSyncService()
val service2 = JvmP2pSyncService()
val port = 9092
service1.startServer(port)
service1.stopServer()
service1.startServer(port)
service1.stopServer()
// After stopping, starting again on same port (even from different instance) should work
service2.startServer(port)
service2.stopServer()
}
// After stopping, starting again on same port (even from different instance) should work
service2.startServer(port)
service2.stopServer()
}
}
@@ -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(
@@ -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,7 +232,6 @@ actual fun DeviceInitializationConfig(
}
}
// BACKUP & DRUCKER
MsFilePicker(
label = "Backup-Verzeichnis (Plan-USB)",
selectedPath = settings.backupPath,
@@ -246,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))
@@ -295,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)),
@@ -307,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,17 +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.NetworkConfig
import at.mocode.frontend.core.network.chat.KtorWebSocketServerService
import at.mocode.frontend.core.network.discovery.NetworkDiscoveryService
import at.mocode.frontend.core.network.networkModule
import at.mocode.frontend.core.network.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
@@ -25,86 +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")
// Base URL Log für schnelle Fehlerdiagnose
println("[Network] baseUrl=${NetworkConfig.baseUrl}")
// Starte Netzwerk-Dienste für den POC
val koin = GlobalContext.get()
try {
val wsServer = koin.get<KtorWebSocketServerService>()
wsServer.start()
val discovery = koin.get<NetworkDiscoveryService>()
discovery.startDiscovery()
// Im Host-Modus würden wir hier registerService aufrufen.
// Für den POC registrieren wir den lokalen Host-Dienst immer mit dem WS-Port
try {
discovery.registerService(wsServer.getPort())
println("[DesktopApp] Discovery-Registrierung durchgeführt (Port ${wsServer.getPort()})")
} catch (e: Exception) {
println("[DesktopApp] Discovery-Registrierung fehlgeschlagen: ${e.message}")
}
} catch(e: Exception) {
println("[DesktopApp] POC-Dienste konnten nicht gestartet werden: ${e.message}")
val koin = koinApp.koin
// 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()
}
// 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}")
}
Window(
onCloseRequest = ::exitApplication,
title = "Meldestelle",
state = WindowState(width = 1600.dp, height = 900.dp),
) {
DesktopApp()
}
}
@@ -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)
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 13 KiB

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