22 Commits

Author SHA1 Message Date
schalli 1c4247a7c9 ci(18-05): Windows-Cross-Bau (cargo-xwin/NSIS) in den Job desktop einbauen
Tessera CI/CD / Lint & Type Check (push) Successful in 49s
Tessera CI/CD / Tests (push) Successful in 58s
Tessera CI/CD / Desktop-Pakete bauen (push) Failing after 5m59s
Tessera CI/CD / Build & Publish Images (push) Has been skipped
Baut nach dem AppImage zusaetzlich den Windows-Installer per Cross-Bau auf
dem Linux-Runner (cargo-xwin, NSIS aus dem Ubuntu-Paket), cached SDK und
NSIS-Plugins, und sammelt beide Dateien mit --require linux,windows ein.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 16:46:59 +02:00
schalli 233de7eae0 docs(18-04): STATE/ROADMAP nach Plan 04 aktualisieren
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 16:43:52 +02:00
schalli 289604a28e docs(18-04): Client-Fertigstellung — Update-Hinweis, Tray-Autostart, Erststart-Seite, echtes Icon
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 16:43:15 +02:00
schalli 2d55f07729 feat(18-04): Task 2 — Erststart-Seite in Tessera-Gestalt, echtes App-Icon, lokaler AppImage-Beweis
- setup.html spricht nur noch über window.__TAURI__.core.invoke (kein
  Modul-Import mehr, der im gebauten Client ohnehin scheiterte), prüft die
  Adresse über check_server und speichert sie über save_server_url
- Sie-Form-Texte, Tessera-Zeichen als inline-SVG, Markengelb/-Olive,
  keine vorbelegte Server-Adresse
- CSP ohne Fremdhost (unpkg.com entfernt), da nichts mehr von außen geladen wird
- Icon-Satz aus apps/web/src/app/icon.svg neu erzeugt (512, 128, 128@2x, 32,
  ICO mit 16-256) statt der bisherigen flachen gelben Quadrate
- Versehentlich verschachteltes apps/desktop/src-tauri/apps/ entfernt
- Lokaler AppImage-Bau (Tessera-1.1.0.AppImage, ~103 MB) erfolgreich, von
  desktop-collect.sh eingesammelt

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 16:41:21 +02:00
schalli 8b130fddbd feat(18-04): Task 1 — Kommandos, Versionsprüfung und Tray für Update/Autostart
- tauri-plugin-opener@2 hinzugefügt (Legitimität in RESEARCH geprüft)
- Neue Rust-Kommandos check_server/save_server_url für die Erststart-Seite,
  Adressen laufen über api_url() ausschließlich über /api-proxy/*
- Versionsprüfung gegen /api-proxy/desktop/latest statt /health/version,
  schaltet den Tray-Eintrag "Update herunterladen" frei und benennt ihn um
- Tray-Menü: Öffnen · Update herunterladen · Autostart-Haken · Beenden,
  echte Umlaute, Update-Eintrag öffnet die Download-Seite im Systembrowser
- capabilities/default.json: opener:allow-open-url für http/https
- cargo check und cargo clippy grün

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 16:36:53 +02:00
schalli 82312ef691 docs(18-03): complete Web-Oberflaeche fuer Desktop-App plan 2026-09-16 16:32:35 +02:00
schalli e96d460b8e feat(18-03): Task 2 — Einstellungsseite Desktop-App mit Knoepfen, Groesse und Erklaerung
- Neue Route /settings/general/desktop (DesktopSettingsPage)
- DesktopAppSettings: Version, zwei Primaerknoepfe mit Plattform-Symbol,
  Dateiname/Groesse je Knopf, Beta-Kanal-Hinweis, vier erklaerende Saetze,
  Hinweistext wenn keine Pakete hinterlegt sind
- Seitenleiste: Eintrag "Desktop-App" unter "Allgemein"
- de/en: settings.categoryDesktopApp, settings.desktop.*
- umlaut-dictionary.ts: "neuere" zur Allowlist ergaenzt (Rule 1 —
  Waechter-Test brach an einer bereits korrekten Umlautschreibung, die
  zufaellig die Buchstabenfolge "ue" enthaelt)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 16:30:34 +02:00
schalli 026d9c39af feat(18-03): Task 1 — Fetch-Helfer und Download-Link auf der Anmeldeseite
- apps/web/src/lib/desktop.ts: loadDesktopLatest (memoisiert, still bei
  Fehler), desktopDownloadUrl, formatFileSize (lokalisierte MB-Werte)
- DesktopDownloadLinks: unauffaelliger Link-Block, rendert nichts ohne
  Daten; Windows fuehrt, Linux als Kurzlink wenn beide Pakete vorliegen
- Anmeldeseite bindet den Block nach dem Formular ein (D-12)
- de/en: auth.desktopDownload.* ergaenzt

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 16:27:44 +02:00
schalli 2164cd537a docs(18-02): complete CI-Pipeline fuer Desktop-Client plan
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 16:23:58 +02:00
schalli ab75911b72 docs(18-02): Plan 02 abgeschlossen — Job desktop, Cache-Uebergabe, Release-Anhaenge
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 16:22:49 +02:00
schalli 75a8e40587 feat(18-02): Task 2 — Release-Dateien idempotent an den Gitea-Release haengen
- upload_asset() nach dem bestehenden GET-then-PATCH/POST-Idempotenzmuster:
  GET .../assets -> vorhandene Datei gleichen Namens per DELETE entfernen ->
  frisch per POST multipart (Feld attachment) hochladen
- Zweite Header-Datei HDR_AUTH (nur Authorization, kein JSON-Content-Type) fuer
  den multipart-Upload; Token bleibt in Header-Dateien, nie als Argument (T-18-03)
- Nach Release-Anlage/-Aktualisierung wird jede Datei aus desktop-dist/manifest.json
  hochgeladen; fehlt das Manifest bei einem Freigabe-Tag, bricht das Skript hart ab
- --dry-run listet zusaetzlich die geplanten Uploads aus dem Manifest, ohne Netzaufruf

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 16:21:17 +02:00
schalli a6ffe05150 feat(18-02): Task 1 — Job desktop (Linux-AppImage) und Uebergabe an publish per actions/cache
- Neuer CI-Job desktop nach test, laeuft auf main und bei Tags v* (Systemabhaengigkeiten,
  Rust-Toolchain per rustup, Cargo-Zwischenspeicher, Version setzen, cargo check/clippy,
  alte Bundles entfernen, Linux-AppImage bauen, desktop-collect.sh --require linux,
  Uebergabe per actions/cache/save mit Schluessel desktop-dist-${{ gitea.sha }})
- publish haengt jetzt an desktop (needs: desktop) statt an test, holt die Pakete per
  actions/cache/restore mit fail-on-cache-miss: true und prueft das Manifest hart
- publish-images.sh bricht im echten Baupfad ohne desktop-dist/manifest.json ab
  (zweites Netz gegen einen Cache-Fehlschlag, D-08/Pitfall 1)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 16:21:09 +02:00
schalli cd62de1d38 docs(18-01): Aktenstand + Fahrplan nach Plan 01 aktualisieren
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 16:14:06 +02:00
schalli c721464af2 docs(18-01): Plan 01 abgeschlossen — Durchstich Skript -> Abbild -> API bewiesen
SUMMARY: 8 gruene Spec-Tests, Typpruefung fehlerfrei, lokaler Stack
liefert /desktop/latest und /desktop/download/linux auf Basislinie
1.1.0 aus. Ein Deviation-Eintrag (Rule 3): @Inject(DesktopService)
explizit noetig, da Vitest ueber esbuild ohne emitDecoratorMetadata
transpiliert.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 16:12:53 +02:00
schalli 614289a350 feat(18-01): Task 2 — Version aus dem Freigabe-Tag, Basislinie 1.1.0
desktop-version.sh (D-07): schreibt die reine X.Y.Z-Form des letzten
Freigabe-Tags in tauri.conf.json und Cargo.toml; --print nur zur
Anzeige, keine Vorab-/Metadatenform wird je geschrieben (Pitfall 2 —
NSIS-Ressourcen sind rein numerisch). Negativproben (v1.2.3-beta
abgelehnt, v2.0.0 akzeptiert aber ungeschrieben) bestehen.

Basislinie 1.1.0 (aktueller Tag v1.1.0) in tauri.conf.json, Cargo.toml,
Cargo.lock (cargo check) und package.json eingecheckt — die Wahrheit im
CI bleibt der Tag, dies ist nur die Vorgabe fuer lokale Baue.

Frischer AppImage-Bau mit der Basislinie bestaetigt: desktop-collect.sh
liefert jetzt Tessera-1.1.0.AppImage samt Manifest.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 16:10:07 +02:00
schalli ae8fecb538 feat(18-01): Task 1 — Linux-Paket bis zum Download aus der API (Durchstich)
Die duenne Strecke Skript -> Abbild -> API beweisen (D-08, D-10):

- desktop-collect.sh sammelt AppImage/exe ein, schreibt manifest.json
  (Version, Kanal, Commit, Groesse, SHA-256) ohne Secrets
- apps/api/src/desktop/: neues Modul mit GET /desktop/latest und
  GET /desktop/download/:platform, beide @Public(); Plattform-Whitelist
  vor jedem Dateisystemzugriff, Dateiname ausschliesslich aus dem
  Manifest (T-18-01, T-18-02)
- packages/shared: DesktopPlatform/-Manifest(File)/-Latest(Response)
  Typen
- Dockerfile kopiert desktop-dist/ in die runner-Stufe; desktop-dist/
  per .gitkeep + .gitignore versioniert (leeres Verzeichnis, Pakete
  bleiben ungetrackt)
- HTTP-Durchstich-Spec (8 Tests) via NestFactory, kein fs-Mock; echtes
  Temp-Verzeichnis + unabhaengig berechneter SHA-256

Abweichung: DesktopController braucht @Inject(DesktopService) explizit
— Vitest transpiliert ueber esbuild, das emitDecoratorMetadata nicht
abbildet, sonst bleibt desktopService bei einem echten NestFactory-Bau
undefined (Rule 3, Blocker).

Lokal bewiesen: neu gebautes API-Abbild liefert /desktop/latest (200)
und /desktop/download/linux (200, attachment) aus, auch ueber
/api-proxy/ des Web-Containers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 16:05:55 +02:00
schalli 0e4eb9bf9e docs(18): Planung abgeschlossen — Pruefer gruen (0 Blocker), Wellen im Fahrplan annotiert, offene Forschungsfragen in Plaenen aufgeloest, Aktenstand 'Ready to execute'
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 15:57:00 +02:00
schalli 2eb3be8b74 docs(18): Phase 18 geplant — sechs Plaene (Tracer Linux-Strecke, Pipeline, Web, Client, Windows-Cross-Bau mit Iterationsschleife, Abschluss), Validierungsplan, API-Coverage, Fahrplan 2026-09-16 15:53:45 +02:00
schalli e307a8e689 docs(phase-18): Recherche (Cross-Bau, Runner-Cache, Versionsregel, API/Web-Vorbilder) und Pruefstrategie
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 15:02:34 +02:00
schalli 01ec4d3d4e docs(phase-18): Forschung — Cross-Bau Windows-NSIS auf Linux, act_runner-Cache statt Artifact-Actions, API-Downloadmodul
Cargo-xwin/NSIS-Toolchain gegen Cargo.lock und offizielle Tauri-Doku geprueft, act_runner-Cache-Server live bestaetigt (Docker-Inspektion), Paket-Legitimitaet fuer tauri-plugin-opener/cargo-xwin gecheckt, bestehende Codebase-Muster (DkvService-Pfadschutz, app-version.ts, @Public()) als Vorlage fuer das neue Desktop-Modul dokumentiert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 15:02:02 +02:00
schalli b1d7822f7e docs(phase-18): Kontext — Entscheidungen fuer den Desktop-Client (Download in Tessera + Gitea-Release, Cross-Bau auf Linux, Pakete im API-Abbild, Update-Hinweis)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 14:49:03 +02:00
schalli 2d3c09f302 docs: Phase 18 Desktop-Client fertigstellen im Fahrplan angelegt (Ziel, Erfolgskriterien, Aktenstand)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 14:47:26 +02:00
65 changed files with 7005 additions and 61 deletions
+156
View File
@@ -0,0 +1,156 @@
#!/bin/sh
# desktop-collect.sh -- Desktop-Pakete aus dem Tauri-Bau einsammeln, unter
# kanonischem Namen ablegen und manifest.json schreiben (Phase 18, D-08).
#
# Kanalmodell (identisch zu publish-images.sh, an GITHUB_REF entschieden,
# damit lokale Proben ohne Runner pruefbar sind):
# refs/tags/v* -> Kanal live, kein Namenssuffix
# refs/heads/main -> Kanal beta, Suffix -beta.{7-stelliger SHA} am Dateinamen
# alles andere -> Kanal dev, kein Suffix (lokale Proben tragen den
# Freigabe-Namen, damit desktop-version.sh/desktop-collect.sh
# ohne Pipeline durchgespielt werden koennen)
#
# Aufruf: sh .gitea/scripts/desktop-collect.sh --require linux[,windows]
#
# Umgebung:
# GITHUB_REF Kanalentscheidung (siehe oben)
# DESKTOP_DIST Zielordner fuer die Pakete (Vorgabe: desktop-dist)
# TAURI_DIR Tauri-Projektordner (Vorgabe: apps/desktop/src-tauri)
#
# Die Version kommt aus tauri.conf.json (von desktop-version.sh geschrieben
# oder als eingecheckte Basislinie vorhanden) -- dieses Skript liest sie nur,
# es schreibt sie nicht. Dieses Skript kennt kein Secret.
set -eu
REQUIRE=""
while [ $# -gt 0 ]; do
case "$1" in
--require)
shift
REQUIRE="${1:-}"
;;
*)
echo "Unbekanntes Argument: $1" >&2
exit 1
;;
esac
shift
done
if [ -z "$REQUIRE" ]; then
echo "Aufruf: $0 --require linux[,windows]" >&2
exit 1
fi
DESKTOP_DIST="${DESKTOP_DIST:-desktop-dist}"
TAURI_DIR="${TAURI_DIR:-apps/desktop/src-tauri}"
REF="${GITHUB_REF:-}"
case "$REF" in
refs/tags/v*)
CHANNEL=live
SUFFIX=""
;;
refs/heads/main)
CHANNEL=beta
SHA_SHORT="$(git rev-parse --short=7 HEAD)"
SUFFIX="-beta.$SHA_SHORT"
;;
*)
CHANNEL=dev
SUFFIX=""
;;
esac
VERSION="$(jq -r .version "$TAURI_DIR/tauri.conf.json")"
case "$VERSION" in
[0-9]*.[0-9]*.[0-9]*)
if ! printf '%s' "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "Version '$VERSION' aus $TAURI_DIR/tauri.conf.json ist nicht rein numerisch (X.Y.Z)." >&2
exit 1
fi
;;
*)
echo "Version '$VERSION' aus $TAURI_DIR/tauri.conf.json ist nicht rein numerisch (X.Y.Z)." >&2
exit 1
;;
esac
COMMIT="$(git rev-parse --short=7 HEAD)"
BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
mkdir -p "$DESKTOP_DIST"
# Alte Pakete/Manifest entfernen, Platzhalter (.gitkeep) bleibt erhalten.
rm -f "$DESKTOP_DIST"/*.AppImage "$DESKTOP_DIST"/*.exe "$DESKTOP_DIST/manifest.json"
MANIFEST_ARGS=""
TMP_MANIFEST="$(mktemp)"
trap 'rm -f "$TMP_MANIFEST"' EXIT INT TERM
LINUX_NAME=""
LINUX_SIZE=""
LINUX_SHA=""
WINDOWS_NAME=""
WINDOWS_SIZE=""
WINDOWS_SHA=""
case ",$REQUIRE," in
*,linux,*)
APPIMAGE_DIR="$TAURI_DIR/target/release/bundle/appimage"
APPIMAGE_COUNT="$(find "$APPIMAGE_DIR" -maxdepth 1 -name '*.AppImage' 2>/dev/null | wc -l | tr -d ' ')"
if [ "$APPIMAGE_COUNT" -ne 1 ]; then
echo "Erwartet genau eine .AppImage-Datei in $APPIMAGE_DIR, gefunden: $APPIMAGE_COUNT" >&2
exit 1
fi
APPIMAGE_SRC="$(find "$APPIMAGE_DIR" -maxdepth 1 -name '*.AppImage')"
LINUX_NAME="Tessera-${VERSION}${SUFFIX}.AppImage"
cp "$APPIMAGE_SRC" "$DESKTOP_DIST/$LINUX_NAME"
LINUX_SIZE="$(stat -c %s "$DESKTOP_DIST/$LINUX_NAME")"
LINUX_SHA="$(sha256sum "$DESKTOP_DIST/$LINUX_NAME" | cut -d' ' -f1)"
echo "linux: $LINUX_NAME (${LINUX_SIZE} Bytes, sha256 $LINUX_SHA)"
;;
esac
case ",$REQUIRE," in
*,windows,*)
NSIS_DIR="$TAURI_DIR/target/x86_64-pc-windows-msvc/release/bundle/nsis"
NSIS_COUNT="$(find "$NSIS_DIR" -maxdepth 1 -name '*.exe' 2>/dev/null | wc -l | tr -d ' ')"
if [ "$NSIS_COUNT" -ne 1 ]; then
echo "Erwartet genau eine .exe-Datei in $NSIS_DIR, gefunden: $NSIS_COUNT" >&2
exit 1
fi
NSIS_SRC="$(find "$NSIS_DIR" -maxdepth 1 -name '*.exe')"
WINDOWS_NAME="Tessera-Setup-${VERSION}${SUFFIX}.exe"
cp "$NSIS_SRC" "$DESKTOP_DIST/$WINDOWS_NAME"
WINDOWS_SIZE="$(stat -c %s "$DESKTOP_DIST/$WINDOWS_NAME")"
WINDOWS_SHA="$(sha256sum "$DESKTOP_DIST/$WINDOWS_NAME" | cut -d' ' -f1)"
echo "windows: $WINDOWS_NAME (${WINDOWS_SIZE} Bytes, sha256 $WINDOWS_SHA)"
;;
esac
# manifest.json ausschliesslich ueber jq -n mit --arg/--argjson bauen (kein
# manuelles String-Zusammenbauen von JSON).
jq -n \
--arg version "$VERSION" \
--arg channel "$CHANNEL" \
--arg commit "$COMMIT" \
--arg buildTime "$BUILD_TIME" \
--arg linuxName "$LINUX_NAME" \
--argjson linuxSize "${LINUX_SIZE:-null}" \
--arg linuxSha "$LINUX_SHA" \
--arg windowsName "$WINDOWS_NAME" \
--argjson windowsSize "${WINDOWS_SIZE:-null}" \
--arg windowsSha "$WINDOWS_SHA" \
'{
version: $version,
channel: $channel,
commit: $commit,
buildTime: $buildTime,
files: (
{}
+ (if $linuxName != "" then { linux: { name: $linuxName, size: $linuxSize, sha256: $linuxSha } } else {} end)
+ (if $windowsName != "" then { windows: { name: $windowsName, size: $windowsSize, sha256: $windowsSha } } else {} end)
)
}' > "$DESKTOP_DIST/manifest.json"
echo "Manifest geschrieben: $DESKTOP_DIST/manifest.json (Version $VERSION, Kanal $CHANNEL)"
+53
View File
@@ -0,0 +1,53 @@
#!/bin/sh
# desktop-version.sh -- Version aus dem letzten Freigabe-Tag in
# tauri.conf.json und Cargo.toml schreiben (Phase 18, D-07).
#
# Die Wahrheit der Client-Version ist der Freigabe-Tag (git describe), nicht
# eine im Repository eingecheckte Zahl -- dieses Skript liest den Tag und
# schreibt ihn vor jedem Bau in beide Dateien. Geschrieben wird IMMER die
# reine Form X.Y.Z, nie eine Vorab- oder Metadaten-Form (Pitfall 2: NSIS'
# VIProductVersion/VIFileVersion sind rein numerisch, das ist eine
# Windows-Ressourcen-Vorgabe, keine Tauri-Entscheidung). Die
# Beta-vs-Freigabe-Unterscheidung lebt ausschliesslich im Dateinamen-Suffix
# und in manifest.json (desktop-collect.sh), nicht hier.
#
# DESKTOP_TAG dient nur der lokalen Probe (siehe unten); im CI ist
# `fetch-depth: 0` Pflicht, sonst findet `git describe` keinen Tag.
#
# Option --print: nur die ermittelte Version ausgeben, nichts schreiben.
#
# Dieses Skript kennt kein Secret.
set -eu
CONF="apps/desktop/src-tauri/tauri.conf.json"
CARGO="apps/desktop/src-tauri/Cargo.toml"
PRINT_ONLY=0
if [ "${1:-}" = "--print" ]; then
PRINT_ONLY=1
fi
if [ -n "${DESKTOP_TAG:-}" ]; then
TAG="$DESKTOP_TAG"
else
if ! TAG="$(git describe --tags --abbrev=0 --match 'v[0-9]*' 2>/dev/null)"; then
echo "Kein erreichbarer Freigabe-Tag (v*) -- im CI ist fetch-depth: 0 Pflicht." >&2
exit 1
fi
fi
VERSION="${TAG#v}"
if ! printf '%s' "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "Tag '$TAG' ergibt keine reine X.Y.Z-Version ('$VERSION') -- Vorab-/Metadatenformen werden nie geschrieben (Pitfall 2)." >&2
exit 1
fi
if [ "$PRINT_ONLY" = "1" ]; then
printf '%s\n' "$VERSION"
exit 0
fi
jq --arg v "$VERSION" '.version = $v' "$CONF" > "$CONF.tmp" && mv "$CONF.tmp" "$CONF"
sed -i "s/^version = \".*\"/version = \"$VERSION\"/" "$CARGO"
echo "Desktop-Version gesetzt: $VERSION (aus Tag $TAG)"
+11
View File
@@ -19,6 +19,12 @@
# die volle Historie samt Tags (fetch-depth: 0 im Workflow). # die volle Historie samt Tags (fetch-depth: 0 im Workflow).
# #
# Dieses Skript kennt kein Secret und gibt keines aus; der Registry-Login bleibt im Workflow. # Dieses Skript kennt kein Secret und gibt keines aus; der Registry-Login bleibt im Workflow.
#
# Phase 18 (18-02): Die Desktop-Pakete kommen aus dem vorgeschalteten Job `desktop`
# und werden per actions/cache als desktop-dist/ uebergeben; das Dockerfile der API
# kopiert desktop-dist/ ins Abbild. Ohne desktop-dist/manifest.json bricht dieses
# Skript im echten Baupfad hart ab -- zweites Netz gegen Pitfall 1 (Cache-Fehlschlag),
# der Workflow selbst prueft es bereits vor diesem Schritt.
set -eu set -eu
REGISTRY="${REGISTRY:-localhost:3002/schalli/tessera-ctl}" REGISTRY="${REGISTRY:-localhost:3002/schalli/tessera-ctl}"
@@ -54,6 +60,11 @@ if [ "${1:-}" = "--print-plan" ]; then
exit 0 exit 0
fi fi
if [ ! -f desktop-dist/manifest.json ]; then
echo "desktop-dist/manifest.json fehlt -- kein Abbild ohne Desktop-Pakete (Pitfall 1)." >&2
exit 1
fi
for IMG in web api; do for IMG in web api; do
docker build -t "$REGISTRY/$IMG:$APP_CHANNEL" \ docker build -t "$REGISTRY/$IMG:$APP_CHANNEL" \
--build-arg APP_VERSION="$APP_VERSION" \ --build-arg APP_VERSION="$APP_VERSION" \
+74 -1
View File
@@ -21,9 +21,21 @@
# sonst http://localhost:3002/api/v1 (nur lokal erreichbar). # sonst http://localhost:3002/api/v1 (nur lokal erreichbar).
# GITEA_REPO owner/repo; sonst GITHUB_REPOSITORY, sonst schalli/tessera-ctl. # GITEA_REPO owner/repo; sonst GITHUB_REPOSITORY, sonst schalli/tessera-ctl.
# CHANGELOG_FILE Pfad zur Aenderungsliste; Vorgabe CHANGELOG.md. # CHANGELOG_FILE Pfad zur Aenderungsliste; Vorgabe CHANGELOG.md.
# DESKTOP_DIST Ordner mit den Desktop-Paketen und manifest.json (Phase 18,
# 18-02); Vorgabe desktop-dist. Fehlt manifest.json bei Tags,
# bricht das Skript ab -- der Release-Text ist dann schon
# angelegt/aktualisiert, der Job wird sichtbar rot.
# #
# Fehlt der Abschnitt fuer die Version, endet das Skript mit Exit 1 -- es entsteht # Fehlt der Abschnitt fuer die Version, endet das Skript mit Exit 1 -- es entsteht
# nie ein leerer Release. JSON wird ausschliesslich mit jq gebaut. # nie ein leerer Release. JSON wird ausschliesslich mit jq gebaut.
#
# Release-Dateien (Phase 18, 18-02): jede Datei aus manifest.json wird idempotent
# angehaengt -- GET .../releases/{id}/assets, vorhandene Datei gleichen Namens per
# DELETE .../releases/{id}/assets/{asset_id} entfernen, dann frisch per
# POST .../releases/{id}/assets?name=... (multipart-Feld attachment) hochladen.
# Der multipart-Upload braucht eine zweite Header-Datei ($HDR_AUTH) OHNE
# Content-Type: application/json -- curl setzt den multipart-Content-Type sonst
# nicht korrekt, wenn der JSON-Header schon gesetzt ist.
set -eu set -eu
usage() { usage() {
@@ -101,11 +113,21 @@ UPDATE_JSON=$(jq -n --arg name "$NAME" --arg body "$BODY" '{name: $name, body: $
RELEASES_URL="$API/repos/$REPO/releases" RELEASES_URL="$API/repos/$REPO/releases"
TAG_URL="$API/repos/$REPO/releases/tags/$TAG" TAG_URL="$API/repos/$REPO/releases/tags/$TAG"
DESKTOP_DIST="${DESKTOP_DIST:-desktop-dist}"
MANIFEST="$DESKTOP_DIST/manifest.json"
if [ "$DRY_RUN" -eq 1 ]; then if [ "$DRY_RUN" -eq 1 ]; then
echo "Probelauf (kein Netzaufruf):" echo "Probelauf (kein Netzaufruf):"
echo " POST $RELEASES_URL" echo " POST $RELEASES_URL"
echo " PATCH $RELEASES_URL/<id> (falls GET $TAG_URL bereits 200 liefert)" echo " PATCH $RELEASES_URL/<id> (falls GET $TAG_URL bereits 200 liefert)"
printf '%s\n' "$CREATE_JSON" printf '%s\n' "$CREATE_JSON"
if [ -f "$MANIFEST" ]; then
for FNAME in $(jq -r '.files[].name' "$MANIFEST"); do
echo " POST $RELEASES_URL/<id>/assets?name=$FNAME"
done
else
echo " ($MANIFEST fehlt -- keine geplanten Uploads im Probelauf)"
fi
exit 0 exit 0
fi fi
@@ -118,9 +140,46 @@ umask 077
TMPDIR_REL=$(mktemp -d) TMPDIR_REL=$(mktemp -d)
trap 'rm -rf "$TMPDIR_REL"' EXIT INT TERM trap 'rm -rf "$TMPDIR_REL"' EXIT INT TERM
HDR="$TMPDIR_REL/headers" HDR="$TMPDIR_REL/headers"
HDR_AUTH="$TMPDIR_REL/headers-auth"
RESP="$TMPDIR_REL/response.json" RESP="$TMPDIR_REL/response.json"
ASSETS_RESP="$TMPDIR_REL/assets.json"
JSONFILE="$TMPDIR_REL/payload.json" JSONFILE="$TMPDIR_REL/payload.json"
printf 'Authorization: token %s\nContent-Type: application/json\n' "$GITEA_TOKEN" > "$HDR" printf 'Authorization: token %s\nContent-Type: application/json\n' "$GITEA_TOKEN" > "$HDR"
# Zweite Header-Datei ohne Content-Type: application/json -- der multipart-Upload
# (POST .../assets) darf keinen JSON-Content-Type mitbekommen.
printf 'Authorization: token %s\n' "$GITEA_TOKEN" > "$HDR_AUTH"
# upload_asset FILE NAME RELEASE_ID -- idempotent: vorhandene Datei gleichen Namens
# wird zuerst entfernt (GET -> DELETE), dann frisch hochgeladen (POST multipart).
upload_asset() {
ASSET_FILE="$1"
ASSET_NAME="$2"
ASSET_RELEASE_ID="$3"
ASSETS_CODE=$(curl -sS --header @"$HDR" -o "$ASSETS_RESP" -w '%{http_code}' "$RELEASES_URL/$ASSET_RELEASE_ID/assets")
if [ "$ASSETS_CODE" != "200" ]; then
echo "GET $RELEASES_URL/$ASSET_RELEASE_ID/assets antwortete mit $ASSETS_CODE:" >&2
cat "$ASSETS_RESP" >&2
exit 1
fi
EXISTING_ID=$(jq -r --arg n "$ASSET_NAME" '.[] | select(.name == $n) | .id' "$ASSETS_RESP")
if [ -n "$EXISTING_ID" ]; then
DEL_CODE=$(curl -sS --header @"$HDR" -X DELETE -o "$RESP" -w '%{http_code}' "$RELEASES_URL/$ASSET_RELEASE_ID/assets/$EXISTING_ID")
if [ "$DEL_CODE" != "204" ]; then
echo "DELETE $RELEASES_URL/$ASSET_RELEASE_ID/assets/$EXISTING_ID antwortete mit $DEL_CODE:" >&2
cat "$RESP" >&2
exit 1
fi
fi
UPLOAD_CODE=$(curl -sS --header @"$HDR_AUTH" -X POST -F "attachment=@${ASSET_FILE};filename=${ASSET_NAME}" -o "$RESP" -w '%{http_code}' "$RELEASES_URL/$ASSET_RELEASE_ID/assets?name=$ASSET_NAME")
if [ "$UPLOAD_CODE" != "201" ]; then
echo "POST $RELEASES_URL/$ASSET_RELEASE_ID/assets?name=$ASSET_NAME antwortete mit $UPLOAD_CODE:" >&2
cat "$RESP" >&2
exit 1
fi
}
CODE=$(curl -sS --header @"$HDR" -o "$RESP" -w '%{http_code}' "$TAG_URL") CODE=$(curl -sS --header @"$HDR" -o "$RESP" -w '%{http_code}' "$TAG_URL")
case "$CODE" in case "$CODE" in
@@ -140,7 +199,8 @@ case "$CODE" in
printf '%s' "$CREATE_JSON" > "$JSONFILE" printf '%s' "$CREATE_JSON" > "$JSONFILE"
CODE=$(curl -sS --header @"$HDR" -X POST --data @"$JSONFILE" -o "$RESP" -w '%{http_code}' "$RELEASES_URL") CODE=$(curl -sS --header @"$HDR" -X POST --data @"$JSONFILE" -o "$RESP" -w '%{http_code}' "$RELEASES_URL")
if [ "$CODE" = "201" ]; then if [ "$CODE" = "201" ]; then
echo "Release $TAG angelegt (id $(jq -r .id "$RESP"))" ID=$(jq -r .id "$RESP")
echo "Release $TAG angelegt (id $ID)"
else else
echo "POST $RELEASES_URL antwortete mit $CODE:" >&2 echo "POST $RELEASES_URL antwortete mit $CODE:" >&2
cat "$RESP" >&2 cat "$RESP" >&2
@@ -153,3 +213,16 @@ case "$CODE" in
exit 1 exit 1
;; ;;
esac esac
# Release-Dateien aus dem Manifest anhaengen (Phase 18, 18-02). Der Release-Text
# ist an dieser Stelle bereits angelegt/aktualisiert -- fehlt das Manifest, wird
# das trotzdem hart abgebrochen (kein Release ohne Pakete bei einem Freigabe-Tag).
if [ ! -f "$MANIFEST" ]; then
echo "$MANIFEST fehlt -- kein Release ohne Desktop-Pakete." >&2
exit 1
fi
for FNAME in $(jq -r '.files[].name' "$MANIFEST"); do
upload_asset "$DESKTOP_DIST/$FNAME" "$FNAME" "$ID"
echo "Release-Datei $FNAME hochgeladen"
done
+103 -1
View File
@@ -2,6 +2,13 @@
# Tag v* -> Kanal live (Etiketten live + vX.Y.Z); Zweig live ohne Tag wird nur geprueft. # Tag v* -> Kanal live (Etiketten live + vX.Y.Z); Zweig live ohne Tag wird nur geprueft.
# Die Entscheidung trifft .gitea/scripts/publish-images.sh anhand GITHUB_REF. # Die Entscheidung trifft .gitea/scripts/publish-images.sh anhand GITHUB_REF.
# Tag v* (quick-260916-dcz): zusaetzlich Gitea-Release aus dem CHANGELOG.md-Abschnitt (publish-release.sh). # Tag v* (quick-260916-dcz): zusaetzlich Gitea-Release aus dem CHANGELOG.md-Abschnitt (publish-release.sh).
# Phase 18 (18-02): Job `desktop` baut vor `publish` das Linux-AppImage und
# uebergibt es per actions/cache; `publish` bricht ohne Manifest ab.
# Phase 18 (18-05): Derselbe Job baut zusaetzlich den Windows-Installer per
# Cross-Bau (cargo-xwin, NSIS aus dem Ubuntu-Paket) -- kein Windows-Rechner
# in der Pipeline. `tauri.conf.json`/`Cargo.toml` bleiben dabei immer rein
# numerisch (X.Y.Z), weil NSIS' Windows-Ressourcenfelder das verlangen; die
# Beta-Kennzeichnung lebt ausschliesslich im Dateinamen-Suffix.
name: Tessera CI/CD name: Tessera CI/CD
on: on:
@@ -52,16 +59,111 @@ jobs:
- name: Run tests - name: Run tests
run: pnpm test run: pnpm test
desktop:
name: Desktop-Pakete bauen
runs-on: ubuntu-latest
needs: test
if: gitea.ref == 'refs/heads/main' || startsWith(gitea.ref, 'refs/tags/v')
steps:
# Ohne volle Historie und Tags liefert `git describe` nichts -- Pflicht fuer die Version.
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 24
- name: Enable pnpm via corepack
run: corepack enable && corepack prepare pnpm@9.15.0 --activate
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Systemabhaengigkeiten
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libwebkit2gtk-4.1-dev libjavascriptcoregtk-4.1-dev \
libayatana-appindicator3-dev librsvg2-dev \
libgtk-3-dev libssl-dev patchelf file xdg-utils \
lld llvm clang nsis
- name: Rust-Toolchain
run: |
curl -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
- name: Cargo-Zwischenspeicher
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
~/.cargo/bin/cargo-xwin
~/.cache/tauri
~/.cache/cargo-xwin
~/.local/share/tauri
apps/desktop/src-tauri/target
key: desktop-cargo-${{ hashFiles('apps/desktop/src-tauri/Cargo.lock') }}
restore-keys: desktop-cargo-
- name: Windows-Werkzeuge
run: |
rustup target add x86_64-pc-windows-msvc
command -v cargo-xwin >/dev/null 2>&1 || cargo install --locked cargo-xwin
- name: Version setzen
run: sh .gitea/scripts/desktop-version.sh
- name: Rust pruefen
working-directory: apps/desktop/src-tauri
run: |
cargo check
cargo clippy
- name: Alte Bundles entfernen
run: |
rm -rf apps/desktop/src-tauri/target/release/bundle
rm -rf apps/desktop/src-tauri/target/x86_64-pc-windows-msvc/release/bundle
- name: Linux-AppImage bauen
run: pnpm --filter @tessera/desktop exec tauri build --bundles appimage
- name: Windows-Installer bauen (Cross-Bau)
run: pnpm --filter @tessera/desktop exec tauri build --runner cargo-xwin --target x86_64-pc-windows-msvc --bundles nsis
- name: Pakete einsammeln
run: sh .gitea/scripts/desktop-collect.sh --require linux,windows
- name: Uebergabe an publish
uses: actions/cache/save@v4
with:
path: desktop-dist
key: desktop-dist-${{ gitea.sha }}
publish: publish:
name: Build & Publish Images name: Build & Publish Images
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: test needs: desktop
steps: steps:
# Ohne volle Historie und Tags liefert `git describe` nichts -- Pflicht fuer den Stempel. # Ohne volle Historie und Tags liefert `git describe` nichts -- Pflicht fuer den Stempel.
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Desktop-Pakete aus dem Zwischenspeicher holen
uses: actions/cache/restore@v4
with:
path: desktop-dist
key: desktop-dist-${{ gitea.sha }}
fail-on-cache-miss: true
- name: Pakete pruefen
run: |
test -f desktop-dist/manifest.json
jq . desktop-dist/manifest.json
- name: Log in to Gitea Container Registry - name: Log in to Gitea Container Registry
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login localhost:3002 -u ${{ gitea.actor }} --password-stdin run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login localhost:3002 -u ${{ gitea.actor }} --password-stdin
+4
View File
@@ -39,3 +39,7 @@ user-files/
# GSD runtime scratch (Dispatch-Sentinel, pro Sitzung neu geschrieben) # GSD runtime scratch (Dispatch-Sentinel, pro Sitzung neu geschrieben)
.gsd/ .gsd/
# Desktop-Pakete aus dem Bau (Phase 18)
desktop-dist/*
!desktop-dist/.gitkeep
+33
View File
@@ -653,3 +653,36 @@ Plans (Wellenstruktur — streng nacheinander, alle drei fassen Schema, Controll
- [x] 17-01-PLAN.md (Welle 1) — Alert-Postfach wechselt vom Mandanten zum Nutzer: Schema, handgeschriebene Migration mit Besitzer-Zuordnung, Dienst und Endpunkt, erste Fassung der Seite "Meine Quellen". Enthaelt den Entscheidungspunkt fuer beide Datenbank-Umbauten der Phase. - [x] 17-01-PLAN.md (Welle 1) — Alert-Postfach wechselt vom Mandanten zum Nutzer: Schema, handgeschriebene Migration mit Besitzer-Zuordnung, Dienst und Endpunkt, erste Fassung der Seite "Meine Quellen". Enthaelt den Entscheidungspunkt fuer beide Datenbank-Umbauten der Phase.
- [x] 17-02-PLAN.md (Welle 2, nach 17-01) — RSS-Feeds bekommen einen Besitzer: Schema und Migration, Besitzerlogik, Schutz gegen fremdes Loeschen, Mengenbegrenzung, Herkunftsmarkierung im Abruf, angepasste Startbestueckung. - [x] 17-02-PLAN.md (Welle 2, nach 17-01) — RSS-Feeds bekommen einen Besitzer: Schema und Migration, Besitzerlogik, Schutz gegen fremdes Loeschen, Mengenbegrenzung, Herkunftsmarkierung im Abruf, angepasste Startbestueckung.
- [x] 17-03-PLAN.md (Welle 3, nach 17-01 und 17-02) — Oberflaeche nach Zustaendigkeit trennen: "Meine Quellen" vollstaendig, Administrationsseite reduziert und rollengeprueft, Zahnrad umgehaengt, Beschriftungen in beiden Sprachen, Backlog-Punkt geschlossen. - [x] 17-03-PLAN.md (Welle 3, nach 17-01 und 17-02) — Oberflaeche nach Zustaendigkeit trennen: "Meine Quellen" vollstaendig, Administrationsseite reduziert und rollengeprueft, Zahnrad umgehaengt, Beschriftungen in beiden Sprachen, Backlog-Punkt geschlossen.
### Phase 18: Desktop-Client fertigstellen
**Goal:** Anwender koennen den Tessera-Desktop-Client (Tauri, Grundgeruest aus Phase 6) als fertigen Windows-Installer (und Linux-AppImage) direkt aus Tessera herunterladen und installieren; die Pipeline baut die Pakete bei jedem Freigabe-Tag und haengt sie an das Gitea-Release; der Client traegt die Freigabe-Version, fragt die Server-Adresse weiterhin beim ersten Start ab und weist bei einer neueren Client-Version mit Download-Link hin.
**Requirements**: DESK-01, DESK-02 (Fortfuehrung), neu: DESK-03 Download in Tessera, DESK-04 Release-Dateien in Gitea, DESK-05 Client-Versionierung + Update-Hinweis
**Depends on:** Phase 17
**Success Criteria** (what must be TRUE):
1. Ein Freigabe-Tag `vX.Y.Z` erzeugt in der Pipeline `Tessera-Setup-X.Y.Z.exe` (Windows, NSIS, Cross-Bau auf Linux) und `Tessera-X.Y.Z.AppImage` (Linux) und haengt beide als Dateien an das Gitea-Release
2. Auf der Anmeldeseite und unter Einstellungen gibt es "Desktop-App herunterladen" (Windows/Linux) mit Versionsangabe; der Download laeuft ueber die Tessera-API (Proxy auf die Release-Datei), Anwender brauchen keinen Gitea-Zugang
3. Der installierte Client zeigt nach Eingabe der Server-Adresse die Tessera-Anmeldung, laeuft mit Tray/Schliessen-ins-Tray/Autostart wie in Phase 6 und meldet eine neuere Client-Version mit Link zur Download-Seite
4. Anwender- und Betriebshandbuch beschreiben Installation, Erststart, Tray-Verhalten, Pipeline, Release-Dateien und Umgebungsvariablen
**Plans:** 4/6 plans executed
Plans:
**Wave 1**
- [x] 18-01-PLAN.md — Tracer (Welle 1): Linux-Strecke lokal durchgehend — desktop-collect.sh (Manifest), API-Modul /desktop/latest + /desktop/download/:platform mit HTTP-Durchstich-Spec, Dockerfile COPY desktop-dist, Beweis im lokalen Docker-Stack; desktop-version.sh + Basislinie 1.1.0
**Wave 2** *(blocked on Wave 1 completion)*
- [x] 18-02-PLAN.md — Pipeline (Welle 2): CI-Job desktop (Linux-AppImage mit Tag-Version) + Uebergabe an publish per actions/cache mit hartem Abbruch, Release-Upload (idempotent) in publish-release.sh
- [x] 18-03-PLAN.md — Web (Welle 2): lib/desktop.ts, Download-Link auf der Anmeldeseite, Seite Einstellungen → Allgemein → Desktop-App, Seitenleiste, i18n de/en, Tests
- [x] 18-04-PLAN.md — Client (Welle 2): lib.rs mit check_server/save_server_url, Versionspruefung gegen /api-proxy/desktop/latest, Tray mit Update-Eintrag und Autostart-Haken (Umlaute), tauri-plugin-opener, Erststart-Seite in Sie-Form/Tessera-Gestalt, echter Icon-Satz, lokaler AppImage-Beweis
**Wave 3** *(blocked on Wave 2 completion)*
- [ ] 18-05-PLAN.md — Windows-Cross-Bau (Welle 3): cargo-xwin/NSIS im Job desktop, Einsammeln beider Pakete, Push-Checkpoint mit Iterationsschleife (max. 3 Runden) bis zum gruenen Lauf
**Wave 4** *(blocked on Wave 3 completion)*
- [ ] 18-06-PLAN.md — Abschluss (Welle 4): Handbuecher (Anwender, Betrieb, Entwicklung, CI/CD-Runbook), CHANGELOG, REQUIREMENTS DESK-01..05, Gesamtlaeufe, Bedienprobe des Nutzers auf Windows
+26 -16
View File
@@ -1,19 +1,19 @@
--- ---
gsd_state_version: "1.0" gsd_state_version: "1.0"
milestone: v1.2 milestone: v1.2
current_phase: 17 current_phase: 18
current_phase_name: eigene-ausschreibungs-quellen-je-nutzer current_phase_name: desktop-client-fertigstellen
status: verified status: verified
stopped_at: "2026-09-16: Version 1.1.0 freigegeben (Tag v1.1.0, live-Abbilder, Gitea-Release von der Pipeline); Beta = main; Mandantenfaehigkeit ruht; Schalter AUS" stopped_at: Completed 18-04-PLAN.md
last_updated: "2026-09-16T10:15:09.000Z" last_updated: "2026-09-16T14:43:25.735Z"
last_activity: 2026-09-16 last_activity: 2026-09-16
last_activity_desc: Quick 260910-jab — drei zu kurz greifende RLS-Regeln geschlossen (GroupMembership beide Seiten, ModuleGrant beide Ziele, TenderRssFeedSource Lese-/Schreibsplit), listForUser gebunden, Aktenstand kohaerent last_activity_desc: Quick 260910-jab — drei zu kurz greifende RLS-Regeln geschlossen (GroupMembership beide Seiten, ModuleGrant beide Ziele, TenderRssFeedSource Lese-/Schreibsplit), listForUser gebunden, Aktenstand kohaerent
state_head: c5f4adeeed4bc858171323a5b4c3866ee991f560 state_head: 289604a28e326f9909963922ca938c6a8fa7d661
progress: progress:
total_phases: 17 total_phases: 18
completed_phases: 15 completed_phases: 15
total_plans: 83 total_plans: 89
completed_plans: 82 completed_plans: 86
milestone_name: Plattform-Berechtigungen milestone_name: Plattform-Berechtigungen
--- ---
@@ -28,12 +28,12 @@ See: .planning/PROJECT.md (updated 2026-07-17)
## Current Position ## Current Position
Phase: 17 (eigene-ausschreibungs-quellen-je-nutzer) — VERIFIED / passed Phase: 18 (desktop-client-fertigstellen) — IN PROGRESS
Plan: 3 of 3 Plan: 4 of 6 (18-02 abgeschlossen)
Status: Phase abgeschlossen und im Browser gegengeprueft — bereit fuer /gsd-ship Status: 18-02 (CI-Job desktop, Cache-Uebergabe an publish, Release-Anhaenge) fertig; 18-03 (Web-Oberflaeche), 18-04 (Client-Updatepruefung), 18-05 (Windows-Cross-Bau + Pipeline-Beweis), 18-06 (Freigabe) stehen aus
Last activity: 2026-09-16 - Aenderungsliste (260916-dcz: CHANGELOG.md, Seite "Was ist neu", Gitea-Release je Tag, Release v1.0.0 angelegt) + Uebersetzungs-Nachtrag c3d8e16; Beta-Abbild c3d8e16 bereit. Freigabe der naechsten Version (1.1.0) auf Zuruf des Users: CHANGELOG Unveroeffentlicht -> 1.1.0, live ff-merge, Tag, Push Last activity: 2026-09-16 - 18-02: Job desktop (Linux-AppImage, actions/cache) vor publish, Manifest-Pruefung in publish-images.sh, idempotenter Release-Anhang-Upload in publish-release.sh
Progress: [██████████] 100% Progress: [███░░░░░░░] 33%
## Performance Metrics ## Performance Metrics
@@ -123,11 +123,17 @@ Progress: [██████████] 100%
| Phase quick-260911-nke P01 | 1 Sitzung | 3 tasks | 27 files | | Phase quick-260911-nke P01 | 1 Sitzung | 3 tasks | 27 files |
| Phase quick-260914-ebg P01 | 6min | 3 tasks | 4 files | | Phase quick-260914-ebg P01 | 6min | 3 tasks | 4 files |
| Phase quick-260914-eym P01 | 1 Sitzung | 3 tasks | 29 files | | Phase quick-260914-eym P01 | 1 Sitzung | 3 tasks | 29 files |
| Phase 18 P01 | 13min | 2 tasks | 15 files |
| Phase 18 P02 | 8 min | 2 tasks | 3 files |
| Phase 18-desktop-client-fertigstellen P03 | 20 min | 2 tasks | 12 files |
| Phase 18 P04 | 9min | 2 tasks | 15 files |
## Accumulated Context ## Accumulated Context
### Roadmap Evolution ### Roadmap Evolution
- Phase 18 added (2026-09-16): Desktop-Client fertigstellen — Installer aus Tessera und am Gitea-Release herunterladbar (User-Entscheidung), Windows-NSIS per Cross-Bau auf dem Linux-Runner, Linux-AppImage, Client-Version = Freigabe-Tag, Update-Hinweis mit Download-Link, Server-Adresse beim Erststart
- Phase 17 added (2026-08-12): Eigene Ausschreibungs-Quellen je Nutzer. TenderEmailConfig (heute `tenantId @unique`) und TenderRssFeedSource (heute `url @unique`, plattformweit) wandern auf `userId`; die Rollenpruefung faellt fuer diese beiden Abschnitte weg, das Abrufintervall der oeffentlichen Quelle bleibt Admin-Sache. Ausschreibungsdaten bleiben plattform-global (D-03 aus Phase 10 unangetastet) — geaendert wird nur, wer Quellen einspeist, nicht wer Treffer sieht. Ausloeser: Backlog `2026-08-11-tender-radar-einstellungen-mischen-rollen.md`; die urspruengliche Zustimmung zur gemeinsamen Konfiguration beruhte auf einer missverstaendlichen Erklaerung. - Phase 17 added (2026-08-12): Eigene Ausschreibungs-Quellen je Nutzer. TenderEmailConfig (heute `tenantId @unique`) und TenderRssFeedSource (heute `url @unique`, plattformweit) wandern auf `userId`; die Rollenpruefung faellt fuer diese beiden Abschnitte weg, das Abrufintervall der oeffentlichen Quelle bleibt Admin-Sache. Ausschreibungsdaten bleiben plattform-global (D-03 aus Phase 10 unangetastet) — geaendert wird nur, wer Quellen einspeist, nicht wer Treffer sieht. Ausloeser: Backlog `2026-08-11-tender-radar-einstellungen-mischen-rollen.md`; die urspruengliche Zustimmung zur gemeinsamen Konfiguration beruhte auf einer missverstaendlichen Erklaerung.
- Phase 15 added (2026-08-04): Modul-Berechtigungen — Gruppen & User-Grants. Zweistufiger Modulzugriff (Mandanten-Aktivierung + Grants pro Gruppe/User), Gruppen mit optionaler AD-Bindung, default geschlossen, ADMIN/SUPER_ADMIN umgehen Grants, nur Zugriff an/aus. Startet Milestone v1.2 Plattform-Berechtigungen. - Phase 15 added (2026-08-04): Modul-Berechtigungen — Gruppen & User-Grants. Zweistufiger Modulzugriff (Mandanten-Aktivierung + Grants pro Gruppe/User), Gruppen mit optionaler AD-Bindung, default geschlossen, ADMIN/SUPER_ADMIN umgehen Grants, nur Zugriff an/aus. Startet Milestone v1.2 Plattform-Berechtigungen.
@@ -305,6 +311,10 @@ Recent decisions affecting current work:
- [Phase 17]: 260911-nke: forTenant(prisma, tenantId, userId?) — optionaler dritter Parameter statt Schwesterhelfer, IS-NULL-OR-Form in den Regeln der zehn persoenlichen Tabellen, sechs Loch-Pruefungen umgedreht - [Phase 17]: 260911-nke: forTenant(prisma, tenantId, userId?) — optionaler dritter Parameter statt Schwesterhelfer, IS-NULL-OR-Form in den Regeln der zehn persoenlichen Tabellen, sechs Loch-Pruefungen umgedreht
- [Phase 17]: [quick-260914-ebg]: Zielrollen-Riegel als eigenstaendige Pruefung nach der Mandantengrenze in UserController.update()/remove() eingezogen (Vorlage AuthService.adminResetPassword, T-FH9-04) — WINDOWS #29 geschlossen - [Phase 17]: [quick-260914-ebg]: Zielrollen-Riegel als eigenstaendige Pruefung nach der Mandantengrenze in UserController.update()/remove() eingezogen (Vorlage AuthService.adminResetPassword, T-FH9-04) — WINDOWS #29 geschlossen
- [Phase 17]: [quick-260914-eym]: forSystem(prisma) als Schwesterhelfer (eigene Detektor-Erkennungsform, Umkehrung der 3b-Begruendung); system_read_policy FOR SELECT auf fuenf Tabellen, SmtpConfig nicht (Mail-Startpfad entfernt, Transport je Versand nach Mandant); DKV-Planer Auftrag je Mandant (promote); Single-Flight-Riegel bleibt prozessweit -> WINDOWS #37 - [Phase 17]: [quick-260914-eym]: forSystem(prisma) als Schwesterhelfer (eigene Detektor-Erkennungsform, Umkehrung der 3b-Begruendung); system_read_policy FOR SELECT auf fuenf Tabellen, SmtpConfig nicht (Mail-Startpfad entfernt, Transport je Versand nach Mandant); DKV-Planer Auftrag je Mandant (promote); Single-Flight-Riegel bleibt prozessweit -> WINDOWS #37
- [Phase 18]: [18-01]: DesktopController braucht @Inject(DesktopService) explizit, da Vitest ueber esbuild ohne emitDecoratorMetadata transpiliert (sonst desktopService=undefined im echten NestFactory-HTTP-Durchstich).
- [Phase 18]: 18-02: Cross-Job-Uebergabe per actions/cache (save/restore, Schluessel exakt am gitea.sha) statt upload-/download-artifact, da diese auf der Gitea-Instanz unzuverlaessig sind. — Pitfall 1 aus 18-RESEARCH.md; publish bricht bei Cache-Fehlschlag hart ab (fail-on-cache-miss + explizite Manifest-Pruefung in Workflow und Skript).
- [Phase 18]: Desktop-Download-Link/Einstellungsseite lesen GET /desktop/latest memoisiert und blenden sich ohne Pakete aus — Wiederverwendung des app-version.ts-Musters (Modul-Ebene-Promise, still bei Fehler)
- [Phase 18]: 18-04: api_url() als einzige Stelle mit dem /api-proxy-Rewrite-Praefix; Erststart-Seite spricht nur noch ueber window.__TAURI__.core.invoke statt Modul-Import
### Pitfalls & Anti-Patterns ### Pitfalls & Anti-Patterns
@@ -457,8 +467,8 @@ sind. Kein Anlass, sie vorher erneut vorzulegen.
## Session Continuity ## Session Continuity
Last session: 2026-09-16T10:15:09.000Z Last session: 2026-09-16T14:43:25.471Z
Resumed: 2026-09-14 — Sitzung ueber /gsd-resume-work fortgesetzt; #29 und 3c als /gsd-quick --validate mit voller Kette durchgefuehrt. Resumed: 2026-09-14 — Sitzung ueber /gsd-resume-work fortgesetzt; #29 und 3c als /gsd-quick --validate mit voller Kette durchgefuehrt.
Stopped at: **VERSION 1.1.0 FREIGEGEBEN — 2026-09-16.** CHANGELOG `Unveröffentlicht` -> `1.1.0 – 2026-09-16` (e0d4532), `live` per ff-merge auf e0d4532, Tag `v1.1.0` gepusht; CI (Beta, live-Pruefung, Tag) gruen; Registry traegt `live`/`v1.1.0` (Stempel `v1.1.0 live e0d4532`); **erster echter CI-Beweis des Release-Wegs: Gitea-Release "Tessera 1.1.0" von der Pipeline angelegt** (Body 2146 Zeichen). Der User spielt 1.1.0 auf tessera.ctl.de ein (pull + up -d --force-recreate api web). Inhalt: Dashboard-Umbau + Nachbesserung, Seite "Was ist neu", Uebersetzungs-Fix. Offen ohne Dringlichkeit: Desktop-Client-Todo, Ship Phase 17, Ledger #35/#36/#37. Mandantenfaehigkeit RUHT. Schalter AUS. Einstieg: `/gsd-resume-work`. Stopped at: Completed 18-04-PLAN.md
Resume file: .planning/HANDOFF.json + .planning/.continue-here.md (2026-09-16T10:28Z) Resume file: None
Last activity: 2026-09-16 - Completed quick task 260916-k2z: Mehrfach-Kreise je Kalender am Tag; Browser-Pruefung + Push ausstehend Last activity: 2026-09-16 - Completed quick task 260916-k2z: Mehrfach-Kreise je Kalender am Tag; Browser-Pruefung + Push ausstehend
@@ -0,0 +1 @@
@@ -0,0 +1,465 @@
---
phase: 18-desktop-client-fertigstellen
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- .gitea/scripts/desktop-collect.sh
- .gitea/scripts/desktop-version.sh
- .gitignore
- desktop-dist/.gitkeep
- apps/api/Dockerfile
- apps/api/src/app.module.ts
- apps/api/src/desktop/desktop.controller.ts
- apps/api/src/desktop/desktop.module.ts
- apps/api/src/desktop/desktop.service.spec.ts
- apps/api/src/desktop/desktop.service.ts
- apps/desktop/package.json
- apps/desktop/src-tauri/Cargo.lock
- apps/desktop/src-tauri/Cargo.toml
- apps/desktop/src-tauri/tauri.conf.json
- packages/shared/src/index.ts
autonomous: true
requirements: [DESK-01, DESK-03, DESK-05]
user_setup: []
estimate:
tokens: 78000
raw_tokens: 78000
tasks: 2
confidence: low
must_haves:
truths:
- "GET /desktop/latest antwortet ohne Anmeldung mit Version, Kanal und Dateiliste aus manifest.json; fehlt das Manifest, antwortet die API mit 404 (D-10)."
- "GET /desktop/download/linux streamt die Datei mit Content-Disposition: attachment und dem Dateinamen aus dem Manifest; eine unbekannte Plattform endet mit 400, bevor das Dateisystem beruehrt wird (D-10)."
- "Das API-Abbild traegt /app/desktop-dist/ mit Paketen und manifest.json; ein lokal neu gebautes Abbild liefert das lokal gebaute AppImage ueber die API aus (D-08)."
- "desktop-version.sh schreibt die Version des letzten Freigabe-Tags als reines X.Y.Z in tauri.conf.json und Cargo.toml; Beta-Laeufe haengen den Commit-Stempel nur an den Dateinamen und ins Manifest (D-07)."
artifacts:
- path: ".gitea/scripts/desktop-version.sh"
provides: "Version aus dem letzten Tag in tauri.conf.json und Cargo.toml schreiben (D-07)"
contains: "git describe --tags"
- path: ".gitea/scripts/desktop-collect.sh"
provides: "Pakete unter kanonischen Namen einsammeln, Groesse und SHA-256 berechnen, manifest.json schreiben (D-08)"
contains: "manifest.json"
- path: "apps/api/src/desktop/desktop.service.ts"
provides: "Manifest lesen, Plattform-Whitelist, Datei-Stream (D-10)"
contains: "PLATFORMS"
- path: "apps/api/src/desktop/desktop.controller.ts"
provides: "GET /desktop/latest und GET /desktop/download/:platform, beide @Public()"
exports: ["DesktopController"]
- path: "apps/api/src/desktop/desktop.service.spec.ts"
provides: "HTTP-Durchstich ueber NestFactory: Manifest vorhanden/fehlt, Whitelist, Traversal, @Public()"
min_lines: 80
- path: "apps/api/Dockerfile"
provides: "COPY desktop-dist nach /app/desktop-dist"
contains: "desktop-dist"
key_links:
- from: ".gitea/scripts/desktop-collect.sh"
to: "apps/api/src/desktop/desktop.service.ts"
via: "manifest.json (version, channel, commit, buildTime, files.{windows,linux}.{name,size,sha256}) — die API liest ausschliesslich diese Datei"
pattern: "manifest\\.json"
- from: "apps/api/Dockerfile"
to: "apps/api/src/desktop/desktop.service.ts"
via: "COPY desktop-dist ./desktop-dist — vier Ebenen ueber apps/api/dist/desktop/ liegt /app/desktop-dist"
pattern: "desktop-dist"
---
<objective>
Die duenne, aber vollstaendige Bahn dieser Phase: Ein Linux-AppImage aus dem
Tauri-Bau bekommt die Freigabe-Version, wird unter kanonischem Namen samt
`manifest.json` eingesammelt, landet im API-Abbild unter `/app/desktop-dist/`
und wird von der API ueber `GET /desktop/latest` und
`GET /desktop/download/linux` ohne Anmeldung ausgeliefert — lokal bewiesen
mit dem echten Docker-Stack. Der CI-Job `desktop`, die Uebergabe an `publish`
und der Release-Upload folgen in 18-02; der Windows-Cross-Bau (18-05), die
Web-Oberflaeche (18-03) und der Client (18-04) bauen daneben auf dieser
bewiesenen Strecke auf.
Purpose: D-07, D-08 (Abbild-Seite) und D-10 aus 18-CONTEXT.md umsetzen und
die Architektur (Skript -> Abbild -> API) einmal durchgehend beweisen, bevor
die breiteren Plaene folgen.
Output: Zwei CI-Skripte, das API-Modul `apps/api/src/desktop/` mit
HTTP-Durchstich-Spec, geteilte Typen, Dockerfile-Erweiterung, Basislinie
`1.1.0`.
**Kein Datenbank-Schema betroffen:** Diese Phase aendert weder
`schema.prisma` noch Migrationen — kein Schema-Push noetig.
**Identitaetsfrage (Plattform):** `platform` ist ein geschlossener Wertevorrat
`'windows' | 'linux'` (Typ `DesktopPlatform` in `packages/shared`, Konstante
`PLATFORMS` im Dienst), kein freier String. Eine dritte Plattform waere eine
bewusste Erweiterung an genau diesen zwei Stellen.
**Externe Schnittstellen (Gitea REST, einzige in dieser Phase):** Bereits in
Gebrauch: `GET /repos/{owner}/{repo}/releases/tags/{tag}`, `POST .../releases`,
`PATCH .../releases/{id}`. Neu in diesem Plan:
`GET /repos/{owner}/{repo}/releases/{id}/assets`,
`DELETE /repos/{owner}/{repo}/releases/{id}/assets/{asset_id}`,
`POST /repos/{owner}/{repo}/releases/{id}/assets?name={name}` (multipart-Feld
`attachment`). Keine weitere Gitea-Faehigkeit ist im Umfang. Am 2026-09-16
gegen die laufende Instanz geprueft: Gitea 1.26.2; Release-Anhaenge sind
standardmaessig ohne Typ-Beschraenkung und bis 2048 MB erlaubt
(`[repository.release]`, Voreinstellung).
**Vom Client aus ist die API nur ueber den Web-Ursprung erreichbar:** Im
Betrieb steht die API nicht unter dem Web-Hostnamen, sondern hinter dem
Next.js-Rewrite `/api-proxy/*` (`apps/web/next.config.ts`; die Web-Oberflaeche
nutzt zur Bauzeit `NEXT_PUBLIC_API_URL=/api-proxy`). Deshalb sind alle
`url`-Felder der Antwort von `/desktop/latest` **relativ zur API-Basis**
(`/desktop/download/linux`); die Web-Oberflaeche stellt `API_URL` davor, der
Client (18-04) spricht `{server}/api-proxy/desktop/latest`.
</objective>
## Artifacts this phase produces
Phase 18 gesamt (dieser Plan erzeugt die mit * markierten):
- `.gitea/scripts/desktop-version.sh` * — Version aus dem letzten Tag setzen
- `.gitea/scripts/desktop-collect.sh` * — Pakete einsammeln, `manifest.json`
- 18-02: `.gitea/workflows/ci.yml` — Job `desktop`, `publish` mit Cache-Restore (18-05 ergaenzt Windows); `.gitea/scripts/publish-images.sh` — harte Pruefung auf `desktop-dist/manifest.json`; `.gitea/scripts/publish-release.sh` — Funktion `upload_asset`, Upload aller Manifest-Dateien
- `desktop-dist/.gitkeep` *, `.gitignore` * — Platzhalter-Verzeichnis fuer die Pakete
- `apps/api/Dockerfile` * — `COPY desktop-dist ./desktop-dist`
- `packages/shared/src/index.ts` * — `DesktopPlatform`, `DesktopManifestFile`, `DesktopManifest`, `DesktopLatestFile`, `DesktopLatestResponse`
- `apps/api/src/desktop/desktop.module.ts` *, `desktop.controller.ts` * (`DesktopController.getLatest`, `DesktopController.download`), `desktop.service.ts` * (`DesktopService.getManifest`, `getLatest`, `getPackage`, `PLATFORMS`), `desktop.service.spec.ts` *
- `apps/api/src/app.module.ts` * — `DesktopModule` registriert
- `apps/desktop/src-tauri/tauri.conf.json` *, `Cargo.toml` *, `Cargo.lock` *, `apps/desktop/package.json` * — Basislinie `1.1.0`
- 18-03: `apps/web/src/lib/desktop.ts` (`loadDesktopLatest`, `desktopDownloadUrl`, `formatFileSize`), `desktop.test.ts`, `components/desktop/desktop-download-links.tsx` (+Test), `app/(auth)/login/page.tsx`, `app/(portal)/settings/general/desktop/page.tsx`, `components/settings/desktop-app-settings.tsx` (+Test), `components/settings/settings-sidebar.tsx`, `messages/de.json`, `messages/en.json`
- 18-04: `apps/desktop/src-tauri/src/lib.rs` (Kommandos `check_server`, `save_server_url`; Tray `update`, `autostart`), `Cargo.toml` (`tauri-plugin-opener`), `capabilities/default.json`, `apps/desktop/src/setup.html`, `icons/*`
- 18-05: `ci.yml` (Windows-Cross-Bau), `desktop-collect.sh --require linux,windows`
- 18-06: `docs/anleitung-anwender.md`, `docs/anleitung-betrieb.md`, `docs/anleitung-entwicklung.md`, `docs/ci-cd-setup.md`, `CHANGELOG.md`, `.planning/REQUIREMENTS.md`
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/18-desktop-client-fertigstellen/18-CONTEXT.md
@.planning/phases/18-desktop-client-fertigstellen/18-RESEARCH.md
@.planning/phases/18-desktop-client-fertigstellen/18-PATTERNS.md
@.gitea/scripts/publish-images.sh
@apps/api/Dockerfile
@apps/api/src/health/health.controller.ts
@apps/api/src/health/health.controller.spec.ts
@apps/api/src/dkv/dkv.service.ts
@packages/shared/src/index.ts
</context>
<tasks>
<task type="tracer">
<name>Task 1: Ein Linux-Paket aus dem Bau bis zum Download aus der API — eine Strecke</name>
<precondition>Auf dem Entwicklungsrechner sind Rust/Cargo (1.96) und die Tauri-Linux-Abhaengigkeiten installiert (libwebkit2gtk-4.1-dev, libayatana-appindicator3-dev, librsvg2-dev, libgtk-3-dev — laut 18-RESEARCH.md "Environment Availability" vorhanden), und der lokale Docker-Stack aus `docker-compose.yml` laeuft (Container `tessera-ctl-api-1` auf Port 3001, `tessera-ctl-web-1` auf Port 3000).</precondition>
<reversibility rating="costly">Die Antwortform von `GET /desktop/latest` (Feld `version`, `files.{windows,linux}.{name,size,sha256,url}`) wird von installierten Clients gelesen; Aenderungen muessen abwaertskompatibel (nur additiv) bleiben, sonst verlieren alte Clients den Update-Hinweis.</reversibility>
<files>
.gitea/scripts/desktop-collect.sh,
.gitignore,
desktop-dist/.gitkeep,
packages/shared/src/index.ts,
apps/api/src/desktop/desktop.module.ts,
apps/api/src/desktop/desktop.controller.ts,
apps/api/src/desktop/desktop.service.ts,
apps/api/src/desktop/desktop.service.spec.ts,
apps/api/src/app.module.ts,
apps/api/Dockerfile
</files>
<read_first>
.planning/phases/18-desktop-client-fertigstellen/18-RESEARCH.md (Abschnitte "Pattern 2", "Code Examples 7", "Common Pitfalls 1 und 4"),
.planning/phases/18-desktop-client-fertigstellen/18-PATTERNS.md (Abschnitte desktop.module/controller/service/spec, Dockerfile, shared),
apps/api/src/health/health.controller.ts,
apps/api/src/health/health.controller.spec.ts,
apps/api/src/health/health.module.ts,
apps/api/src/dkv/dkv.service.ts (Zeilen 85-110 und 700-732),
apps/api/src/auth/decorators/public.decorator.ts,
apps/api/Dockerfile,
.gitea/scripts/publish-images.sh (Kopfkommentar und case-Block als Stilvorlage),
packages/shared/src/index.ts,
.dockerignore
</read_first>
<behavior>
- `GET /desktop/latest` liefert bei vorhandenem Manifest 200 mit `{ version, channel, commit, buildTime, files: { linux: { name, size, sha256, url: "/desktop/download/linux" } } }`; ohne Manifest 404.
- `GET /desktop/download/linux` liefert 200, `Content-Disposition: attachment; filename="{name aus Manifest}"`, `Content-Type: application/octet-stream`, `Content-Length` = `size`, und der Inhalt hat exakt den SHA-256 aus dem Manifest.
- `GET /desktop/download/mac` und `GET /desktop/download/..%2F..%2Fetc%2Fpasswd` enden mit 400 — auch dann, wenn das Verzeichnis gar nicht existiert (Whitelist greift vor jedem Dateisystemzugriff).
- Listet das Manifest die angefragte Plattform nicht, kommt 404; traegt ein Manifest-Eintrag einen Namen mit Pfadzeichen, kommt ebenfalls 404 (Verteidigung in der Tiefe, T-18-02).
- Beide Handler tragen `@Public()` (Reflect-Metadaten `isPublic === true`).
- `desktop-collect.sh` findet das AppImage im Tauri-Bundle-Verzeichnis, kopiert es nach `desktop-dist/Tessera-{version}.AppImage` und schreibt `desktop-dist/manifest.json` mit korrekter Groesse und korrektem SHA-256.
</behavior>
<action>
**Platzhalter-Verzeichnis.** `desktop-dist/.gitkeep` (leer) anlegen und in
`.gitignore` unter einer neuen Ueberschrift "Desktop-Pakete aus dem Bau
(Phase 18)" die zwei Zeilen `desktop-dist/*` und `!desktop-dist/.gitkeep`
ergaenzen. Grund: Das Dockerfile kopiert `desktop-dist/` immer; ohne
versionierten Platzhalter scheitert jeder lokale `docker build`, und die API
soll bei leerem Verzeichnis sauber 404 liefern (D-10). `.dockerignore` braucht
keine Aenderung — die Zeile `dist` trifft nur das Wurzelverzeichnis `dist`,
nicht `desktop-dist` (per D-08 muss der Ordner in den Bau-Kontext).
**Geteilte Typen (`packages/shared/src/index.ts`).** Direkt unter
`VersionResponse` im selben flachen Stil ergaenzen, mit deutschem
Kopfkommentar (Quelle: `manifest.json`, geschrieben nur von
`desktop-collect.sh` im CI, D-08): `export type DesktopPlatform = 'windows' | 'linux'`;
`DesktopManifestFile { name: string; size: number; sha256: string }`;
`DesktopManifest { version: string; channel: string; commit: string; buildTime: string; files: Partial<Record<DesktopPlatform, DesktopManifestFile>> }`;
`DesktopLatestFile extends DesktopManifestFile { url: string }`;
`DesktopLatestResponse` mit denselben vier Kopf-Feldern und
`files: Partial<Record<DesktopPlatform, DesktopLatestFile>>`. `files` ist
bewusst `Partial`, weil dieser Plan nur Linux liefert und Windows erst mit
18-05 dazukommt.
**Sammel-Skript `.gitea/scripts/desktop-collect.sh`** (POSIX `sh`, `set -eu`,
deutscher Kopfkommentar im Stil von `publish-images.sh`, kennt kein Secret).
Aufruf `sh .gitea/scripts/desktop-collect.sh --require linux` (Kommaliste,
spaeter `linux,windows`). Umgebung: `GITHUB_REF` (Kanalentscheidung exakt wie
in `publish-images.sh`: `refs/tags/v*` -> Kanal `live`, kein Suffix;
`refs/heads/main` -> Kanal `beta`, Suffix `-beta.{7-stelliger SHA}`; alles
andere -> Kanal `dev`, kein Suffix, damit lokale Proben die Freigabe-Namen
tragen), `DESKTOP_DIST` (Vorgabe `desktop-dist`), `TAURI_DIR` (Vorgabe
`apps/desktop/src-tauri`). Ablauf: Version per `jq -r .version` aus
`$TAURI_DIR/tauri.conf.json` lesen und gegen `^[0-9]+\.[0-9]+\.[0-9]+$`
pruefen (sonst Exit 1 — Pitfall 2, NSIS nimmt nur numerische Versionen);
`git rev-parse --short=7 HEAD`; alte `*.AppImage`, `*.exe`, `manifest.json`
im Zielordner entfernen (Platzhalter bleibt); Linux: genau eine Datei
`$TAURI_DIR/target/release/bundle/appimage/*.AppImage` per `find`/`ls`
ermitteln — bei null oder mehr als einer Datei und geforderter Plattform Exit 1
mit klarer Meldung (Pitfall 4: niemals den Tauri-Vorgabenamen annehmen);
kopieren nach `Tessera-${VERSION}${SUFFIX}.AppImage`; Windows analog aus
`$TAURI_DIR/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe` nach
`Tessera-Setup-${VERSION}${SUFFIX}.exe` (in diesem Plan noch nicht gefordert,
Zweig aber schon anlegen); je Datei `size` ueber `stat -c %s` und `sha256`
ueber `sha256sum | cut -d' ' -f1`; `manifest.json` ausschliesslich mit `jq -n`
und `--arg`/`--argjson` bauen (Felder `version`, `channel`, `commit`,
`buildTime` als UTC-ISO-Zeit, `files` nur mit tatsaechlich vorhandenen
Plattformen); zum Schluss je Datei eine Zeile `linux: {Name} ({Bytes} Bytes,
sha256 {Hash})` ausgeben. Datei ausfuehrbar machen (`chmod +x`) wie die
Nachbarskripte.
**Lokales AppImage als Testobjekt.** Liegt unter
`apps/desktop/src-tauri/target/release/bundle/appimage/` noch das AppImage aus
Phase 6, reicht es fuer diesen Durchstich; sonst zuerst
`pnpm --filter @tessera/desktop exec tauri build --bundles appimage`
laufen lassen (dauert einige Minuten). Danach das Sammel-Skript aufrufen; es
muss `desktop-dist/Tessera-0.0.1.AppImage` und `desktop-dist/manifest.json`
erzeugen (die Basislinie `1.1.0` kommt erst in Task 2).
**API-Modul `apps/api/src/desktop/`.** `desktop.module.ts` nach dem Vorbild
`health.module.ts` mit `controllers: [DesktopController]` und
`providers: [DesktopService]`; in `app.module.ts` importieren und hinter
`HealthModule` in die `imports`-Liste aufnehmen.
`desktop.service.ts` (`@Injectable()`, Imports `fs`/`path` wie
`dkv.service.ts`): Konstante `PLATFORMS = ['windows', 'linux'] as const`
(Wertevorrat = `DesktopPlatform`). Verzeichnis im Konstruktor bestimmen:
`process.env.DESKTOP_DIST_DIR` (getrimmt, nicht leer) hat Vorrang, sonst
`path.resolve(__dirname, '..', '..', '..', '..', 'desktop-dist')` — gleiche
Vier-Ebenen-Aufloesung wie `userFilesDir` in `dkv.service.ts`, ergibt im
Abbild `/app/desktop-dist` und lokal die Monorepo-Wurzel. Methoden:
`getManifest(): DesktopManifest | null` (liest `manifest.json`, `null` wenn
Datei fehlt oder `JSON.parse` scheitert oder `version` kein String bzw. `files`
kein Objekt ist — mit `Logger.warn`, nie werfen);
`getLatest(): DesktopLatestResponse` (wirft `NotFoundException('Desktop packages are not available on this server')`
ohne Manifest; sonst Kopf-Felder uebernehmen und je vorhandener Plattform
`url: '/desktop/download/' + platform` ergaenzen);
`getPackage(platform: string): { stream: fs.ReadStream; entry: DesktopManifestFile }`
in genau dieser Reihenfolge: (1) `PLATFORMS.includes(platform)` sonst
`BadRequestException('Unknown platform')` — vor jedem Dateisystemzugriff;
(2) Manifest holen, sonst 404; (3) `manifest.files[platform]` fehlt -> 404;
(4) `entry.name` muss `^[A-Za-z0-9._-]+$` erfuellen, sonst 404 (kein Name aus
der Anfrage, aber auch ein manipuliertes Manifest darf nicht aus dem Ordner
hinausfuehren); (5) `path.join(dir, entry.name)` muss existieren, sonst 404;
(6) `fs.createReadStream` zurueckgeben.
`desktop.controller.ts` (`@Controller('desktop')`, Konstruktor mit
`DesktopService`): `@Public() @Get('latest') getLatest()` mit einem
Kommentar, warum oeffentlich (D-10: die Anmeldeseite zeigt den Link vor jeder
Anmeldung; gleicher Grund wie `HealthController.getVersion`, T-KU1-03);
`@Public() @Get('download/:platform') download(@Param('platform') platform: string): StreamableFile`
— `new StreamableFile(stream, { type: 'application/octet-stream', disposition: 'attachment; filename="' + entry.name + '"', length: entry.size })`
(Optionen-Objekt von `StreamableFile` aus `@nestjs/common`; kein `@Res`, kein
Puffern der ganzen Datei — Installer sind zwei Groessenordnungen groesser als
die DKV-Exporte, deshalb bewusst anders als `dkv.controller.ts`). Kein
`@Roles()` an beiden Handlern.
`desktop.service.spec.ts` (Kopfkommentar und nummerierte `it('Test N (…)')`
im Stil von `health.controller.spec.ts`, `import 'reflect-metadata'` zuerst).
Keine `fs`-Mocks — stattdessen ein echtes Temp-Verzeichnis
(`fs.mkdtempSync(path.join(os.tmpdir(), 'tessera-desktop-'))`) mit einer
kleinen Zufallsdatei (z. B. 64 KiB aus `crypto.randomBytes`) und einem von
Hand geschriebenen `manifest.json`, dessen `sha256` im Test unabhaengig ueber
`crypto.createHash('sha256')` berechnet wird. Fuer den HTTP-Durchstich
`process.env.DESKTOP_DIST_DIR` auf das Temp-Verzeichnis setzen, dann
`NestFactory.create(DesktopModule, { logger: false })`, `await app.listen(0)`,
Port aus `app.getHttpServer().address().port`, Aufrufe mit dem globalen
`fetch`; im `afterAll` `app.close()` und Temp-Verzeichnis entfernen. Faelle:
Test 1 latest -> 200 und Form wie in `<behavior>`; Test 2 Dienst ohne
Manifest (zweites, leeres Temp-Verzeichnis, eigene `DesktopService`-Instanz
nach Umsetzen der Umgebungsvariable) -> `NotFoundException`; Test 3
download/linux -> Header und Body-Hash wie in `<behavior>`; Test 4 `mac` und
`..%2F..%2Fetc%2Fpasswd` -> 400; Test 5 Dienst mit nicht existierendem
Verzeichnis und Plattform `mac` -> `BadRequestException` (nicht
`NotFoundException`) — beweist die Reihenfolge Whitelist vor Dateisystem;
Test 6 Manifest nur mit `windows` -> download/linux 404; Test 7 Manifest mit
Namen `../x.AppImage` -> 404; Test 8 `@Public()` auf `getLatest` und
`download` per `Reflect.getMetadata(IS_PUBLIC_KEY, DesktopController.prototype.getLatest)`.
Erwartungswerte von Hand hinschreiben, nicht ueber den Pruefling erzeugen.
**Dockerfile (`apps/api/Dockerfile`).** In der `runner`-Stufe unmittelbar vor
`USER nestjs` die Zeile `COPY desktop-dist ./desktop-dist` mit deutschem
Kommentar (Phase 18, D-08: Pakete werden vom CI in den Bau-Kontext gelegt,
lokal nur der Platzhalter; nur lesend, keine `chown` noetig).
**Durchstich im laufenden Stack.** Nach den Tests das API-Abbild lokal neu
bauen und den Container ersetzen (`docker compose build api` und danach
`docker compose up -d --force-recreate api` — `up` allein baut nicht neu,
Projektwissen "Deploy-Fallstricke"); dann `curl http://localhost:3001/desktop/latest`
und die Kopfzeilen von `/desktop/download/linux` pruefen, zusaetzlich ueber
den Web-Rewrite `http://localhost:3000/api-proxy/desktop/latest`. Danach bleibt
der lokale Stack in diesem Zustand (mit Paketen) stehen.
</action>
<acceptance_criteria>
- `git ls-files --error-unmatch desktop-dist/.gitkeep` endet mit 0 nach dem Commit; `grep -c '^!desktop-dist/.gitkeep$' .gitignore` ergibt 1.
- `grep -c 'export type DesktopPlatform' packages/shared/src/index.ts` ergibt 1; `grep -c 'export interface DesktopLatestResponse' packages/shared/src/index.ts` ergibt 1.
- `grep -c "DesktopModule" apps/api/src/app.module.ts` ergibt mindestens 2 (Import und imports-Eintrag).
- `grep -c '^COPY desktop-dist ./desktop-dist' apps/api/Dockerfile` ergibt 1.
- `grep -v '^\s*//' apps/api/src/desktop/desktop.controller.ts | grep -c '@Public()'` ergibt 2.
- `grep -v '^\s*//' apps/api/src/desktop/desktop.service.ts | grep -c "PLATFORMS = \['windows', 'linux'\] as const"` ergibt 1.
- `pnpm --filter @tessera/api exec vitest run src/desktop` meldet 8 Tests bestanden, 0 fehlgeschlagen.
- `sh .gitea/scripts/desktop-collect.sh --require linux` erzeugt `desktop-dist/manifest.json`; `jq -r .files.linux.name desktop-dist/manifest.json` ergibt `Tessera-0.0.1.AppImage` (bzw. die aktuelle Version aus tauri.conf.json) und der SHA-256 im Manifest ist gleich `sha256sum` der Datei.
- `curl -s http://localhost:3001/desktop/latest | jq -r .files.linux.url` ergibt `/desktop/download/linux`; `curl -sI http://localhost:3001/desktop/download/linux` enthaelt `content-disposition: attachment; filename="Tessera-` und den Status 200.
</acceptance_criteria>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/api exec vitest run src/desktop && pnpm --filter @tessera/api type-check</automated>
<fails_when>vitest meldet "failed" oder einen Exit-Code ungleich 0, oder tsc gibt Fehlerzeilen aus.</fails_when>
<automated>cd /home/vicolab/projects/tessera-ctl && sh .gitea/scripts/desktop-collect.sh --require linux && test "$(sha256sum "desktop-dist/$(jq -r .files.linux.name desktop-dist/manifest.json)" | cut -d' ' -f1)" = "$(jq -r .files.linux.sha256 desktop-dist/manifest.json)" && echo MANIFEST-OK</automated>
<fails_when>Das Skript endet mit Exit 1, `manifest.json` fehlt, oder die Zeile `MANIFEST-OK` erscheint nicht (Hash-Abweichung).</fails_when>
<automated>cd /home/vicolab/projects/tessera-ctl && curl -sf http://localhost:3001/desktop/latest | jq -e '.files.linux.url == "/desktop/download/linux"' && curl -sI http://localhost:3001/desktop/download/linux | grep -i 'content-disposition: attachment; filename="Tessera-' && curl -sf http://localhost:3000/api-proxy/desktop/latest | jq -e .version</automated>
<fails_when>curl liefert einen Nicht-2xx-Status (Exit 22), `jq -e` findet das Feld nicht, oder die Kopfzeile `content-disposition: attachment; filename="Tessera-` fehlt — dann liefert das neu gebaute Abbild die Pakete nicht aus.</fails_when>
</verify>
<done>
Acht Spec-Tests gruen, Typpruefung fehlerfrei. Das lokal eingesammelte
AppImage liegt mit passendem Manifest in `desktop-dist/`, das neu gebaute
API-Abbild liefert es unter `/desktop/download/linux` mit `attachment`-Header
aus, und `/desktop/latest` ist auch ueber `/api-proxy/` des Web-Containers
erreichbar.
</done>
</task>
<task type="auto">
<name>Task 2: Die Version kommt aus dem Freigabe-Tag — Skript und Basislinie 1.1.0</name>
<files>
.gitea/scripts/desktop-version.sh,
apps/desktop/src-tauri/tauri.conf.json,
apps/desktop/src-tauri/Cargo.toml,
apps/desktop/src-tauri/Cargo.lock,
apps/desktop/package.json
</files>
<read_first>
.planning/phases/18-desktop-client-fertigstellen/18-RESEARCH.md (Abschnitte "Code Examples 1" und "Common Pitfalls 2"),
.gitea/scripts/publish-images.sh,
apps/desktop/src-tauri/tauri.conf.json,
apps/desktop/src-tauri/Cargo.toml (Zeile `version = "0.0.1"` unter `[package]`; die Zeilen `tauri = { version = "2", … }` stehen nicht am Zeilenanfang),
apps/desktop/package.json
</read_first>
<action>
**Skript `.gitea/scripts/desktop-version.sh`** (POSIX `sh`, `set -eu`,
deutscher Kopfkommentar; Muster aus RESEARCH Code Example 1, D-07).
Versionsquelle: `TAG="${DESKTOP_TAG:-$(git describe --tags --abbrev=0 --match 'v[0-9]*')}"`
(die Umgebungsvariable `DESKTOP_TAG` dient nur der lokalen Probe); scheitert
`git describe` (kein Tag erreichbar), Exit 1 mit Meldung — im CI ist das ein
Fehler, weil `fetch-depth: 0` Pflicht ist. `VERSION="${TAG#v}"` muss
`^[0-9]+\.[0-9]+\.[0-9]+$` erfuellen, sonst Exit 1: es wird **nie** eine
Vorab- oder Metadaten-Form geschrieben (Pitfall 2, Windows-Ressourcen sind
rein numerisch). Schreiben: `tauri.conf.json` per `jq --arg v "$VERSION" '.version = $v'`
ueber eine Temp-Datei; `Cargo.toml` per `sed -i` nur auf der Zeile, die mit
`version = "` **am Zeilenanfang** beginnt (trifft ausschliesslich den
`[package]`-Eintrag). `apps/desktop/package.json` bleibt vom Skript
unberuehrt (kein Bau-Eingang). Option `--print`: nur die ermittelte Version
ausgeben, nichts schreiben. Abschlusszeile `Desktop-Version gesetzt: X.Y.Z (aus Tag vX.Y.Z)`.
Ausfuehrbar machen.
**Basislinie einchecken.** Das Skript einmal lokal ausfuehren (aktueller
letzter Tag ist `v1.1.0`), danach `cargo check` im Verzeichnis
`apps/desktop/src-tauri` laufen lassen, damit `Cargo.lock` den Eintrag des
eigenen Pakets auf `1.1.0` zieht; `apps/desktop/package.json` von Hand auf
`"version": "1.1.0"` setzen. Alle vier Dateien werden mit dem Skript
committet — die eingecheckten Werte sind nur die Basislinie fuer lokale Baue,
die Wahrheit im CI ist der Tag (Kopfkommentar des Skripts sagt genau das).
**Frisches AppImage mit der Basislinie.** `pnpm --filter @tessera/desktop exec tauri build --bundles appimage`
erneut laufen lassen (bei warmem `target/` wenige Minuten), vorher das alte
Bundle-Verzeichnis `apps/desktop/src-tauri/target/release/bundle` entfernen,
damit `desktop-collect.sh` genau eine Datei findet. Danach
`sh .gitea/scripts/desktop-collect.sh --require linux` — das Manifest traegt
jetzt `1.1.0` und den Namen `Tessera-1.1.0.AppImage`.
</action>
<acceptance_criteria>
- `sh .gitea/scripts/desktop-version.sh --print` gibt genau `1.1.0` aus (bei Tag-Stand v1.1.0).
- `jq -r .version apps/desktop/src-tauri/tauri.conf.json` ergibt `1.1.0`; `grep -c '^version = "1.1.0"' apps/desktop/src-tauri/Cargo.toml` ergibt 1; `grep -c '"version": "1.1.0"' apps/desktop/package.json` ergibt 1.
- `grep -A1 'name = "tessera-desktop"' apps/desktop/src-tauri/Cargo.lock | grep -c 'version = "1.1.0"'` ergibt 1.
- `jq -r .files.linux.name desktop-dist/manifest.json` ergibt `Tessera-1.1.0.AppImage`.
- Negativprobe: `DESKTOP_TAG=v1.2.3-beta sh .gitea/scripts/desktop-version.sh --print` endet mit Exit 1 und schreibt nichts; `DESKTOP_TAG=v2.0.0 sh .gitea/scripts/desktop-version.sh --print` gibt `2.0.0` aus und schreibt ebenfalls nichts (Dateien bleiben bei `1.1.0`).
</acceptance_criteria>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl && test "$(sh .gitea/scripts/desktop-version.sh --print)" = "1.1.0" && test "$(jq -r .version apps/desktop/src-tauri/tauri.conf.json)" = "1.1.0" && grep -q '^version = "1.1.0"' apps/desktop/src-tauri/Cargo.toml && test "$(jq -r .files.linux.name desktop-dist/manifest.json)" = "Tessera-1.1.0.AppImage" && echo VERSION-OK</automated>
<fails_when>Eine der Pruefungen schlaegt fehl und `VERSION-OK` erscheint nicht — Skript, Basislinie oder Manifest tragen nicht `1.1.0`.</fails_when>
<automated>cd /home/vicolab/projects/tessera-ctl && if DESKTOP_TAG=v1.2.3-beta sh .gitea/scripts/desktop-version.sh --print >/dev/null 2>&1; then echo "Vorabversion wurde akzeptiert"; exit 1; fi && test "$(jq -r .version apps/desktop/src-tauri/tauri.conf.json)" = "1.1.0" && echo REJECT-OK</automated>
<fails_when>Das Skript akzeptiert `v1.2.3-beta` (Exit 0) oder hat trotz `--print` die Datei veraendert — `REJECT-OK` fehlt.</fails_when>
<automated>cd /home/vicolab/projects/tessera-ctl/apps/desktop/src-tauri && cargo check 2>&1 | tail -1 | grep -q 'Finished'</automated>
<fails_when>`cargo check` endet nicht mit einer `Finished`-Zeile (Kompilierfehler nach der Versionsaenderung).</fails_when>
</verify>
<done>
Skript, Basislinie `1.1.0` in allen vier Dateien, `cargo check` gruen, und
`desktop-dist/` traegt `Tessera-1.1.0.AppImage` samt Manifest.
</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Internet -> API (`/desktop/latest`, `/desktop/download/:platform`) | Oeffentliche, unauthentifizierte Endpunkte; der Pfadparameter ist Angreifereingabe. |
| CI-Runner -> API-Abbild (`desktop-dist/`) | Das Manifest und die Pakete entstehen im Runner und werden unveraendert ins Abbild kopiert. |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-18-01 | Tampering / Information Disclosure | `DesktopService.getPackage` (Pfad-Traversal ueber `:platform`) | high | mitigate | Whitelist `PLATFORMS` vor jedem Dateisystemzugriff; Dateiname kommt ausschliesslich aus `manifest.json`; zusaetzlich Namensmuster `^[A-Za-z0-9._-]+$`. Spec-Tests 4, 5 und 7 pinnen das. |
| T-18-02 | Tampering | `manifest.json` (veraltet oder manipuliert) | medium | mitigate | Nur `desktop-collect.sh` im CI schreibt die Datei; sie liegt im unveraenderlichen Abbild, kein Laufzeitpfad schreibt nach `/app/desktop-dist/`; Namensmuster-Pruefung als Verteidigung in der Tiefe. SHA-256 ist Integritaets-Metadatum, keine Signatur (D-09). |
| T-18-04 | Information Disclosure | `GET /desktop/latest` (Version, Kanal, Commit oeffentlich) | low | accept | Gleiche Abwaegung wie `GET /health/version` (T-KU1-03): keine Komponentenversionen, privates Repository; die Anmeldeseite braucht die Daten vor der Anmeldung (D-10). |
| T-18-05 | Denial of Service | `GET /desktop/download/:platform` (grosse Datei, oeffentlich) | low | accept | Streaming statt Puffern; Ratenbegrenzung ist Aufgabe des vorgeschalteten Nginx Proxy Managers (ASVS L1). |
| T-18-SC | Tampering | Paketinstallationen | low | accept | Dieser Plan installiert kein neues Paket (Legitimitaetstabelle in RESEARCH: `cargo-xwin`, `tauri-plugin-opener` beide `OK`, kommen in 18-04/18-05). |
</threat_model>
<verification>
1. `pnpm --filter @tessera/api exec vitest run src/desktop` — 8 Tests gruen.
2. `pnpm --filter @tessera/api type-check` — fehlerfrei.
3. `desktop-dist/manifest.json` traegt `1.1.0` und den Namen `Tessera-1.1.0.AppImage`, SHA-256 stimmt mit der Datei ueberein.
4. Lokal neu gebautes API-Abbild liefert `/desktop/latest` (200) und `/desktop/download/linux` (200, `attachment`) aus; ueber `http://localhost:3000/api-proxy/desktop/latest` ebenfalls 200.
5. Beide neuen Skripte bestehen `sh -n`; `desktop-version.sh` weist eine Vorabversion ab.
</verification>
<success_criteria>
- Ein lokal gebautes AppImage wird nach dem Einsammeln vom neu gebauten
API-Abbild ohne Anmeldung ausgeliefert (Strecke Skript -> Abbild -> API
bewiesen).
- Unbekannte Plattformen und Traversal-Versuche enden mit 400, fehlende
Pakete mit 404 — gepinnt durch die Spec.
- Die Versionsquelle ist der Freigabe-Tag; die Basislinie im Repository ist
`1.1.0`.
</success_criteria>
<output>
Create `.planning/phases/18-desktop-client-fertigstellen/18-01-SUMMARY.md` when done.
Im SUMMARY festhalten: Groesse und SHA-256 des lokal eingesammelten AppImage,
die Dauer des lokalen `tauri build`, und ob das Phase-6-AppImage oder ein
frischer Bau als Testobjekt diente.
</output>
@@ -0,0 +1,221 @@
---
phase: 18-desktop-client-fertigstellen
plan: 01
subsystem: infra
tags: [nestjs, tauri, gitea-actions, streamable-file, desktop-distribution]
# Dependency graph
requires:
- phase: 06-desktop-client-ci-cd
provides: Tauri-Grundgeruest (apps/desktop, AppImage+NSIS-Bundle-Ziele, Tray, Setup-Seite)
provides:
- .gitea/scripts/desktop-collect.sh (Pakete einsammeln, manifest.json schreiben)
- .gitea/scripts/desktop-version.sh (Version aus dem Freigabe-Tag schreiben)
- apps/api/src/desktop/ (GET /desktop/latest, GET /desktop/download/:platform, beide @Public())
- packages/shared DesktopPlatform/DesktopManifest(File)/DesktopLatest(Response) Typen
- apps/api/Dockerfile mit COPY desktop-dist
- Basislinie 1.1.0 in tauri.conf.json/Cargo.toml/Cargo.lock/package.json
affects: [18-02-ci-pipeline-release-assets, 18-03-web-oberflaeche, 18-04-client-updateprüfung]
actuals:
tokens: 6718
tasks: 2
commits: 2
plan_head_before: 0e4eb9b
tech-stack:
added: []
patterns:
- "NestJS StreamableFile fuer grosse Downloads statt res.send(buffer) (Installer-Groessenordnung)"
- "Manifest-getriebene Dateiauswahl: Dateiname kommt ausschliesslich aus manifest.json, nie aus dem Request-Pfad (Whitelist vor Dateisystemzugriff)"
- "HTTP-Durchstich-Spec ueber NestFactory.create() + app.listen(0) statt fs-Mocks fuer datei-lesende Module"
key-files:
created:
- .gitea/scripts/desktop-collect.sh
- .gitea/scripts/desktop-version.sh
- apps/api/src/desktop/desktop.module.ts
- apps/api/src/desktop/desktop.controller.ts
- apps/api/src/desktop/desktop.service.ts
- apps/api/src/desktop/desktop.service.spec.ts
- desktop-dist/.gitkeep
modified:
- .gitignore
- apps/api/Dockerfile
- apps/api/src/app.module.ts
- packages/shared/src/index.ts
- apps/desktop/package.json
- apps/desktop/src-tauri/Cargo.toml
- apps/desktop/src-tauri/Cargo.lock
- apps/desktop/src-tauri/tauri.conf.json
key-decisions:
- "DesktopController braucht @Inject(DesktopService) explizit auf dem Konstruktor-Parameter — Vitest transpiliert ueber esbuild, das emitDecoratorMetadata nicht abbildet; ohne den expliziten Token bleibt desktopService bei einem echten NestFactory-Bau (der HTTP-Durchstich-Test) undefined, obwohl derselbe Code unter tsc (nest build) korrekt aufgeloest wuerde."
- "Lokaler Stack am Ende beider Tasks zweimal neu gebaut (einmal je Task) statt nur einmal am Schluss, damit jede Verify-Stufe gegen den tatsaechlich damals gueltigen desktop-dist-Inhalt prueft und der Stack in einem konsistenten 1.1.0-Endzustand stehen bleibt."
patterns-established:
- "PLATFORMS-Konstante (geschlossener Wertevorrat) vor jedem Dateisystemzugriff pruefen, danach erst das Manifest lesen — Reihenfolge ist die Sicherheitseigenschaft (T-18-01)."
requirements-completed: [DESK-01, DESK-03, DESK-05]
coverage:
- id: D1
description: "GET /desktop/latest liefert Version/Kanal/Dateiliste aus manifest.json (200) oder 404 ohne Manifest"
requirement: "DESK-03"
verification:
- kind: integration
ref: "apps/api/src/desktop/desktop.service.spec.ts#Test 1 (latest, Manifest vorhanden)"
status: pass
- kind: integration
ref: "apps/api/src/desktop/desktop.service.spec.ts#Test 2 (getLatest ohne Manifest)"
status: pass
- kind: other
ref: "curl -sf http://localhost:3001/desktop/latest (lokaler Docker-Stack, neu gebautes Abbild)"
status: pass
human_judgment: false
- id: D2
description: "GET /desktop/download/:platform streamt die Datei mit attachment-Header, Whitelist vor Dateisystemzugriff, Traversal/unbekannte Plattform enden mit 400, fehlende Pakete/Namen mit 404"
requirement: "DESK-03"
verification:
- kind: integration
ref: "apps/api/src/desktop/desktop.service.spec.ts#Test 3 (download/linux)"
status: pass
- kind: integration
ref: "apps/api/src/desktop/desktop.service.spec.ts#Test 4 (Plattform-Whitelist + Traversal ueber HTTP)"
status: pass
- kind: integration
ref: "apps/api/src/desktop/desktop.service.spec.ts#Test 5 (Whitelist vor Dateisystem)"
status: pass
- kind: integration
ref: "apps/api/src/desktop/desktop.service.spec.ts#Test 6 (Manifest nur mit windows)"
status: pass
- kind: integration
ref: "apps/api/src/desktop/desktop.service.spec.ts#Test 7 (manipulierter Name im Manifest)"
status: pass
- kind: other
ref: "curl -sI http://localhost:3001/desktop/download/linux (lokaler Docker-Stack)"
status: pass
human_judgment: false
- id: D3
description: "Beide Routen tragen @Public() (kein Anmelde-Zwang)"
requirement: "DESK-03"
verification:
- kind: unit
ref: "apps/api/src/desktop/desktop.service.spec.ts#Test 8 (bewusst oeffentlich)"
status: pass
human_judgment: false
- id: D4
description: "desktop-collect.sh sammelt das Tauri-AppImage ein, benennt es kanonisch um und schreibt manifest.json mit korrekter Groesse/SHA-256"
requirement: "DESK-01"
verification:
- kind: other
ref: "sh .gitea/scripts/desktop-collect.sh --require linux + sha256sum-Vergleich gegen manifest.json (zweimal ausgefuehrt: 0.0.1 und 1.1.0)"
status: pass
human_judgment: false
- id: D5
description: "desktop-version.sh schreibt die reine X.Y.Z-Version des letzten Freigabe-Tags in tauri.conf.json/Cargo.toml, verweigert Vorab-/Metadatenformen"
requirement: "DESK-05"
verification:
- kind: other
ref: "sh .gitea/scripts/desktop-version.sh --print + Negativproben (v1.2.3-beta abgelehnt, v2.0.0 akzeptiert-aber-ungeschrieben)"
status: pass
human_judgment: false
- id: D6
description: "Basislinie 1.1.0 in allen vier Client-Dateien eingecheckt, cargo check bleibt gruen"
requirement: "DESK-05"
verification:
- kind: other
ref: "cargo check (apps/desktop/src-tauri) -> Finished"
status: pass
human_judgment: false
duration: 13min
completed: 2026-09-16
status: complete
---
# Phase 18 Plan 01: Desktop-Paket-Durchstich (Skript -> Abbild -> API) Summary
**Linux-AppImage aus dem Tauri-Bau wird per neuem `.gitea/scripts/desktop-collect.sh` unter kanonischem Namen samt `manifest.json` eingesammelt, vom neu gebauten API-Abbild (`apps/api/src/desktop/`) ohne Anmeldung ausgeliefert (`GET /desktop/latest`, `GET /desktop/download/linux`), und die Client-Version stammt ab sofort aus dem Freigabe-Tag (`desktop-version.sh`, Basislinie 1.1.0).**
## Performance
- **Duration:** 13 min
- **Started:** 2026-09-16T13:58:00Z (geschaetzt)
- **Completed:** 2026-09-16T14:11:25Z
- **Tasks:** 2
- **Files modified:** 15
## Accomplishments
- Neues API-Modul `apps/api/src/desktop/` mit `GET /desktop/latest` (200 mit Version/Kanal/Dateiliste, 404 ohne Manifest) und `GET /desktop/download/:platform` (Stream mit `Content-Disposition: attachment`, Plattform-Whitelist vor jedem Dateisystemzugriff, Traversal/unbekannte Plattform -> 400, fehlende Pakete/manipulierte Namen -> 404) — 8 gruene Spec-Tests via echtem HTTP-Durchstich (`NestFactory.create` + `app.listen(0)`, kein `fs`-Mock).
- `.gitea/scripts/desktop-collect.sh` sammelt das gebaute AppImage ein, benennt es kanonisch (`Tessera-{Version}{Suffix}.AppImage`) und schreibt `manifest.json` (Version, Kanal, Commit, Groesse, SHA-256) — Kanalmodell identisch zu `publish-images.sh` (main=beta, Tag=live, sonst dev); Windows-Zweig bereits angelegt, aber in diesem Plan noch nicht gefordert (kommt in 18-05).
- `.gitea/scripts/desktop-version.sh` schreibt die reine `X.Y.Z`-Version des letzten Freigabe-Tags in `tauri.conf.json`/`Cargo.toml`, verweigert jede Vorab-/Metadatenform (Pitfall 2 — NSIS-Ressourcen sind rein numerisch); Basislinie `1.1.0` (aktueller Tag `v1.1.0`) in allen vier Client-Dateien eingecheckt, `cargo check` bleibt gruen.
- Lokaler Durchstich zweimal bewiesen: einmal mit dem Phase-6-AppImage (Version 0.0.1) fuer Task 1, einmal mit einem frisch gebauten AppImage (Version 1.1.0, Task 2) — beide Male liefert das neu gebaute API-Abbild die Datei ueber `/desktop/download/linux` und `/api-proxy/desktop/latest` (Web-Container) korrekt aus. Der lokale Stack steht am Ende auf der finalen 1.1.0-Baseline.
## Task Commits
Each task was committed atomically:
1. **Task 1: Ein Linux-Paket aus dem Bau bis zum Download aus der API — eine Strecke** - `ae8fecb` (feat)
2. **Task 2: Die Version kommt aus dem Freigabe-Tag — Skript und Basislinie 1.1.0** - `614289a` (feat)
**Plan metadata:** commit pending (this SUMMARY + STATE.md/ROADMAP.md/REQUIREMENTS.md)
## Files Created/Modified
- `.gitea/scripts/desktop-collect.sh` - Pakete einsammeln, umbenennen, `manifest.json` schreiben (Kanalmodell, `--require linux[,windows]`)
- `.gitea/scripts/desktop-version.sh` - Version aus dem letzten Freigabe-Tag in `tauri.conf.json`/`Cargo.toml` schreiben, `--print`-Option
- `.gitignore` - `desktop-dist/*` ignoriert, `!desktop-dist/.gitkeep` als versionierter Platzhalter
- `apps/api/Dockerfile` - `COPY desktop-dist ./desktop-dist` vor `USER nestjs`
- `apps/api/src/app.module.ts` - `DesktopModule` registriert (hinter `HealthModule`)
- `apps/api/src/desktop/desktop.module.ts` - Modul-Verdrahtung (Vorbild `health.module.ts`)
- `apps/api/src/desktop/desktop.controller.ts` - `GET /desktop/latest`, `GET /desktop/download/:platform`, beide `@Public()`, `@Inject(DesktopService)` explizit
- `apps/api/src/desktop/desktop.service.ts` - Manifest lesen, `PLATFORMS`-Whitelist, Datei-Stream, 6-stufige Sicherheitspruefung in `getPackage()`
- `apps/api/src/desktop/desktop.service.spec.ts` - HTTP-Durchstich-Spec (8 Tests, echtes Temp-Verzeichnis, unabhaengig berechneter SHA-256)
- `packages/shared/src/index.ts` - `DesktopPlatform`, `DesktopManifestFile`, `DesktopManifest`, `DesktopLatestFile`, `DesktopLatestResponse`
- `desktop-dist/.gitkeep` - Platzhalter, damit `docker build` auch ohne CI-Pakete funktioniert
- `apps/desktop/package.json`, `apps/desktop/src-tauri/tauri.conf.json`, `apps/desktop/src-tauri/Cargo.toml`, `apps/desktop/src-tauri/Cargo.lock` - Basislinie `1.1.0`
## Decisions Made
- `@Inject(DesktopService)` explizit auf dem Controller-Konstruktor gesetzt, weil Vitest ueber esbuild transpiliert (kein `emitDecoratorMetadata`) — ohne den expliziten Token bleibt die Abhaengigkeit im echten `NestFactory.create()`-Durchstich `undefined`, obwohl `nest build` (tsc) denselben Code ohne `@Inject()` korrekt aufloest. Kein Verhaltensunterschied im Produktionsbau, nur eine Testinfrastruktur-Notwendigkeit fuer den in RESEARCH/PATTERNS vorgeschlagenen echten HTTP-Durchstich ohne `fs`-Mocks.
- Lokaler Docker-Stack (API-Container) wurde zweimal neu gebaut — einmal je Task — statt nur am Ende, damit jede der drei automatisierten `<verify>`-Stufen tatsaechlich gegen den zu diesem Zeitpunkt gueltigen `desktop-dist`-Inhalt prueft, und der Stack am Ende in einem konsistenten 1.1.0-Zustand stehen bleibt (nicht mit der Task-1-Zwischenversion 0.0.1).
- Testobjekt fuer Task 1: das bereits vorhandene Phase-6-AppImage (`Tessera_0.0.1_amd64.AppImage`, 106.461.688 Bytes, SHA-256 `ea5e1ef5...`) wurde direkt verwendet, wie im Plan als zulaessige Abkuerzung vorgesehen ("Liegt ... noch das AppImage aus Phase 6, reicht es fuer diesen Durchstich"). Fuer Task 2 war ein frischer Bau mit der neuen Version 1.1.0 zwingend (Basislinie-Nachweis).
## AppImage-Baudaten (Auftrag des Output-Abschnitts)
- **Task 1 (Testobjekt Phase-6-AppImage, kein frischer Bau):** `Tessera_0.0.1_amd64.AppImage`, 106.461.688 Bytes, SHA-256 `ea5e1ef56c282009ab8c20adbf84dbdb8b3fc29e777884817d50c7ad44bfb0ec` (Build-Datum 25. Juni, aus einer fruaheren Sitzung — nicht in dieser Sitzung neu gebaut).
- **Task 2 (frischer Bau mit Basislinie 1.1.0):** `Tessera_1.1.0_amd64.AppImage`, 106.928.632 Bytes, SHA-256 `da38fd89ced60c91e4a32929f43dfdc1435efdc8b668cddb2f47c9348010fcb4`. `pnpm --filter @tessera/desktop exec tauri build --bundles appimage` lief bei warmem `target/`-Verzeichnis (nach Entfernen des alten `bundle/`-Ordners) — Rust-Kompilierung 40,99 s laut `cargo`-Ausgabe, Gesamtlauf (inkl. Bundling) rund 2,5 Minuten Wanduhrzeit (14:06:56Z Start bis 14:09:39Z Manifest-Buildzeit).
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 3 - Blocking] `@Inject(DesktopService)` noetig fuer den HTTP-Durchstich-Test unter Vitest**
- **Found during:** Task 1 (erster Testlauf von `desktop.service.spec.ts`)
- **Issue:** Alle 5 HTTP-abhaengigen Tests scheiterten mit 500 ("Cannot read properties of undefined (reading 'getLatest')"). Ursache: Vitest transpiliert `.ts`-Dateien ueber esbuild, das `emitDecoratorMetadata` (TypeScript-Compiler-Feature) nicht abbildet — NestJS' automatische Konstruktor-Injection stuetzt sich normalerweise auf die von `tsc` erzeugten `design:paramtypes`-Metadaten, die unter esbuild fehlen. Ein echter `NestFactory.create()`-Bau (wie ihn RESEARCH/PATTERNS fuer den fs-mock-freien Test vorschlagen) konnte `DesktopService` deshalb nicht automatisch in `DesktopController` injizieren.
- **Fix:** Expliziten Injection-Token per `@Inject(DesktopService)` auf dem Konstruktor-Parameter ergaenzt — das macht die Abhaengigkeit unabhaengig von `design:paramtypes` explizit und funktioniert sowohl unter Vitest/esbuild als auch im echten `nest build` (tsc) unveraendert.
- **Files modified:** `apps/api/src/desktop/desktop.controller.ts`
- **Verification:** Alle 8 Spec-Tests gruen nach der Aenderung (`pnpm --filter @tessera/api exec vitest run src/desktop`).
- **Committed in:** `ae8fecb` (Task 1 commit)
---
**Total deviations:** 1 auto-fixed (1 blocking)
**Impact on plan:** Notwendig, um den vom Plan geforderten fs-mock-freien HTTP-Durchstich-Test ueberhaupt lauffaehig zu machen. Keine Verhaltensaenderung im Produktionscode, keine Ausweitung des Umfangs.
## Issues Encountered
None.
## User Setup Required
None - no external service configuration required.
## Next Phase Readiness
- Die duenne Strecke Skript -> Abbild -> API ist bewiesen; 18-02 (CI-Pipeline, `desktop`-Job, `publish-release.sh`-Erweiterung) kann direkt auf `desktop-collect.sh`/`desktop-version.sh` und dem API-Modul aufbauen.
- `packages/shared`-Typen (`DesktopLatestResponse` etc.) stehen fuer 18-03 (Web-Oberflaeche) und 18-04 (Client-Versionspruefung) bereit.
- Kein Blocker. Der Windows-Cross-Bau (cargo-xwin, NSIS) ist NICHT Teil dieses Plans — `desktop-collect.sh` hat den Windows-Zweig bereits vorbereitet (ungetestet), 18-05 baut ihn aus und beweist ihn in der Pipeline.
---
*Phase: 18-desktop-client-fertigstellen*
*Completed: 2026-09-16*
## Self-Check: PASSED
All created files verified on disk (`.gitea/scripts/desktop-collect.sh`, `.gitea/scripts/desktop-version.sh`, `apps/api/src/desktop/{desktop.module.ts,desktop.controller.ts,desktop.service.ts,desktop.service.spec.ts}`, `desktop-dist/.gitkeep`). All three task/plan commits found in `git log` (`ae8fecb`, `614289a`, plus this SUMMARY's own commit). All plan-level `<verification>` items re-run and passing: `pnpm --filter @tessera/api exec vitest run src/desktop` (8/8 green), `pnpm --filter @tessera/api type-check` (clean), `desktop-dist/manifest.json` at `1.1.0`/`Tessera-1.1.0.AppImage` with matching SHA-256, local Docker stack serving `/desktop/latest` and `/desktop/download/linux` (also via `/api-proxy/`), both new scripts pass `sh -n`, `desktop-version.sh` rejects a pre-release tag.
@@ -0,0 +1,290 @@
---
phase: 18-desktop-client-fertigstellen
plan: 02
type: execute
wave: 2
depends_on: ["18-01"]
files_modified:
- .gitea/workflows/ci.yml
- .gitea/scripts/publish-images.sh
- .gitea/scripts/publish-release.sh
autonomous: true
requirements: [DESK-01, DESK-04]
user_setup: []
estimate:
tokens: 45000
raw_tokens: 45000
tasks: 2
confidence: low
must_haves:
truths:
- "Der CI-Job desktop laeuft nach test auf main und bei Tags v*, baut das Linux-AppImage mit der Tag-Version und uebergibt desktop-dist/ per actions/cache an publish (D-06, D-07)."
- "publish bricht hart ab, wenn das Manifest aus dem Zwischenspeicher fehlt — nie ein Abbild ohne Pakete (D-08, Pitfall 1)."
- "publish-release.sh haengt bei Tags jede Datei aus dem Manifest idempotent als Release-Datei an den Gitea-Release; das Token verlaesst nie die Header-Datei (D-01, D-08)."
artifacts:
- path: ".gitea/workflows/ci.yml"
provides: "Job desktop (Linux-AppImage) und Uebergabe an publish per actions/cache"
contains: "desktop-dist-${{ gitea.sha }}"
- path: ".gitea/scripts/publish-images.sh"
provides: "Harte Pruefung auf desktop-dist/manifest.json vor dem Docker-Bau"
contains: "manifest.json"
- path: ".gitea/scripts/publish-release.sh"
provides: "Idempotenter Upload der Release-Dateien (GET assets, DELETE, POST multipart)"
contains: "upload_asset"
key_links:
- from: ".gitea/workflows/ci.yml (desktop)"
to: ".gitea/workflows/ci.yml (publish)"
via: "actions/cache/save + actions/cache/restore mit Schluessel desktop-dist-${{ gitea.sha }}, fail-on-cache-miss: true"
pattern: "fail-on-cache-miss"
- from: ".gitea/workflows/ci.yml (desktop)"
to: ".gitea/scripts/desktop-version.sh + desktop-collect.sh"
via: "Schritte 'Version setzen' und 'Pakete einsammeln'"
pattern: "desktop-collect.sh --require linux"
- from: ".gitea/scripts/publish-release.sh"
to: "desktop-dist/manifest.json"
via: "jq -r '.files[].name' — nur Dateien aus dem Manifest werden hochgeladen"
pattern: "files\\[\\]"
---
<objective>
Die in 18-01 lokal bewiesene Strecke wird in die Pipeline gehoben: ein neuer
Job `desktop` baut auf `main` und bei Tags `v*` das Linux-AppImage mit der
Tag-Version, sammelt es mit Manifest ein und uebergibt `desktop-dist/` per
`actions/cache` an `publish`, das ohne Manifest hart abbricht und die Pakete
ins API-Abbild kopiert. Bei Tags haengt `publish-release.sh` jede Datei aus
dem Manifest an den Gitea-Release. Der Windows-Cross-Bau kommt in 18-05 in
denselben Job; der echte Pipeline-Lauf wird dort mit beiden Dateien bewiesen.
Purpose: D-06, D-08 (Pipeline-Seite) und D-01 (Release-Dateien) aus
18-CONTEXT.md; Erfolgskriterium 1 (Linux-Haelfte und Release-Anhang).
Output: Job `desktop`, angepasster Job `publish`, Manifest-Pruefung in
`publish-images.sh`, Funktion `upload_asset` in `publish-release.sh`.
**Externe Schnittstellen (Gitea REST):** siehe `18-COVERAGE.md` — neu sind
`GET …/releases/{id}/assets`, `DELETE …/releases/{id}/assets/{asset_id}` und
`POST …/releases/{id}/assets?name=` (multipart-Feld `attachment`); Gitea
1.26.2 laesst Release-Anhaenge standardmaessig ohne Typ-Beschraenkung bis
2048 MB zu.
</objective>
## Artifacts this phase produces
Dieser Plan: `.gitea/workflows/ci.yml` (Job `desktop`: Schritte
"Systemabhaengigkeiten", "Rust-Toolchain", "Cargo-Zwischenspeicher", "Version
setzen", "Rust pruefen", "Alte Bundles entfernen", "Linux-AppImage bauen",
"Pakete einsammeln", "Uebergabe an publish"; Job `publish`: "Desktop-Pakete
aus dem Zwischenspeicher holen", "Pakete pruefen"),
`.gitea/scripts/publish-images.sh` (Manifest-Pruefung),
`.gitea/scripts/publish-release.sh` (`HDR_AUTH`, `upload_asset`,
`DESKTOP_DIST`). Gesamtliste der Phase: siehe 18-01-PLAN.md.
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/18-desktop-client-fertigstellen/18-CONTEXT.md
@.planning/phases/18-desktop-client-fertigstellen/18-RESEARCH.md
@.planning/phases/18-desktop-client-fertigstellen/18-COVERAGE.md
@.planning/phases/18-desktop-client-fertigstellen/18-01-SUMMARY.md
@.gitea/workflows/ci.yml
@.gitea/scripts/publish-images.sh
@.gitea/scripts/publish-release.sh
@.gitea/scripts/desktop-collect.sh
@.gitea/scripts/desktop-version.sh
</context>
<tasks>
<task type="auto">
<name>Task 1: Job desktop (Linux-AppImage) und Uebergabe an publish per actions/cache</name>
<reversibility rating="reversible">Job-Aufbau und Cache-Schluessel lassen sich jederzeit aendern; kein Zustand ausserhalb des Runners.</reversibility>
<files>
.gitea/workflows/ci.yml,
.gitea/scripts/publish-images.sh
</files>
<read_first>
.gitea/workflows/ci.yml,
.gitea/scripts/publish-images.sh,
.gitea/scripts/desktop-collect.sh (Optionen und Ausgabe, aus 18-01),
.planning/phases/18-desktop-client-fertigstellen/18-RESEARCH.md (Abschnitte "Code Examples 2", "Common Pitfalls 1 und 5", "Standard Stack: Installation"),
docs/ci-cd-setup.md (Abschnitt 4 "Pipeline-Ueberblick")
</read_first>
<action>
**Job `desktop` in `.gitea/workflows/ci.yml`** zwischen `test` und `publish`
einfuegen, Kopfkommentar der Datei um einen Satz zu Phase 18 ergaenzen.
`name: Desktop-Pakete bauen`, `runs-on: ubuntu-latest`, `needs: test`,
`if: gitea.ref == 'refs/heads/main' || startsWith(gitea.ref, 'refs/tags/v')`
(D-06). Schritte in dieser Reihenfolge, deutsche Schrittnamen wie im Rest der
Datei: `actions/checkout@v4` mit `fetch-depth: 0` (Tags fuer `git describe`);
`actions/setup-node@v4` (Node 24); corepack/pnpm wie in `test`;
`pnpm install --frozen-lockfile`; "Systemabhaengigkeiten":
`sudo apt-get update` und `sudo apt-get install -y --no-install-recommends`
mit **vollstaendiger** Liste `libwebkit2gtk-4.1-dev libjavascriptcoregtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev libgtk-3-dev libssl-dev patchelf file xdg-utils`
(Pitfall 5 — das Runner-Abbild hat davon nur `librsvg2-dev` und `file`;
alle Paketnamen wurden am 2026-09-16 per `apt-cache policy` im Abbild
`gitea/runner-images:ubuntu-latest` bestaetigt, ebenso `sudo`, `jq`, `curl`
und `git`); "Rust-Toolchain": `curl -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable`
und danach `echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"` (kein Rust im
Runner-Abbild; bewusst kein Fremd-Action, gleiche Zurueckhaltung wie beim
Verzicht auf die Artefakt-Aktionen); "Cargo-Zwischenspeicher": `actions/cache@v4`
mit `path` `~/.cargo/registry`, `~/.cargo/git`, `~/.cache/tauri`,
`apps/desktop/src-tauri/target`, `key: desktop-cargo-${{ hashFiles('apps/desktop/src-tauri/Cargo.lock') }}`,
`restore-keys: desktop-cargo-` (der Cache-Server des Runners ist laut
RESEARCH aktiv: `172.18.0.1:42641`); "Version setzen":
`sh .gitea/scripts/desktop-version.sh`; "Rust pruefen":
`cargo check` und `cargo clippy` mit `working-directory: apps/desktop/src-tauri`
(D-16; Clippy ohne `-D warnings`, Fehler brechen ab, Warnungen nicht);
"Alte Bundles entfernen": `rm -rf apps/desktop/src-tauri/target/release/bundle`
(ein aus dem Cache wiederhergestelltes altes AppImage wuerde sonst neben dem
neuen liegen und das Sammel-Skript zu Recht abbrechen); "Linux-AppImage
bauen": `pnpm --filter @tessera/desktop exec tauri build --bundles appimage`;
"Pakete einsammeln": `sh .gitea/scripts/desktop-collect.sh --require linux`
(18-05 erweitert auf `linux,windows`); "Uebergabe an publish":
`actions/cache/save@v4` mit `path: desktop-dist` und
`key: desktop-dist-${{ gitea.sha }}` (Pitfall 1: bewusst **nicht** die
Artefakt-Aktionen von GitHub — auf dieser Gitea-Instanz dokumentiert
unzuverlaessig; im Workflow-Kommentar ebenfalls nur so umschreiben, damit
das Negativ-Tor in `<verify>` nicht am Kommentartext scheitert).
**Job `publish` anpassen:** `needs: desktop` statt `needs: test`. Nach dem
Checkout und vor dem Registry-Login zwei Schritte: "Desktop-Pakete aus dem
Zwischenspeicher holen" mit `actions/cache/restore@v4`, `path: desktop-dist`,
`key: desktop-dist-${{ gitea.sha }}`, `fail-on-cache-miss: true`; "Pakete
pruefen": `test -f desktop-dist/manifest.json` und `jq . desktop-dist/manifest.json`
(harter Abbruch, nie stillschweigend ein Abbild ohne Pakete). Der Schritt mit
`publish-release.sh` bleibt; die Pakete liegen fuer ihn unter `desktop-dist/`.
**`publish-images.sh`:** Im echten Bau-Pfad (nicht bei `--print-plan`) vor
der Schleife pruefen, dass `desktop-dist/manifest.json` existiert, sonst
Exit 1 mit Meldung — zweites Netz gegen Pitfall 1. Kopfkommentar um einen
Absatz ergaenzen (Phase 18: die Pakete kommen aus dem Job `desktop`, das
Dockerfile der API kopiert `desktop-dist/`). Weiterhin kein Secret.
</action>
<acceptance_criteria>
- `grep -c '^ desktop:$' .gitea/workflows/ci.yml` ergibt 1; `grep -c 'needs: desktop' .gitea/workflows/ci.yml` ergibt 1; `grep -c 'fail-on-cache-miss: true' .gitea/workflows/ci.yml` ergibt 1; `grep -c 'desktop-dist-${{ gitea.sha }}' .gitea/workflows/ci.yml` ergibt 2 (save und restore).
- `grep -c 'upload-artifact' .gitea/workflows/ci.yml` ergibt 0.
- `grep -c 'libwebkit2gtk-4.1-dev' .gitea/workflows/ci.yml` ergibt mindestens 1; `grep -c 'desktop-collect.sh --require linux' .gitea/workflows/ci.yml` ergibt 1; `grep -c 'desktop-version.sh' .gitea/workflows/ci.yml` ergibt 1.
- `sh -n .gitea/scripts/publish-images.sh` endet mit 0; `GITHUB_REF=refs/tags/v1.1.0 sh .gitea/scripts/publish-images.sh --print-plan` gibt weiterhin die vier `push`-Zeilen aus (Probelauf braucht kein Manifest).
- `grep -c 'manifest.json' .gitea/scripts/publish-images.sh` ergibt mindestens 1.
</acceptance_criteria>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl && test "$(grep -c 'desktop-dist-${{ gitea.sha }}' .gitea/workflows/ci.yml)" = "2" && grep -q 'fail-on-cache-miss: true' .gitea/workflows/ci.yml && grep -q 'needs: desktop' .gitea/workflows/ci.yml && test "$(grep -c 'upload-artifact' .gitea/workflows/ci.yml)" = "0" && grep -q 'desktop-collect.sh --require linux' .gitea/workflows/ci.yml && grep -q 'desktop-version.sh' .gitea/workflows/ci.yml && node -e "const y=require('fs').readFileSync('.gitea/workflows/ci.yml','utf8');if(!/^ desktop:\n/m.test(y)||!/^ publish:\n/m.test(y))process.exit(1)" && echo CI-OK</automated>
<fails_when>Cache-Schluessel nicht genau zweimal, Restore ohne harten Abbruch, publish haengt nicht an desktop, ein upload-artifact-Schritt ist vorhanden, Skript-Schritte fehlen, oder die Job-Schluessel fehlen — `CI-OK` fehlt.</fails_when>
<automated>cd /home/vicolab/projects/tessera-ctl && sh -n .gitea/scripts/publish-images.sh && GITHUB_REF=refs/tags/v1.1.0 sh .gitea/scripts/publish-images.sh --print-plan | grep -c '^push ' | grep -qx 4 && grep -q 'manifest.json' .gitea/scripts/publish-images.sh && echo IMAGES-OK</automated>
<fails_when>Syntaxfehler, weniger als vier push-Zeilen im Probelauf, oder die Manifest-Pruefung fehlt im Skript — `IMAGES-OK` fehlt.</fails_when>
</verify>
<done>
Der Workflow enthaelt den Job `desktop` (Linux-AppImage mit Tag-Version,
Cache, Uebergabe per `actions/cache`), `publish` haengt daran und bricht
ohne Manifest ab; `publish-images.sh` prueft das Manifest ebenfalls.
</done>
</task>
<task type="auto">
<name>Task 2: Release-Dateien idempotent an den Gitea-Release haengen</name>
<precondition>Das Gitea-Secret `REGISTRY_TOKEN` traegt `repository: write` (damit wurde am 2026-09-16 der Release v1.1.0 aus der Pipeline angelegt); es wird unveraendert weiterverwendet. Lokal liegt `desktop-dist/manifest.json` aus 18-01 vor (fuer den Probelauf).</precondition>
<files>
.gitea/scripts/publish-release.sh
</files>
<read_first>
.gitea/scripts/publish-release.sh (gesamt — Idempotenz-Muster GET -> case -> PATCH/POST, Header-Datei-Mechanik ab Zeile 117),
.planning/phases/18-desktop-client-fertigstellen/18-RESEARCH.md (Abschnitte "Code Examples 6", "Don't Hand-Roll", "Security Domain"),
.planning/phases/18-desktop-client-fertigstellen/18-COVERAGE.md,
desktop-dist/manifest.json (Form der `files`-Eintraege)
</read_first>
<action>
Kopfkommentar um Umgebung `DESKTOP_DIST` (Vorgabe `desktop-dist`) und die
drei neuen Endpunkte ergaenzen. Neben `$HDR` (mit JSON-Content-Type) eine
zweite Header-Datei `$HDR_AUTH` anlegen, die **nur** die
`Authorization`-Zeile traegt — beim multipart-Upload darf kein
`Content-Type: application/json` mitgehen; gleiche `umask 077`/`mktemp`/
`trap`-Mechanik, Token nie als Argument (T-18-03). Funktion
`upload_asset FILE NAME RELEASE_ID` nach dem Muster GET -> Entscheidung per
HTTP-Code -> Aktion: `GET $RELEASES_URL/$ID/assets` (200 erwartet), per
`jq -r --arg n "$NAME" '.[] | select(.name == $n) | .id'` vorhandene Datei
gleichen Namens ermitteln und mit `DELETE $RELEASES_URL/$ID/assets/$ASSET_ID`
entfernen (204 erwartet), dann
`curl -sS --header @"$HDR_AUTH" -X POST -F "attachment=@$FILE;filename=$NAME" -o "$RESP" -w '%{http_code}' "$RELEASES_URL/$ID/assets?name=$NAME"`
(201 erwartet; jeder andere Code: Meldung mit Code und Antwort nach stderr,
Exit 1). Aufruf nach dem bestehenden `case`-Block (Release angelegt oder
aktualisiert; `ID` aus beiden Zweigen verfuegbar machen): Manifest
`$DESKTOP_DIST/manifest.json` muss existieren, sonst Exit 1 (Release-Text ist
dann schon da, der Job wird sichtbar rot); fuer jeden Namen aus
`jq -r '.files[].name'` `upload_asset "$DESKTOP_DIST/$NAME" "$NAME" "$ID"`,
danach je Datei `Release-Datei $NAME hochgeladen`. `--dry-run` listet
zusaetzlich die geplanten Uploads (`POST $RELEASES_URL/{id}/assets?name=…`)
aus dem Manifest, falls es vorhanden ist.
Bekannter Fallstrick fuer 18-05: Der Job-Container erreicht Gitea ueber
`https://git.vicolab.de` hinter dem Nginx Proxy Manager; das AppImage ist
rund 106 MB — falls der Proxy den Upload abweist (413), kann `GITEA_API` im
Workflow-Schritt auf die Host-Adresse `http://172.18.0.1:3002/api/v1` gesetzt
werden (gleiche Route, ueber die der Runner seinen Cache-Server erreicht).
Das wird erst im CI-Lauf entschieden, nicht hier. Der Upload-Pfad selbst
laeuft erst beim naechsten Freigabe-Tag (ein Test-Tag wuerde den Live-Kanal
ausloesen) — deshalb ist der Probelauf mit `--dry-run` hier das Tor.
</action>
<acceptance_criteria>
- `sh -n .gitea/scripts/publish-release.sh` endet mit 0.
- `sh .gitea/scripts/publish-release.sh --dry-run --tag v1.1.0` gibt eine Zeile mit `assets?name=Tessera-1.1.0.AppImage` aus (Manifest aus 18-01 vorhanden) und endet mit 0; ohne Token, ohne Netzaufruf.
- `grep -c 'HDR_AUTH' .gitea/scripts/publish-release.sh` ergibt mindestens 3 (Anlegen, Schreiben, Verwendung); `grep -c '^upload_asset()' .gitea/scripts/publish-release.sh` ergibt 1.
- `grep -c "files\[\].name" .gitea/scripts/publish-release.sh` ergibt mindestens 1.
- Das Token wird nirgends als Argument uebergeben: `grep -c 'token %s' .gitea/scripts/publish-release.sh` ergibt genau 1 (die bestehende printf-Zeile in die Header-Datei) oder 2 (zweite Header-Datei), nie in einer `curl`-Zeile.
</acceptance_criteria>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl && sh -n .gitea/scripts/publish-release.sh && sh .gitea/scripts/publish-release.sh --dry-run --tag v1.1.0 | grep -q 'assets?name=Tessera-1.1.0.AppImage' && test "$(grep -c 'HDR_AUTH' .gitea/scripts/publish-release.sh)" -ge 3 && grep -q '^upload_asset()' .gitea/scripts/publish-release.sh && grep -q 'files\[\].name' .gitea/scripts/publish-release.sh && test "$(grep -c 'curl.*GITEA_TOKEN' .gitea/scripts/publish-release.sh)" = "0" && echo RELEASE-OK</automated>
<fails_when>Syntaxfehler, der Probelauf nennt den AppImage-Upload nicht, die zweite Header-Datei oder die Funktion fehlt, die Dateinamen kommen nicht aus dem Manifest, oder das Token steht in einer curl-Zeile — `RELEASE-OK` fehlt.</fails_when>
</verify>
<done>
Das Release-Skript laedt alle Manifest-Dateien idempotent hoch (vorhandene
Datei gleichen Namens wird ersetzt), das Token bleibt in Header-Dateien, der
Probelauf nennt die geplanten Uploads. Der echte Pipeline-Beweis folgt in
18-05 (gemeinsam mit Windows), der Release-Anhang beim naechsten Freigabe-Tag.
</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| CI-Runner -> Gitea-API (Release-Dateien) | Ausgehender Aufruf mit dem Zugriffstoken `REGISTRY_TOKEN`. |
| Runner -> Cache-Server (`actions/cache`) | Uebergabe der Pakete zwischen zwei Jobs desselben Laufs. |
| Runner -> Internet (rustup, crates.io, Tauri-Werkzeuge) | Der Job laedt Werkzeuge aus dem Netz. |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-18-03 | Information Disclosure | `publish-release.sh` (Token) | high | mitigate | Token nur aus der Umgebung, nie als Argument, nur ueber Header-Dateien mit `umask 077`; keine Ausgabe des Tokens; zweite Header-Datei ohne JSON-Content-Type fuer multipart. Gate: keine `curl`-Zeile enthaelt `GITEA_TOKEN`. |
| T-18-06 | Tampering | `publish` ohne Pakete (Cache-Fehlschlag) | medium | mitigate | `fail-on-cache-miss: true` plus expliziter `test -f desktop-dist/manifest.json` im Workflow und in `publish-images.sh`. |
| T-18-21 | Tampering | Cache-Uebergabe zwischen Jobs (`desktop-dist-{sha}`) | low | accept | Cache-Server nur lokal fuer diesen Runner (`172.18.0.1`), Schluessel exakt am Commit-SHA, keine `restore-keys`-Fallbacks fuer die Uebergabe. |
| T-18-SC | Tampering | Paketinstallationen (`actions/cache@v4`, `actions/checkout@v4`, `actions/setup-node@v4`; Rust-Toolchain per rustup) | low | mitigate | Nur GitHub-eigene Actions in der bereits genutzten Major-Version; rustup-Installer von der offiziellen Adresse; keine neuen npm/pip/cargo-Pakete in diesem Plan. |
</threat_model>
<verification>
1. `ci.yml` enthaelt Job `desktop`, `publish` mit `needs: desktop`, Cache-Restore mit hartem Abbruch, kein upload-artifact.
2. `publish-images.sh` und `publish-release.sh` bestehen `sh -n`; Probelaeufe zeigen die erwarteten Zeilen (`push` x4, `assets?name=Tessera-1.1.0.AppImage`).
3. Kein `curl`-Aufruf traegt das Token als Argument.
4. Der echte Lauf wird in 18-05 bewiesen; der Release-Anhang beim naechsten Tag (18-06, human-check Punkt b).
</verification>
<success_criteria>
- Job `desktop` baut das AppImage mit Tag-Version und uebergibt es per Cache.
- `publish` kann kein Abbild ohne Pakete mehr bauen.
- Release-Dateien werden bei Tags idempotent aus dem Manifest hochgeladen.
</success_criteria>
<output>
Create `.planning/phases/18-desktop-client-fertigstellen/18-02-SUMMARY.md` when done.
</output>
@@ -0,0 +1,139 @@
---
phase: 18-desktop-client-fertigstellen
plan: 02
subsystem: infra
tags: [gitea-actions, ci-cd, tauri, actions-cache, release-assets]
# Dependency graph
requires:
- phase: 18-desktop-client-fertigstellen (Plan 01)
provides: .gitea/scripts/desktop-collect.sh, .gitea/scripts/desktop-version.sh, desktop-dist/manifest.json-Form
provides:
- "Job desktop in .gitea/workflows/ci.yml (Linux-AppImage mit Tag-Version, Cargo-Zwischenspeicher, actions/cache-Uebergabe)"
- "publish haengt an desktop (needs: desktop), holt Pakete per actions/cache/restore mit fail-on-cache-miss: true, prueft das Manifest hart"
- "publish-images.sh bricht im echten Baupfad ohne desktop-dist/manifest.json ab"
- "publish-release.sh: upload_asset() laedt jede Manifest-Datei idempotent als Release-Anhang hoch (GET -> DELETE vorhandener -> POST multipart)"
affects: [18-05-windows-cross-bau-pipeline-beweis, 18-06-freigabe-release-anhang]
actuals:
tokens: 2586
tasks: 2
commits: 2
plan_head_before: cd62de1
tech-stack:
added: []
patterns:
- "Cross-Job-Uebergabe per actions/cache/save + actions/cache/restore (Schluessel exakt am Commit-SHA, kein restore-keys-Fallback fuer die Uebergabe selbst) statt der auf dieser Gitea-Instanz unzuverlaessigen upload-/download-artifact-Actions"
- "Zweite Header-Datei ohne Content-Type: application/json fuer multipart-Uploads (curl -F) neben der bestehenden JSON-Header-Datei — gleiche umask 077/mktemp/trap-Mechanik, Token nie als Argument"
- "Idempotenter Datei-Upload nach dem bereits etablierten GET-dann-PATCH/POST-Muster von publish-release.sh: GET .../assets, vorhandene Datei gleichen Namens per DELETE entfernen, dann frisch per POST hochladen"
key-files:
created: []
modified:
- .gitea/workflows/ci.yml
- .gitea/scripts/publish-images.sh
- .gitea/scripts/publish-release.sh
key-decisions:
- "Kopfkommentar-Verweis auf 'desktop-version.sh' im neuen CI-Job-Schritt entfernt (nur als run-Zeile belassen), weil sonst grep -c 'desktop-version.sh' in der Datei auf 2 statt der geforderten 1 Fundstelle gestiegen waere — reine Kommentarformulierung, keine Verhaltensaenderung."
- "In der 404-Verzweigung von publish-release.sh wird ID jetzt explizit als Variable gesetzt (vorher nur inline in der Echo-Zeile berechnet), damit sie fuer die nachfolgende Upload-Schleife in beiden Zweigen (200 und 404) verfuegbar ist."
- "Upload-Schleife ueber die Manifest-Dateinamen laeuft als `for FNAME in $(jq -r ...)` statt `jq ... | while read`, damit ein `exit 1` innerhalb von upload_asset() unter dash/sh tatsaechlich das ganze Skript beendet und nicht nur eine Pipe-Subshell (POSIX-sh-Pipelines laufen in eigenen Subshells)."
patterns-established: []
requirements-completed: [DESK-01, DESK-04]
coverage:
- id: D1
description: "Job desktop laeuft nach test auf main und bei Tags v*, baut das Linux-AppImage mit der Tag-Version (System-Abhaengigkeiten, Rust-Toolchain per rustup, Cargo-Zwischenspeicher, cargo check/clippy, alte Bundles entfernen, Bau, desktop-collect.sh --require linux) und uebergibt desktop-dist/ per actions/cache an publish"
requirement: "DESK-01"
verification:
- kind: other
ref: "grep-Batterie aus dem Plan (CI-OK: Job-Schluessel, needs, Cache-Schluessel x2, fail-on-cache-miss, kein upload-artifact, System-Abhaengigkeiten, Skript-Aufrufe) + node-Struktur-Check der Job-Reihenfolge quality/test/desktop/publish"
status: pass
human_judgment: true
rationale: "Der eigentliche Pipeline-Lauf (Rust-Bau, apt-Installation, Cargo-Cache-Verhalten auf dem echten act_runner) kann von diesem Executor nicht ausgefuehrt werden — nur die YAML-Struktur und die POSIX-sh-Skripte sind lokal pruefbar. Der echte gruene Lauf wird laut Plan/Objective erst in 18-05 bewiesen (gemeinsam mit dem Windows-Cross-Bau)."
- id: D2
description: "publish bricht hart ab, wenn das Manifest aus dem Zwischenspeicher fehlt (fail-on-cache-miss im Workflow + expliziter test -f/jq-Schritt + zweites Netz in publish-images.sh vor der Docker-Bau-Schleife)"
requirement: "DESK-01"
verification:
- kind: other
ref: "sh -n .gitea/scripts/publish-images.sh + GITHUB_REF=refs/tags/v1.1.0 sh .gitea/scripts/publish-images.sh --print-plan (liefert weiterhin 4 push-Zeilen, da der Probelauf vor der neuen Pruefung endet) + Code-Inspektion der neuen if [ ! -f desktop-dist/manifest.json ]-Pruefung vor der Bau-Schleife"
status: pass
human_judgment: false
- id: D3
description: "publish-release.sh haengt bei Tags jede Datei aus dem Manifest idempotent als Release-Datei an den Gitea-Release (GET assets -> vorhandene Datei gleichen Namens per DELETE entfernen -> POST multipart); das Token verlaesst nie die Header-Datei"
requirement: "DESK-04"
verification:
- kind: other
ref: "sh -n .gitea/scripts/publish-release.sh + sh .gitea/scripts/publish-release.sh --dry-run --tag v1.1.0 (nennt POST .../assets?name=Tessera-1.1.0.AppImage aus dem echten Manifest von 18-01, kein Netzaufruf, kein Token) + grep-Batterie (HDR_AUTH x4, genau ein upload_asset(), files[].name, kein curl mit GITEA_TOKEN als Argument)"
status: pass
human_judgment: true
rationale: "Der idempotente GET/DELETE/POST-Roundtrip gegen die echte Gitea-API (inkl. multipart-Upload einer ~107-MB-Datei) ist nur im echten CI-Lauf pruefbar; der Probelauf beweist ausschliesslich die Skript-Logik und den erwarteten Zielpfad. Der echte Beweis folgt beim naechsten Freigabe-Tag (18-06, human-check laut Plan-Verifikation Punkt 4)."
duration: 8min
completed: 2026-09-16
status: complete
---
# Phase 18 Plan 02: CI-Pipeline fuer den Desktop-Client — Job `desktop`, Cache-Uebergabe, Release-Anhaenge Summary
**Neuer CI-Job `desktop` baut das Linux-AppImage mit Tag-Version und uebergibt es per `actions/cache` an `publish`, das ohne Manifest hart abbricht; `publish-release.sh` haengt jede Datei aus `manifest.json` idempotent (GET/DELETE/POST) als Release-Anhang an — der echte Pipeline-Lauf folgt in 18-05.**
## Performance
- **Duration:** 8 min
- **Started:** 2026-09-16T14:13:35Z (Aktenstand-Zeitstempel nach 18-01)
- **Completed:** 2026-09-16T14:21:32Z
- **Tasks:** 2
- **Files modified:** 3
## Accomplishments
- `.gitea/workflows/ci.yml`: neuer Job `desktop` zwischen `test` und `publish` — Systemabhaengigkeiten (vollstaendige apt-Liste fuer den bloßen `ubuntu-latest`-Runner, Pitfall 5), Rust-Toolchain per `rustup` (kein Rust im Runner-Abbild), Cargo-Zwischenspeicher (`actions/cache@v4`, Schluessel ueber `Cargo.lock`-Hash), Version aus dem Freigabe-Tag (`desktop-version.sh`), `cargo check`/`cargo clippy` (D-16), alte Bundle-Reste entfernen, Linux-AppImage bauen, `desktop-collect.sh --require linux`, Uebergabe per `actions/cache/save` mit Schluessel `desktop-dist-${{ gitea.sha }}`.
- `publish` haengt jetzt an `desktop` (`needs: desktop`) statt an `test`, holt die Pakete per `actions/cache/restore` mit `fail-on-cache-miss: true` und prueft das Manifest zusaetzlich explizit (`test -f` + `jq .`) — Job bricht sichtbar ab statt ein Abbild ohne Desktop-Pakete zu bauen.
- `publish-images.sh`: zweites Netz gegen einen Cache-Fehlschlag — im echten Baupfad (nicht im `--print-plan`-Probelauf) bricht das Skript ohne `desktop-dist/manifest.json` mit Exit 1 ab, bevor irgendein `docker build` laeuft.
- `publish-release.sh`: neue Funktion `upload_asset()` nach dem bereits etablierten GET-dann-PATCH/POST-Idempotenzmuster der Datei — pro Manifest-Datei erst pruefen, ob ein Anhang gleichen Namens existiert (`GET .../assets`), diesen ggf. entfernen (`DELETE`), dann frisch hochladen (`POST multipart`, Feld `attachment`). Neue Header-Datei `$HDR_AUTH` (nur `Authorization`, kein JSON-Content-Type) fuer den multipart-Upload — gleiche `umask 077`/`mktemp`/`trap`-Mechanik wie die bestehende `$HDR`-Datei, Token verlaesst nie eine `curl`-Kommandozeile. `--dry-run` listet zusaetzlich die geplanten Uploads aus dem vorhandenen Manifest.
## Task Commits
Each task was committed atomically:
1. **Task 1: Job desktop (Linux-AppImage) und Uebergabe an publish per actions/cache** - `a6ffe05` (feat)
2. **Task 2: Release-Dateien idempotent an den Gitea-Release haengen** - `75a8e40` (feat)
**Plan metadata:** commit pending (this SUMMARY + STATE.md/ROADMAP.md/REQUIREMENTS.md)
## Files Created/Modified
- `.gitea/workflows/ci.yml` - Job `desktop` (Linux-AppImage, Cargo-Cache, actions/cache-Uebergabe), `publish` haengt an `desktop`, holt Pakete per Cache-Restore mit hartem Abbruch
- `.gitea/scripts/publish-images.sh` - Harte Manifest-Pruefung vor der Docker-Bau-Schleife im echten Baupfad
- `.gitea/scripts/publish-release.sh` - `HDR_AUTH`, `upload_asset()`, `DESKTOP_DIST`/`MANIFEST`-Variablen, Upload-Schleife nach Release-Anlage/-Aktualisierung, erweiterter `--dry-run`
## Decisions Made
- Kopfkommentar-Referenz auf `desktop-version.sh` im neuen CI-Schritt-Kommentar weggelassen (nur als tatsaechliche `run:`-Zeile vorhanden), damit die Zaehl-basierte Abnahmekriterien-Pruefung (`grep -c 'desktop-version.sh'` == 1) exakt erfuellt wird — keine funktionale Aenderung.
- `ID` in der 404-Verzweigung von `publish-release.sh` (neuer Release) jetzt als Variable gesetzt statt nur inline in der Log-Zeile berechnet, damit dieselbe Variable in beiden Case-Zweigen (bestehender und neuer Release) fuer die nachfolgende Upload-Schleife zur Verfuegung steht.
- Die Upload-Schleife ueber Manifest-Dateinamen nutzt `for FNAME in $(jq -r '.files[].name' "$MANIFEST")` statt einer `jq | while read`-Pipe, weil ein `exit 1` innerhalb der aufgerufenen `upload_asset()`-Funktion in einer POSIX-sh-Pipe-Subshell nur die Subshell beendet hatte, nicht das gesamte Skript — mit `for ... in $(...)` bleibt der Fehlerpfad im Hauptprozess und `set -eu` wirkt wie erwartet.
## Deviations from Plan
None - plan executed exactly as written.
## Issues Encountered
- Die im Plan/`<verify>` verwendeten `grep`-Muster mit `${{ ... }}` (z. B. `desktop-dist-${{ gitea.sha }}`) liefern in dieser Ausfuehrungsumgebung ueber die interaktive `grep`-Shell-Funktion (ugrep-basierter Shim von Claude Code) faelschlich 0 Treffer, obwohl die Zeile exakt vorhanden ist — bestaetigt durch direkten Vergleich mit `command grep`/`/usr/bin/grep` (GNU grep 3.11), die beide korrekt 2 Treffer liefern. Alle `<verify>`- und `<acceptance_criteria>`-Pruefungen wurden deshalb zusaetzlich mit `command grep` wiederholt und sind gruen; die Datei selbst ist unveraendert von diesem Werkzeug-Artefakt betroffen. Kein Code-Problem, reine Umgebungs-Eigenheit dieser Sitzung.
## User Setup Required
None - no external service configuration required.
## Next Phase Readiness
- Der Job `desktop` und die Cache-Uebergabe an `publish` stehen; `publish` kann kein Abbild mehr ohne Desktop-Pakete bauen; `publish-release.sh` laedt Manifest-Dateien idempotent hoch — 18-05 kann direkt den Windows-Cross-Bau (cargo-xwin, NSIS) in denselben `desktop`-Job erweitern und den echten Pipeline-Lauf mit beiden Dateien beweisen.
- Kein Blocker. Der reale CI-Lauf (act_runner, echter Cache-Server, echter Gitea-Upload) ist laut Plan-Objective bewusst nicht Teil dieses Plans — er wird in 18-05 (Pipeline-Beweis) und beim naechsten Freigabe-Tag (18-06, Release-Anhang) gefuehrt.
- `REGISTRY_TOKEN` (Precondition Task 2) bleibt unveraendert im Einsatz; keine neue Secret-Konfiguration noetig.
---
*Phase: 18-desktop-client-fertigstellen*
*Completed: 2026-09-16*
## Self-Check: PASSED
All modified files verified on disk (`.gitea/workflows/ci.yml`, `.gitea/scripts/publish-images.sh`, `.gitea/scripts/publish-release.sh`). Both task commits found in `git log` (`a6ffe05`, `75a8e40`). All plan-level `<verification>` items re-run and passing: `CI-OK` (Job-Struktur, Cache-Schluessel x2, `fail-on-cache-miss`, kein `upload-artifact`, Skript-Aufrufe), `IMAGES-OK` (`sh -n`, vier `push`-Zeilen im Probelauf, Manifest-Pruefung vorhanden), `RELEASE-OK` (`sh -n`, Probelauf nennt `assets?name=Tessera-1.1.0.AppImage`, `HDR_AUTH` x4, genau ein `upload_asset()`, `files[].name`, kein Token in einer `curl`-Zeile) — alle Pruefungen zusaetzlich mit `command grep`/GNU grep gegengeprueft (siehe "Issues Encountered" zum `ugrep`-Shim-Artefakt dieser Sitzung).
@@ -0,0 +1,372 @@
---
phase: 18-desktop-client-fertigstellen
plan: 03
type: execute
wave: 2
depends_on: ["18-01"]
files_modified:
- apps/web/src/lib/desktop.ts
- apps/web/src/lib/desktop.test.ts
- apps/web/src/components/desktop/desktop-download-links.tsx
- apps/web/src/components/desktop/desktop-download-links.test.tsx
- apps/web/src/app/(auth)/login/page.tsx
- apps/web/src/app/(portal)/settings/general/desktop/page.tsx
- apps/web/src/components/settings/desktop-app-settings.tsx
- apps/web/src/components/settings/desktop-app-settings.test.tsx
- apps/web/src/components/settings/settings-sidebar.tsx
- apps/web/src/messages/de.json
- apps/web/src/messages/en.json
autonomous: true
requirements: [DESK-03]
user_setup: []
estimate:
tokens: 80000
raw_tokens: 80000
tasks: 2
confidence: low
must_haves:
truths:
- "Auf der Anmeldeseite steht unterhalb des Formulars ein unauffaelliger Link 'Desktop-App herunterladen (Windows)' mit kleinem Linux-Link und Versionsangabe — nur wenn /desktop/latest antwortet (D-12)."
- "Unter Einstellungen -> Allgemein -> Desktop-App gibt es eine Seite mit Version, zwei Download-Knoepfen in Primaerfarbe mit Plattform-Symbol, Dateiname und Dateigroesse sowie vier Saetzen in Sie-Form; antwortet die API mit 404, erscheint statt der Knoepfe ein Hinweis (D-12)."
- "Jeder Download laeuft ueber die Tessera-API (API_URL + url aus /desktop/latest); Anwender brauchen keinen Gitea-Zugang (D-01, D-10)."
- "Alle neuen Texte liegen 1:1 in de.json und en.json vor, deutsche Texte mit echten Umlauten (Projektkonvention)."
artifacts:
- path: "apps/web/src/lib/desktop.ts"
provides: "loadDesktopLatest (memoisiert, still bei Fehler), desktopDownloadUrl, formatFileSize"
exports: ["loadDesktopLatest", "desktopDownloadUrl", "formatFileSize"]
- path: "apps/web/src/components/desktop/desktop-download-links.tsx"
provides: "Link-Block der Anmeldeseite, rendert nichts ohne Daten"
exports: ["DesktopDownloadLinks"]
- path: "apps/web/src/components/settings/desktop-app-settings.tsx"
provides: "Inhalt der Einstellungsseite: Version, Knoepfe, Groesse, Saetze, Hinweis"
exports: ["DesktopAppSettings"]
- path: "apps/web/src/app/(portal)/settings/general/desktop/page.tsx"
provides: "Route /settings/general/desktop"
contains: "DesktopAppSettings"
- path: "apps/web/src/messages/de.json"
provides: "auth.desktopDownload.*, settings.categoryDesktopApp, settings.desktop.*"
contains: "desktopDownload"
key_links:
- from: "apps/web/src/lib/desktop.ts"
to: "apps/api/src/desktop/desktop.controller.ts"
via: "fetch(`${API_URL}/desktop/latest`) — im Betrieb ueber den Rewrite /api-proxy"
pattern: "desktop/latest"
- from: "apps/web/src/components/desktop/desktop-download-links.tsx"
to: "apps/web/src/lib/desktop.ts"
via: "loadDesktopLatest() in useEffect; null blendet den Block aus"
pattern: "loadDesktopLatest"
- from: "apps/web/src/components/settings/settings-sidebar.tsx"
to: "apps/web/src/app/(portal)/settings/general/desktop/page.tsx"
via: "Link href=/settings/general/desktop unter 'Allgemein'"
pattern: "settings/general/desktop"
---
<objective>
Anwender sehen die Desktop-App in Tessera selbst: ein Link auf der
Anmeldeseite und eine eigene Einstellungsseite "Desktop-App" mit Version,
Download-Knoepfen fuer Windows und Linux, Dateigroesse und einer kurzen
Erklaerung. Beides liest `GET /desktop/latest` aus 18-01 und blendet sich aus,
wenn der Server keine Pakete traegt.
Purpose: D-12 aus 18-CONTEXT.md (Web-Oberflaeche) und Erfolgskriterium 2.
Output: Fetch-Helfer, zwei Komponenten mit Tests, neue Einstellungsroute,
Seitenleisteneintrag, Uebersetzungen de/en.
Alle Adressen werden aus `API_URL` gebildet (`NEXT_PUBLIC_API_URL`, im
Betrieb `/api-proxy`); es wird nirgends eine feste Server- oder
Firmenadresse eingetragen.
</objective>
## Artifacts this phase produces
Dieser Plan: `apps/web/src/lib/desktop.ts` (`DesktopPlatform`,
`DesktopFileInfo`, `DesktopLatestInfo`, `loadDesktopLatest`,
`desktopDownloadUrl`, `formatFileSize`), `desktop.test.ts`,
`components/desktop/desktop-download-links.tsx` (`DesktopDownloadLinks`),
`desktop-download-links.test.tsx`, `app/(auth)/login/page.tsx` (Einbau),
`app/(portal)/settings/general/desktop/page.tsx` (`DesktopSettingsPage`),
`components/settings/desktop-app-settings.tsx` (`DesktopAppSettings`),
`desktop-app-settings.test.tsx`, `components/settings/settings-sidebar.tsx`
(Eintrag), `messages/de.json` und `messages/en.json` (`auth.desktopDownload.*`,
`settings.categoryDesktopApp`, `settings.desktop.*`). Gesamtliste der Phase:
siehe 18-01-PLAN.md.
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/18-desktop-client-fertigstellen/18-CONTEXT.md
@.planning/phases/18-desktop-client-fertigstellen/18-PATTERNS.md
@.planning/phases/18-desktop-client-fertigstellen/18-01-SUMMARY.md
@apps/web/src/lib/app-version.ts
@apps/web/src/lib/app-version.test.ts
@apps/web/src/components/layout/app-version-badge.tsx
@apps/web/src/app/(auth)/login/page.tsx
@apps/web/src/app/(portal)/settings/general/account/page.tsx
@apps/web/src/components/settings/settings-sidebar.tsx
@apps/web/src/components/settings/widget-settings-panel.test.tsx
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Fetch-Helfer und der Download-Link auf der Anmeldeseite</name>
<files>
apps/web/src/lib/desktop.ts,
apps/web/src/lib/desktop.test.ts,
apps/web/src/components/desktop/desktop-download-links.tsx,
apps/web/src/components/desktop/desktop-download-links.test.tsx,
apps/web/src/app/(auth)/login/page.tsx,
apps/web/src/messages/de.json,
apps/web/src/messages/en.json
</files>
<read_first>
apps/web/src/lib/app-version.ts (gesamt — Muster fuer API_URL und memoisiertes Laden),
apps/web/src/lib/app-version.test.ts (gesamt — vi.resetModules + dynamischer Import),
apps/web/src/components/layout/app-version-badge.tsx (useEffect/useState-Konsum),
apps/web/src/app/(auth)/login/page.tsx (Einbaustelle nach dem Formular),
apps/web/src/components/settings/widget-settings-panel.test.tsx (Zeilen 1-30, next-intl-Mock mit de.json),
apps/web/src/messages/de.json (Namensraum `auth`),
.planning/phases/18-desktop-client-fertigstellen/18-RESEARCH.md (Code Example 8)
</read_first>
<behavior>
- `loadDesktopLatest()` ruft `${API_URL}/desktop/latest` genau einmal je Modulinstanz auf (zweiter Aufruf liefert dasselbe Promise); `ok=false` und Netzfehler liefern `null`, nichts wird geworfen.
- `desktopDownloadUrl(file)` ergibt `${API_URL}${file.url}` (z. B. `http://localhost:3001/desktop/download/windows` in Tests).
- `formatFileSize(6123456, 'de')` ergibt `5,8 MB`; `formatFileSize(6123456, 'en')` ergibt `5.8 MB`; `formatFileSize(106461688, 'de')` ergibt `101,5 MB`.
- `DesktopDownloadLinks` rendert nichts, solange nichts geladen ist oder `null` kam; mit Daten fuer beide Plattformen erscheinen ein Link "Desktop-App herunterladen (Windows)" (href = Windows-URL, Attribut `download`) und ein Link "Linux-Version" sowie der Text "Version 1.1.0".
- Fehlt `files.windows` (Stand nach 18-01, nur Linux gebaut), erscheint genau ein Link mit dem Text "Desktop-App herunterladen (Linux)" und der Versionstext.
</behavior>
<action>
**`apps/web/src/lib/desktop.ts`** nach dem Vorbild `app-version.ts` (gleicher
`API_URL`-Ausdruck mit woertlichem `process.env.NEXT_PUBLIC_API_URL`,
deutscher Kopfkommentar mit Verweis auf D-10/D-12 und auf den Rewrite
`/api-proxy`). Typen als Spiegel der API (kein Import aus `@tessera/shared`,
gleiche Begruendung wie im Kommentar von `app-version.ts`):
`DesktopPlatform = 'windows' | 'linux'`,
`DesktopFileInfo { name; size; sha256; url }`,
`DesktopLatestInfo { version; channel; commit; buildTime; files: Partial<Record<DesktopPlatform, DesktopFileInfo>> }`.
`loadDesktopLatest()` memoisiert wie `loadApiVersion()`, aber **ohne**
`credentials: 'include'` (oeffentlicher Endpunkt, Anmeldeseite hat noch kein
Cookie). `desktopDownloadUrl(file)` und `formatFileSize(bytes, locale)`
(`Intl.NumberFormat(locale, { maximumFractionDigits: 1 })` auf `bytes / 1048576`,
Suffix ` MB`).
**`desktop.test.ts`** im Stil von `app-version.test.ts` (`importFresh` mit
`vi.resetModules`, `vi.stubGlobal('fetch', …)`): Test 1 memoisiert (ein
Fetch, zwei gleiche Ergebnisse, Aufruf-URL endet auf `/desktop/latest`,
kein `credentials`-Feld in den Optionen); Test 2 still bei `ok=false`; Test 3
still bei Netzfehler; Test 4 `desktopDownloadUrl`; Test 5 die drei
`formatFileSize`-Faelle aus `<behavior>` — Erwartungen von Hand.
**`components/desktop/desktop-download-links.tsx`** (`'use client'`,
`useTranslations('auth')`, `useLocale()` aus `next-intl`): `useEffect` laedt
`loadDesktopLatest()` mit `active`-Schutz wie `AppVersionBadge`; State
`DesktopLatestInfo | null`. Rendert `null`, wenn keine Daten oder keine
Plattform in `files`. Sonst ein `<div className="text-center text-sm text-muted-foreground">`
mit: Hauptlink (Windows, falls vorhanden, sonst Linux) als `<a href={desktopDownloadUrl(file)} download className="hover:text-foreground underline-offset-4 hover:underline">`
mit Text `t('desktopDownload.windows')` bzw. `t('desktopDownload.linux')`;
ist Windows vorhanden **und** Linux vorhanden, dahinter ` · ` und ein
zweiter Link `t('desktopDownload.linuxShort')`; darunter in `text-xs`
`t('desktopDownload.version', { version })`. Keine Fehlermeldung, kein
Spinner — der Block ist unauffaellig (D-12).
**`desktop-download-links.test.tsx`**: next-intl-Mock nach dem Muster in
`widget-settings-panel.test.tsx` (de.json-gestuetzt, zusaetzlich
`useLocale: () => 'de'`), `vi.mock('@/lib/desktop', …)` mit steuerbarem
`loadDesktopLatest` (echte `desktopDownloadUrl`/`formatFileSize` per
`importOriginal` durchreichen). Faelle: (1) `null` -> Container leer
(`container.firstChild` ist `null`); (2) beide Plattformen -> zwei Links mit
den deutschen Texten aus de.json und hrefs `…/desktop/download/windows` bzw.
`…/desktop/download/linux`, Text `Version 1.1.0`; (3) nur Linux -> genau ein
Link mit dem Linux-Text. `findBy…` fuer die asynchrone Aufloesung.
**Anmeldeseite (`(auth)/login/page.tsx`)**: Import der Komponente; direkt
nach dem schliessenden `</form>` innerhalb des `max-w-sm space-y-8`-Blocks
`<DesktopDownloadLinks />` einfuegen. Sonst nichts aendern.
**Uebersetzungen** in `de.json` unter `auth` neuer Block `desktopDownload`:
`windows` = "Desktop-App herunterladen (Windows)", `linux` = "Desktop-App
herunterladen (Linux)", `linuxShort` = "Linux-Version", `version` =
"Version {version}". In `en.json` 1:1: "Download desktop app (Windows)",
"Download desktop app (Linux)", "Linux version", "Version {version}".
</action>
<acceptance_criteria>
- `pnpm --filter @tessera/web exec vitest run src/lib/desktop.test.ts src/components/desktop` meldet 8 Tests bestanden, 0 fehlgeschlagen.
- `grep -v '^\s*//' apps/web/src/lib/desktop.ts | grep -c 'process.env.NEXT_PUBLIC_API_URL'` ergibt 1.
- `grep -c 'DesktopDownloadLinks' "apps/web/src/app/(auth)/login/page.tsx"` ergibt 2 (Import und Einbau).
- `node -e "const de=require('./apps/web/src/messages/de.json');if(de.auth.desktopDownload.windows!=='Desktop-App herunterladen (Windows)')process.exit(1)"` endet mit 0.
- `pnpm --filter @tessera/web type-check` fehlerfrei.
</acceptance_criteria>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web exec vitest run src/lib/desktop.test.ts src/components/desktop && pnpm --filter @tessera/web type-check</automated>
<fails_when>vitest meldet "failed" oder Exit-Code ungleich 0, oder tsc gibt Fehlerzeilen aus.</fails_when>
</verify>
<done>
Acht Tests gruen, Typpruefung fehlerfrei, die Anmeldeseite baut den
Link-Block ein, de/en tragen den Block `auth.desktopDownload`.
</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Einstellungsseite "Desktop-App" mit Knoepfen, Groesse und Erklaerung</name>
<files>
apps/web/src/app/(portal)/settings/general/desktop/page.tsx,
apps/web/src/components/settings/desktop-app-settings.tsx,
apps/web/src/components/settings/desktop-app-settings.test.tsx,
apps/web/src/components/settings/settings-sidebar.tsx,
apps/web/src/messages/de.json,
apps/web/src/messages/en.json
</files>
<read_first>
apps/web/src/app/(portal)/settings/general/account/page.tsx (Seitenhuelle),
apps/web/src/components/settings/settings-sidebar.tsx (Eintrag "Konto" unter "Allgemein"),
apps/web/src/components/settings/widget-settings-panel.test.tsx (Zeilen 1-30),
apps/web/src/app/(auth)/login/page.tsx (Klassen des Primaerknopfs: `rounded-md bg-primary px-4 py-2.5 text-sm font-medium text-primary-foreground hover:opacity-90`),
apps/web/src/lib/desktop.ts (aus Task 1),
apps/web/src/messages/de.json (Namensraum `settings`, Block `account`)
</read_first>
<behavior>
- Route `/settings/general/desktop` rendert die Ueberschrift "Desktop-App" und die Komponente `DesktopAppSettings`.
- Mit Daten fuer beide Plattformen zeigt die Seite "Aktuelle Version: 1.1.0", zwei Knoepfe "Für Windows herunterladen" und "Für Linux herunterladen" (Primaerfarbe, jeweils mit Plattform-Symbol als inline-SVG, `href` aus `desktopDownloadUrl`, Attribut `download`) und darunter je Knopf die Zeile "{Dateiname} · {Groesse}", z. B. "Tessera-Setup-1.1.0.exe · 5,8 MB".
- Auf dem Beta-Kanal steht zusaetzlich "Beta-Ausgabe, Stand {commit}".
- Vier Saetze in Sie-Form erklaeren, was die App ist, den Erststart mit Server-Adresse, das Verhalten im Infobereich und den Update-Hinweis.
- Antwortet die API mit null, erscheinen statt der Knoepfe der Satz "Auf diesem Server sind derzeit keine Desktop-Pakete hinterlegt." und die vier Saetze bleiben stehen.
- Die Seitenleiste zeigt unter "Allgemein" den Eintrag "Desktop-App" mit `aria-current="page"` auf der Route.
</behavior>
<action>
**Seite `app/(portal)/settings/general/desktop/page.tsx`**: exakt die
Huelle von `account/page.tsx` (`'use client'`, `useTranslations('settings')`,
`<h1>` mit `t('desktop.title')`), Inhalt `<DesktopAppSettings />`. Kein
Anlegen weiterer Layout-Dateien — die Route liegt unter dem bestehenden
`settings`-Layout mit Seitenleiste.
**Komponente `components/settings/desktop-app-settings.tsx`**
(`'use client'`, `useTranslations('settings')`, `useLocale()`): laedt
`loadDesktopLatest()` wie in Task 1 (State `undefined` = laedt, `null` =
nicht verfuegbar, Objekt = Daten). Aufbau: Absatz mit den vier Saetzen
`t('desktop.intro')`, `t('desktop.firstStart')`, `t('desktop.tray')`,
`t('desktop.update')` (ein `<p>` je Satz, `text-sm text-muted-foreground`);
dann bei Daten: `<p>` mit `t('desktop.versionLabel', { version })` und, wenn
`channel === 'beta'`, `t('desktop.channelBeta', { commit })`; dann ein
`<div className="flex flex-wrap gap-4">` mit je Plattform (nur vorhandene,
Reihenfolge Windows, Linux) einem Block aus `<a href download>` im
Primaerknopf-Stil der Anmeldeseite (`inline-flex items-center gap-2 rounded-md bg-primary px-4 py-2.5 text-sm font-medium text-primary-foreground hover:opacity-90`)
mit inline-SVG-Symbol (Windows: vier abgerundete Felder im 2x2-Raster;
Linux: Terminalfenster mit `>_`-Prompt — beide 16x16, `aria-hidden`) und
Text `t('desktop.downloadWindows')` bzw. `t('desktop.downloadLinux')`,
darunter `<p className="mt-1 text-xs text-muted-foreground">` mit
`t('desktop.fileInfo', { name, size: formatFileSize(size, locale) })`. Bei
`null`: `<p>` mit `t('desktop.unavailable')` statt Knoepfen. Waehrend des
Ladens nichts unterhalb der Saetze. `data-testid="desktop-download-windows"`
und `desktop-download-linux` an den Links.
**Seitenleiste `settings-sidebar.tsx`**: im `<nav>` unter "Allgemein" hinter
dem Konto-Link einen zweiten `<Link href="/settings/general/desktop">` mit
identischem Klassen-/`aria-current`-Muster und `t('categoryDesktopApp')`;
`isActive` bleibt unveraendert (`startsWith` deckt die Route ab).
**Uebersetzungen** `de.json` `settings`: `categoryDesktopApp` = "Desktop-App";
Block `desktop`: `title` = "Desktop-App", `intro` = "Die Desktop-App öffnet
Tessera in einem eigenen Fenster – ohne Browser, mit Symbol im Infobereich der
Taskleiste.", `firstStart` = "Beim ersten Start fragt die App nach der Adresse
Ihres Tessera-Servers; das ist die Adresse, unter der Sie Tessera auch im
Browser öffnen.", `tray` = "Schließen Sie das Fenster, läuft Tessera im
Infobereich weiter; über das Symbol dort öffnen Sie das Fenster wieder,
schalten den automatischen Start ein oder beenden die App.", `update` =
"Erscheint eine neuere Version, weist die App Sie darauf hin und führt Sie auf
diese Seite.", `versionLabel` = "Aktuelle Version: {version}", `channelBeta`
= "Beta-Ausgabe, Stand {commit}", `downloadWindows` = "Für Windows
herunterladen", `downloadLinux` = "Für Linux herunterladen", `fileInfo` =
"{name} · {size}", `unavailable` = "Auf diesem Server sind derzeit keine
Desktop-Pakete hinterlegt.". `en.json` 1:1 sinngemaess ("Desktop app",
"The desktop app opens Tessera in its own window – no browser, with an icon
in the notification area of the taskbar.", "On first start the app asks for
the address of your Tessera server; it is the address you also use to open
Tessera in the browser.", "If you close the window, Tessera keeps running in
the notification area; use the icon there to reopen the window, enable
automatic start, or quit the app.", "When a newer version is available the
app notifies you and brings you to this page.", "Current version: {version}",
"Beta build, commit {commit}", "Download for Windows", "Download for Linux",
"{name} · {size}", "No desktop packages are available on this server yet.").
**Test `desktop-app-settings.test.tsx`**: next-intl-Mock wie in Task 1
(Namensraum `settings`, `useLocale: () => 'de'`), `@/lib/desktop` gemockt.
Faelle: (1) beide Plattformen -> Text "Aktuelle Version: 1.1.0", zwei Links
mit den Testids, hrefs `…/desktop/download/windows` und `…/desktop/download/linux`,
Zeile "Tessera-Setup-1.1.0.exe · 5,8 MB" (Groesse 6123456) und
"Tessera-1.1.0.AppImage · 101,5 MB" (Groesse 106461688); (2) Kanal `beta`,
Commit `abc1234` -> "Beta-Ausgabe, Stand abc1234"; (3) `null` -> Hinweistext
sichtbar, keine Links (`queryByTestId` beide `null`), die vier Saetze
weiterhin da (mindestens `intro` per Text geprueft).
</action>
<acceptance_criteria>
- `pnpm --filter @tessera/web exec vitest run src/components/settings/desktop-app-settings.test.tsx` meldet 3 Tests bestanden, 0 fehlgeschlagen.
- `test -f "apps/web/src/app/(portal)/settings/general/desktop/page.tsx"` endet mit 0; `grep -c 'DesktopAppSettings' "apps/web/src/app/(portal)/settings/general/desktop/page.tsx"` ergibt 2.
- `grep -c 'href="/settings/general/desktop"' apps/web/src/components/settings/settings-sidebar.tsx` ergibt 1.
- Parität und Umlaute: das node-Skript aus `<verify>` gibt `i18n OK` aus.
- `pnpm --filter @tessera/web exec vitest run` — gesamte Web-Suite gruen (Basis am 2026-09-16: 52 Dateien / 354 Tests plus die neuen).
</acceptance_criteria>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web exec vitest run src/components/settings/desktop-app-settings.test.tsx src/components/desktop src/lib/desktop.test.ts && pnpm --filter @tessera/web type-check</automated>
<fails_when>vitest meldet "failed" oder Exit-Code ungleich 0, oder tsc gibt Fehlerzeilen aus.</fails_when>
<automated>cd /home/vicolab/projects/tessera-ctl && node -e "const de=require('./apps/web/src/messages/de.json'),en=require('./apps/web/src/messages/en.json');const walk=(o,p='')=>Object.entries(o).flatMap(([k,v])=>typeof v==='object'&&v?walk(v,p+k+'.'):[p+k]);for(const ns of ['auth','settings']){const d=walk(de[ns]),e=walk(en[ns]);const miss=d.filter(k=>!e.includes(k)).concat(e.filter(k=>!d.includes(k)));if(miss.length){console.error('Fehlende Uebersetzungen in '+ns+':',miss);process.exit(1)}}const vals=o=>Object.values(o).flatMap(v=>typeof v==='object'&&v?vals(v):[String(v)]);const bad=vals({a:de.auth.desktopDownload,b:de.settings.desktop,c:{k:de.settings.categoryDesktopApp}}).filter(s=>/\b(fuer|ueber|koennen|Groesse|verfuegbar|oeffnen|schliessen|Oeffnen|Schliessen|laeuft|fuehrt)\b/i.test(s));if(bad.length){console.error('ASCII-Umschrift statt Umlaut:',bad);process.exit(1)}console.log('i18n OK')"</automated>
<fails_when>Ausgabe `Fehlende Uebersetzungen` (Schluessel nur in einer Sprache) oder `ASCII-Umschrift statt Umlaut` (deutscher Text mit ae/oe/ue-Umschrift) und Exit 1; `i18n OK` fehlt.</fails_when>
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web exec vitest run</automated>
<fails_when>Irgendeine Datei der Web-Suite meldet "failed" — dann hat die Aenderung an de.json/en.json oder an der Seitenleiste bestehende Tests gebrochen.</fails_when>
</verify>
<done>
Die Einstellungsseite existiert mit Knoepfen, Groesse, Saetzen und
Hinweisfall, der Seitenleisteneintrag zeigt darauf, drei neue Tests gruen,
die gesamte Web-Suite gruen, de/en vollstaendig und mit Umlauten.
</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Browser -> API (`/desktop/latest`, `/desktop/download/:platform`) | Oeffentliche Endpunkte; die Web-Oberflaeche rendert nur, was die API liefert. |
| API-Antwort -> DOM (`href`, Dateiname, Groesse) | Werte aus dem Manifest landen als Linkziel und Text in der Seite. |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-18-07 | Tampering | `desktopDownloadUrl` (Linkziel aus API-Daten) | low | mitigate | Das Linkziel wird aus `API_URL` plus dem relativen `url`-Feld gebaut; die Komponenten uebernehmen nie eine absolute Adresse aus der Antwort, ein manipuliertes Manifest kann den Download also nicht auf einen fremden Host lenken. |
| T-18-08 | Spoofing | Dateiname/Version als Text | low | accept | React rendert Text escaped; die Werte stammen aus dem vom CI geschriebenen Manifest (T-18-03 in 18-01). |
| T-18-09 | Information Disclosure | Anmeldeseite zeigt Version vor der Anmeldung | low | accept | Beabsichtigt (D-12); gleiche Abwaegung wie T-18-05. |
| T-18-SC | Tampering | Paketinstallationen | low | accept | Dieser Plan installiert kein neues Paket. |
</threat_model>
<verification>
1. `pnpm --filter @tessera/web exec vitest run` — gesamte Web-Suite gruen.
2. `pnpm --filter @tessera/web type-check` — fehlerfrei.
3. i18n-Paritaets- und Umlautpruefung gibt `i18n OK` aus.
4. Browser-Gegenprobe am Phasenende (18-06): Link auf der Anmeldeseite, Seite
unter Einstellungen -> Allgemein -> Desktop-App, Download startet.
</verification>
<success_criteria>
- Anmeldeseite: Link "Desktop-App herunterladen (Windows)" plus Linux-Link
und Version, nur wenn die API antwortet.
- Einstellungen -> Allgemein -> Desktop-App: Version, zwei Primaerknoepfe mit
Symbol, Dateiname und Groesse, vier erklaerende Saetze, Hinweis bei fehlenden
Paketen.
- Alle Downloads laufen ueber die Tessera-API.
- de/en vollstaendig, deutsche Texte mit Umlauten und in Sie-Form.
</success_criteria>
<output>
Create `.planning/phases/18-desktop-client-fertigstellen/18-03-SUMMARY.md` when done.
</output>
@@ -0,0 +1,190 @@
---
phase: 18-desktop-client-fertigstellen
plan: 03
subsystem: ui
tags: [next-intl, react, desktop-distribution, i18n]
# Dependency graph
requires:
- phase: 18-01
provides: "GET /desktop/latest, GET /desktop/download/:platform (beide @Public()), DesktopLatestResponse-Form"
provides:
- "apps/web/src/lib/desktop.ts (loadDesktopLatest, desktopDownloadUrl, formatFileSize)"
- "DesktopDownloadLinks — unauffaelliger Link-Block auf der Anmeldeseite (D-12)"
- "DesktopAppSettings + Route /settings/general/desktop — Version, Download-Knoepfe, Dateigroesse, Erklaerung"
- "Seitenleisteneintrag Desktop-App unter Allgemein"
affects: [18-04-client-updateprüfung, 18-06-browser-gegenprobe]
actuals:
tokens: 6993
tasks: 2
commits: 2
plan_head_before: 2164cd537a8645f39a055bfa3cff77b8ef02822d
tech-stack:
added: []
patterns:
- "memoisiertes Single-Promise-Laden (Modul-Ebene), still bei Fehler -> null, konsumiert per useEffect+useState mit active-Schutz (Muster app-version.ts/AppVersionBadge, jetzt zweimal wiederverwendet: Login-Link und Einstellungsseite)"
- "Linkziel immer aus API_URL plus relativem url-Feld gebaut, nie eine absolute Adresse aus der Antwort uebernommen (T-18-07)"
key-files:
created:
- apps/web/src/lib/desktop.ts
- apps/web/src/lib/desktop.test.ts
- apps/web/src/components/desktop/desktop-download-links.tsx
- apps/web/src/components/desktop/desktop-download-links.test.tsx
- "apps/web/src/app/(portal)/settings/general/desktop/page.tsx"
- apps/web/src/components/settings/desktop-app-settings.tsx
- apps/web/src/components/settings/desktop-app-settings.test.tsx
modified:
- "apps/web/src/app/(auth)/login/page.tsx"
- apps/web/src/components/settings/settings-sidebar.tsx
- apps/web/src/messages/de.json
- apps/web/src/messages/en.json
- apps/web/src/messages/umlaut-dictionary.ts
key-decisions:
- "useLocale() aus der Anmeldeseiten-Komponente entfernt (Plan-Text erwaehnte es, aber der Link-Block zeigt keine Dateigroesse — nur die Einstellungsseite braucht locale fuer formatFileSize); vermeidet eine ungenutzte Variable."
- "'neuere' zur UMLAUT_ALLOWLIST ergaenzt — der bestehende Waechter-Test flaggte das Wort faelschlich, weil es zufaellig die Buchstabenfolge 'ue' enthaelt, obwohl die Schreibweise bereits korrekt ist (kein Substitutionsfehler)."
patterns-established: []
requirements-completed: [DESK-03]
coverage:
- id: D1
description: "Anmeldeseite zeigt Download-Link(s) nur wenn /desktop/latest antwortet, Windows fuehrt, Linux als Kurzlink bei beiden Paketen"
requirement: "DESK-03"
verification:
- kind: unit
ref: "apps/web/src/lib/desktop.test.ts#Test 1-5"
status: pass
- kind: unit
ref: "apps/web/src/components/desktop/desktop-download-links.test.tsx#Test 1-3"
status: pass
human_judgment: false
- id: D2
description: "Einstellungsseite Desktop-App: Version, zwei Primaerknoepfe mit Symbol, Dateiname/Groesse, Beta-Hinweis, vier erklaerende Saetze, Hinweistext ohne Pakete"
requirement: "DESK-03"
verification:
- kind: unit
ref: "apps/web/src/components/settings/desktop-app-settings.test.tsx#Test 1-3"
status: pass
human_judgment: false
- id: D3
description: "Seitenleiste zeigt den Eintrag Desktop-App unter Allgemein mit aria-current auf der Route"
requirement: "DESK-03"
verification:
- kind: other
ref: "grep -c 'href=\"/settings/general/desktop\"' apps/web/src/components/settings/settings-sidebar.tsx (=1)"
status: pass
human_judgment: false
- id: D4
description: "de/en vollstaendig fuer auth.desktopDownload.* und settings.desktop.*/categoryDesktopApp, deutsche Texte mit echten Umlauten"
requirement: "DESK-03"
verification:
- kind: other
ref: "node i18n-Paritaets-/Umlautskript aus 18-03-PLAN.md <verify> -> 'i18n OK'"
status: pass
- kind: unit
ref: "apps/web/src/messages/umlaut-guard.spec.ts"
status: pass
human_judgment: false
- id: D5
description: "Alle Downloads laufen ueber die Tessera-API (API_URL + relatives url-Feld), keine feste Server-/Firmenadresse im Code"
requirement: "DESK-03"
verification:
- kind: unit
ref: "apps/web/src/lib/desktop.test.ts#Test 4 (desktopDownloadUrl)"
status: pass
- kind: other
ref: "grep -v '^\\s*//' apps/web/src/lib/desktop.ts | grep -c 'process.env.NEXT_PUBLIC_API_URL' (=1)"
status: pass
human_judgment: true
rationale: "Der End-zu-Ende-Beweis (Browser klickt echten Download bis zum tatsaechlichen Dateidownload) ist die geplante Browser-Gegenprobe am Phasenende (18-06) — hier nur die Unit-/Text-Ebene automatisiert bewiesen."
duration: 20min
completed: 2026-09-16
status: complete
---
# Phase 18 Plan 03: Desktop-App in der Web-Oberflaeche Summary
**Unauffaelliger Download-Link auf der Anmeldeseite und eine vollstaendige Einstellungsseite "Desktop-App" (Version, zwei Primaerknoepfe mit Plattform-Symbol, Dateigroesse, Beta-Hinweis, vier erklaerende Saetze) — beide lesen `GET /desktop/latest` und blenden sich ohne Pakete aus.**
## Performance
- **Duration:** 20 min (geschaetzt)
- **Started:** 2026-09-16T14:10:00Z (geschaetzt)
- **Completed:** 2026-09-16T14:30:47Z
- **Tasks:** 2
- **Files modified:** 12
## Accomplishments
- `apps/web/src/lib/desktop.ts`: `loadDesktopLatest()` memoisiert (ein Fetch je Modulinstanz, still bei Fehler -> `null`, kein `credentials: 'include'` — die Anmeldeseite hat noch kein Cookie), `desktopDownloadUrl()` (baut die Adresse ausschliesslich aus `API_URL` plus dem relativen `url`-Feld, T-18-07), `formatFileSize()` (lokalisierte MB-Werte, `Intl.NumberFormat`).
- `DesktopDownloadLinks` auf der Anmeldeseite: rendert nichts ohne Daten oder ohne Plattform in `files`; Windows fuehrt als Hauptlink, Linux folgt als kleiner Zusatzlink, wenn beide Pakete vorliegen; darunter die Versionszeile.
- `DesktopAppSettings` unter `/settings/general/desktop`: vier erklaerende Saetze in Sie-Form (Was ist die App, Erststart, Tray-Verhalten, Update-Hinweis), Versionszeile, Beta-Kanal-Zusatzhinweis mit Commit, zwei Primaerknoepfe (`bg-primary`, inline-SVG-Plattformsymbol, `download`-Attribut) mit Dateiname+Groesse darunter, und ein Hinweistext statt der Knoepfe, wenn die API `null` liefert.
- Seitenleiste: neuer Eintrag "Desktop-App" unter "Allgemein" mit identischem `aria-current`-Muster wie "Konto".
- `de.json`/`en.json`: `auth.desktopDownload.*` und `settings.desktop.*`/`settings.categoryDesktopApp` vollstaendig, deutsche Texte mit echten Umlauten.
## Task Commits
Each task was committed atomically:
1. **Task 1: Fetch-Helfer und der Download-Link auf der Anmeldeseite** - `026d9c3` (feat)
2. **Task 2: Einstellungsseite "Desktop-App" mit Knoepfen, Groesse und Erklaerung** - `e96d460` (feat)
**Plan metadata:** commit pending (this SUMMARY + STATE.md/ROADMAP.md/REQUIREMENTS.md)
## Files Created/Modified
- `apps/web/src/lib/desktop.ts` - `DesktopPlatform`/`DesktopFileInfo`/`DesktopLatestInfo`, `loadDesktopLatest`, `desktopDownloadUrl`, `formatFileSize`
- `apps/web/src/lib/desktop.test.ts` - 5 Tests (memoisiert, still bei ok=false/Netzfehler, URL-Bau, Groessenformatierung)
- `apps/web/src/components/desktop/desktop-download-links.tsx` - Link-Block der Anmeldeseite
- `apps/web/src/components/desktop/desktop-download-links.test.tsx` - 3 Tests (leer, beide Plattformen, nur Linux)
- `apps/web/src/app/(auth)/login/page.tsx` - `DesktopDownloadLinks` nach dem Formular eingebaut
- `apps/web/src/app/(portal)/settings/general/desktop/page.tsx` - Route, delegiert an `DesktopAppSettings`
- `apps/web/src/components/settings/desktop-app-settings.tsx` - Version, Knoepfe, Groesse, Saetze, Hinweisfall
- `apps/web/src/components/settings/desktop-app-settings.test.tsx` - 3 Tests (beide Plattformen, Beta-Hinweis, keine Pakete)
- `apps/web/src/components/settings/settings-sidebar.tsx` - Eintrag "Desktop-App" ergaenzt
- `apps/web/src/messages/de.json` / `en.json` - `auth.desktopDownload.*`, `settings.desktop.*`, `settings.categoryDesktopApp`
- `apps/web/src/messages/umlaut-dictionary.ts` - `neuere` zur Allowlist ergaenzt (Deviation, siehe unten)
## Decisions Made
- `useLocale()` in `DesktopDownloadLinks` weggelassen: der Link-Block der Anmeldeseite zeigt keine Dateigroesse, nur die Version — `formatFileSize` wird ausschliesslich auf der Einstellungsseite gebraucht. Eine ungenutzte Variable haette keinen Wert gehabt.
- Die vier erklaerenden Saetze und die Download-Bloecke bleiben eine einzige Client-Komponente (`DesktopAppSettings`) statt mehrerer Unterkomponenten — passend zur Groesse des Inhalts und zum bestehenden `account`/`smtp`-Seitenmuster (eine Komponente pro Einstellungsseite).
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 1 - Bug] Umlaut-Waechter-Test schlug auf "neuere" fehl**
- **Found during:** Task 2 (voller `pnpm --filter @tessera/web exec vitest run` nach dem i18n-Block)
- **Issue:** `src/messages/umlaut-guard.spec.ts` flaggte `settings.desktop.update: "neuere"` als vermeintlich falsche ASCII-Umschrift, weil das Wort die Buchstabenfolge "ue" enthaelt (n-e-**ue**-r-e) — die Schreibweise ist aber bereits korrektes Deutsch, keine Substitution noetig.
- **Fix:** `neuere` zur `UMLAUT_ALLOWLIST` in `apps/web/src/messages/umlaut-dictionary.ts` ergaenzt (neben den bereits vorhandenen `neue`/`neuen`/`Neue`/`Neues`).
- **Files modified:** `apps/web/src/messages/umlaut-dictionary.ts`
- **Verification:** `pnpm --filter @tessera/web exec vitest run` — vollstaendige Suite gruen (365/365).
- **Committed in:** `e96d460` (Task 2 commit)
---
**Total deviations:** 1 auto-fixed (1 bug)
**Impact on plan:** Reine Testinfrastruktur-Korrektur, kein Verhaltensunterschied im Produktionscode. Keine Ausweitung des Umfangs.
## Issues Encountered
None.
## User Setup Required
None - no external service configuration required.
## Next Phase Readiness
- Die Web-Oberflaeche liest `GET /desktop/latest` an beiden vorgesehenen Stellen (Anmeldeseite, Einstellungen) und blendet sich korrekt aus, wenn keine Pakete hinterlegt sind — 18-04 (Client-Versionspruefung) kann auf demselben Endpunkt aufbauen, ohne die Web-Seite zu beruehren.
- Die Browser-Gegenprobe (echter Klick, echter Download) ist bewusst auf 18-06 verschoben (siehe Plan-`<verification>` Punkt 4); alle automatisierten Ebenen (Unit-Tests, Typpruefung, i18n-Paritaet/Umlaute, volle Web-Suite) sind gruen.
- Kein Blocker.
---
*Phase: 18-desktop-client-fertigstellen*
*Completed: 2026-09-16*
## Self-Check: PASSED
All created files verified on disk (`apps/web/src/lib/desktop.ts`, `desktop.test.ts`, `apps/web/src/components/desktop/desktop-download-links.tsx`, `desktop-download-links.test.tsx`, `apps/web/src/app/(portal)/settings/general/desktop/page.tsx`, `apps/web/src/components/settings/desktop-app-settings.tsx`, `desktop-app-settings.test.tsx`). Both task commits found in `git log` (`026d9c3`, `e96d460`). All plan-level `<verification>` items re-run and passing: `pnpm --filter @tessera/web exec vitest run` (365/365 green, baseline 354 + 11 new), `pnpm --filter @tessera/web type-check` (clean), i18n parity/umlaut script -> `i18n OK`. Browser-Gegenprobe bleibt fuer 18-06 (Plan-`<verification>` Punkt 4, ausserhalb dieses Plans).
@@ -0,0 +1,409 @@
---
phase: 18-desktop-client-fertigstellen
plan: 04
type: execute
wave: 2
depends_on: ["18-01"]
files_modified:
- apps/desktop/src-tauri/src/lib.rs
- apps/desktop/src-tauri/Cargo.toml
- apps/desktop/src-tauri/Cargo.lock
- apps/desktop/src-tauri/capabilities/default.json
- apps/desktop/src-tauri/tauri.conf.json
- apps/desktop/src/setup.html
- apps/desktop/src-tauri/icons/icon.png
- apps/desktop/src-tauri/icons/icon.ico
- apps/desktop/src-tauri/icons/128x128.png
- apps/desktop/src-tauri/icons/128x128@2x.png
- apps/desktop/src-tauri/icons/32x32.png
files_deleted:
- apps/desktop/src-tauri/apps/desktop/src-tauri/icons/128x128.png
- apps/desktop/src-tauri/apps/desktop/src-tauri/icons/32x32.png
- apps/desktop/src-tauri/apps/desktop/src-tauri/icons/icon.ico
- apps/desktop/src-tauri/apps/desktop/src-tauri/icons/icon.png
autonomous: true
requirements: [DESK-01, DESK-02, DESK-05]
user_setup: []
estimate:
tokens: 90000
raw_tokens: 90000
tasks: 2
confidence: low
must_haves:
truths:
- "Der Client vergleicht beim Start seine Version mit `{server}/api-proxy/desktop/latest`; weicht sie ab, zeigt er die Benachrichtigung 'Neue Version X.Y.Z verfügbar' und schaltet den Tray-Eintrag 'Update herunterladen' frei, der `{server}/settings/general/desktop` im Systembrowser öffnet (D-11, D-13)."
- "Die Erststart-Seite fragt die Server-Adresse ab, prueft sie ueber `/api-proxy/health/version` (Rust-Kommando, kein CORS), speichert sie und laedt die Tessera-Anmeldung; Texte in Sie-Form, Tessera-Farben und -Logo (D-02, D-13)."
- "Das Tray-Menue traegt 'Öffnen', 'Update herunterladen', den Haken 'Mit Windows starten' (auf Linux 'Beim Anmelden starten') und 'Beenden' — mit echten Umlauten; Schliessen-ins-Tray, Fensterzustand und Autostart-Plugin bleiben wie in Phase 6 (D-13, D-14)."
- "Der Client traegt das Tessera-Zeichen als App- und Fenster-Icon (kein flaches gelbes Quadrat), und `cargo check`, `cargo clippy` sowie ein lokaler AppImage-Bau laufen durch (D-16)."
artifacts:
- path: "apps/desktop/src-tauri/src/lib.rs"
provides: "Kommandos check_server und save_server_url, Versionspruefung gegen /desktop/latest, Tray-Eintraege update und autostart, Opener"
contains: "check_server"
- path: "apps/desktop/src-tauri/capabilities/default.json"
provides: "opener:allow-open-url mit http/https-Scope"
contains: "opener:allow-open-url"
- path: "apps/desktop/src/setup.html"
provides: "Erststart-Seite ohne Bundler-Import, ueber window.__TAURI__.core.invoke"
contains: "__TAURI__"
- path: "apps/desktop/src-tauri/icons/icon.ico"
provides: "Mehrgroessen-ICO (16 bis 256) aus dem Tessera-Zeichen"
key_links:
- from: "apps/desktop/src-tauri/src/lib.rs"
to: "apps/api/src/desktop/desktop.controller.ts"
via: "GET {server}/api-proxy/desktop/latest — Feld version"
pattern: "api-proxy/desktop/latest"
- from: "apps/desktop/src/setup.html"
to: "apps/desktop/src-tauri/src/lib.rs"
via: "window.__TAURI__.core.invoke('check_server' | 'save_server_url')"
pattern: "invoke\\('check_server'"
- from: "apps/desktop/src-tauri/src/lib.rs (Tray 'update')"
to: "apps/web/src/app/(portal)/settings/general/desktop/page.tsx"
via: "opener().open_url(`{server}/settings/general/desktop`)"
pattern: "settings/general/desktop"
---
<objective>
Der Tauri-Client aus Phase 6 wird zum fertigen Produkt: Versionspruefung
gegen `/desktop/latest` mit Update-Hinweis und Download-Link im Tray,
Autostart-Haken im Tray, Umlaute in allen Tray-Texten, eine Erststart-Seite
in Sie-Form mit Tessera-Gestalt, die die Adresse wirklich prueft, und ein
echtes App-Icon. Der Windows-Bau wird in 18-05 in der Pipeline bewiesen; hier
werden `cargo check`, `cargo clippy` und ein lokaler AppImage-Bau als Beweis
vor dem Push verlangt (D-16).
Purpose: D-11, D-13 und D-14 aus 18-CONTEXT.md sowie Erfolgskriterium 3.
Output: Geaenderte `lib.rs`, neues Plugin `tauri-plugin-opener`, erweiterte
Capabilities, ueberarbeitete `setup.html`, Icon-Satz, lokal gebautes AppImage.
**Zwei Befunde aus der Planung, die dieser Plan behebt:**
1. `setup.html` importiert das Store-Plugin als nacktes ES-Modul; ohne
Bundler und ohne Importmap scheitert dieser Import im gebauten Client mit
"Failed to resolve module specifier", der Knopf "Verbinden" tut dann
nichts. Die Seite spricht kuenftig ausschliesslich ueber
`window.__TAURI__.core.invoke` mit zwei Rust-Kommandos (`withGlobalTauri`
ist bereits aktiv).
2. Die API ist vom Client nur ueber den Web-Ursprung erreichbar
(Next.js-Rewrite `/api-proxy/*`, siehe 18-01). Die bisherige Pruefung
gegen `{server}/health/version` lief im Betrieb ins Leere; alle Aufrufe
gehen jetzt ueber `{server}/api-proxy/...`.
**Discretion (Icon-Pruefung, Tray-Reihenfolge):** Die heutigen Icons sind
flache gelbe Quadrate (32x32, 105 Bytes). Sie werden aus dem Web-Zeichen
`apps/web/src/app/icon.svg` neu erzeugt. Tray-Reihenfolge: Öffnen ·
Update herunterladen · — · Autostart-Haken · — · Beenden.
</objective>
## Artifacts this phase produces
Dieser Plan: `apps/desktop/src-tauri/src/lib.rs` (Funktionen `api_url`,
`check_server`, `save_server_url`, Struktur `DesktopLatest`, Tray-IDs
`open`/`update`/`autostart`/`quit`), `Cargo.toml` (+`tauri-plugin-opener`),
`Cargo.lock`, `capabilities/default.json` (`opener:allow-open-url`),
`tauri.conf.json` (Icon-Liste, CSP ohne Fremdhost), `apps/desktop/src/setup.html`,
`icons/icon.png` (512), `icons/128x128.png`, `icons/128x128@2x.png`,
`icons/32x32.png`, `icons/icon.ico` (16-256). Entfernt: das versehentlich
verschachtelte Verzeichnis `apps/desktop/src-tauri/apps/`. Gesamtliste der
Phase: siehe 18-01-PLAN.md.
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/18-desktop-client-fertigstellen/18-CONTEXT.md
@.planning/phases/18-desktop-client-fertigstellen/18-RESEARCH.md
@.planning/phases/06-desktop-client-ci-cd/06-02-SUMMARY.md
@apps/desktop/src-tauri/src/lib.rs
@apps/desktop/src-tauri/Cargo.toml
@apps/desktop/src-tauri/capabilities/default.json
@apps/desktop/src-tauri/tauri.conf.json
@apps/desktop/src/setup.html
@apps/web/src/app/icon.svg
</context>
<tasks>
<task type="auto">
<name>Task 1: lib.rs — Kommandos fuer die Erststart-Seite, Versionspruefung gegen /desktop/latest, Tray mit Update und Autostart</name>
<precondition>Rust/Cargo 1.96 mit Clippy ist installiert (`cargo clippy --version` antwortet), `cargo check` in `apps/desktop/src-tauri` ist am Stand von 18-01 gruen, und die Basislinie `1.1.0` aus 18-01 Task 2 ist eingecheckt.</precondition>
<reversibility rating="reversible">Plugin-Einbindung und Tray-Aufbau sind lokal in einer Datei; ein Rueckbau ist ein Commit.</reversibility>
<files>
apps/desktop/src-tauri/src/lib.rs,
apps/desktop/src-tauri/Cargo.toml,
apps/desktop/src-tauri/Cargo.lock,
apps/desktop/src-tauri/capabilities/default.json
</files>
<read_first>
apps/desktop/src-tauri/src/lib.rs (gesamt, 121 Zeilen),
apps/desktop/src-tauri/Cargo.toml,
apps/desktop/src-tauri/capabilities/default.json,
.planning/phases/18-desktop-client-fertigstellen/18-RESEARCH.md (Code Examples 4 und 5, "Package Legitimacy Audit"),
.planning/phases/18-desktop-client-fertigstellen/18-PATTERNS.md (Abschnitte lib.rs, capabilities, Cargo.toml)
</read_first>
<behavior>
- `check_server(url)` (async Tauri-Kommando) prueft Schema http/https, ruft `{url}/api-proxy/health/version` mit 8 Sekunden Zeitlimit ab und liefert `Ok(version)`; jeder Fehler liefert `Err({deutsche Meldung in Sie-Form})`.
- `save_server_url(url)` normalisiert die Adresse, schreibt `server_url` in `config.json` des Store-Plugins, speichert den Store und navigiert das Fenster `main` auf die Adresse.
- Beim Start mit gespeicherter Adresse laeuft die Versionspruefung gegen `{server}/api-proxy/desktop/latest`; bei `version != CARGO_PKG_VERSION` erscheint die Benachrichtigung (Titel "Tessera-Update", Text "Neue Version X.Y.Z verfügbar – Download über das Symbol im Infobereich."), und der Tray-Eintrag `update` wird aktiviert und in "Version X.Y.Z herunterladen" umbenannt.
- Tray-Eintrag `update` oeffnet `{server}/settings/general/desktop` im Systembrowser ueber `tauri-plugin-opener`.
- Tray-Haken `autostart` spiegelt beim Start `autolaunch().is_enabled()`; ein Klick schaltet um und setzt den Haken auf den neuen Zustand.
- Tray-Texte: "Öffnen", "Update herunterladen", "Mit Windows starten" (unter `cfg!(target_os = "windows")`, sonst "Beim Anmelden starten"), "Beenden".
</behavior>
<action>
**Abhaengigkeit.** Im Verzeichnis `apps/desktop/src-tauri`
`cargo add tauri-plugin-opener@2` ausfuehren (Legitimitaetspruefung in
RESEARCH: `OK`, offizielles Plugin aus `tauri-apps/plugins-workspace`;
gleiche unpinnte Major-Schreibweise wie die anderen `tauri-plugin-*`-Zeilen).
`Cargo.lock` wird dabei aktualisiert und mit committet.
**Capabilities (`capabilities/default.json`).** An das `permissions`-Array
das Objekt `{ "identifier": "opener:allow-open-url", "allow": [ { "url": "https://*" }, { "url": "http://*" } ] }`
anhaengen (`http://*` wegen D-02: interne Server ohne TLS sind erlaubt,
gleiche Begruendung wie die HTTP-Warnung der Erststart-Seite). Die
Autostart-Rechte sind bereits vorhanden.
**`lib.rs` — Imports und Plugins.** Zusaetzlich `use tauri_plugin_opener::OpenerExt;`,
`use tauri_plugin_autostart::ManagerExt;` (neben `MacosLauncher`),
`tauri::menu::CheckMenuItemBuilder`, `tauri::AppHandle`, `std::time::Duration`.
Plugin `.plugin(tauri_plugin_opener::init())` registrieren und
`.invoke_handler(tauri::generate_handler![check_server, save_server_url])`
vor `.setup(...)` einhaengen.
**Hilfsfunktion `fn api_url(server: &str, path: &str) -> String`**: liefert
`format!("{}/api-proxy{}", server.trim_end_matches('/'), path)` — die einzige
Stelle, an der der Rewrite-Praefix steht; Kommentar erklaert, warum
(Next.js-Rewrite, API nicht unter dem Web-Hostnamen).
**Kommando `check_server`** (`#[tauri::command] async fn check_server(url: String) -> Result<String, String>`):
`tauri::Url::parse` (Fehler: "Diese Adresse ist ungültig."), Schema
`http`/`https` (sonst "Es sind nur Adressen mit http oder https erlaubt."),
`reqwest::Client::builder().timeout(Duration::from_secs(8)).build()`,
GET `api_url(&url, "/health/version")`; Netzfehler -> "Unter dieser Adresse
antwortet kein Tessera-Server."; Nicht-2xx -> "Der Server antwortete mit
Status {code}."; JSON in die bestehende Struktur `VersionResponse` (Feld
`version`) -> `Ok(version)`. Meldungen sind Sie-Form-tauglich (keine
Anrede), Umlaute als UTF-8.
**Kommando `save_server_url`** (`#[tauri::command] fn save_server_url(app: AppHandle, url: String) -> Result<(), String>`):
Adresse parsen und als `String` normalisieren (`Url::as_str`), Store
`config.json` ueber `app.store(...)`, `store.set("server_url", serde_json::json!(normalized))`,
`store.save()` (Fehler als `String`), danach `get_webview_window("main")`
und `navigate(parsed_url)`.
**Versionspruefung umbauen.** Struktur `DesktopLatest { version: String }`
(`serde::Deserialize`). Im bestehenden `async_runtime::spawn`-Block die
Adresse durch `api_url(&server_url, "/desktop/latest")` ersetzen, Antwort
als `DesktopLatest` lesen; bei Abweichung Benachrichtigung mit Titel
"Tessera-Update" und Text "Neue Version {v} verfügbar – Download über das
Symbol im Infobereich." **und** am geklonten Handle des Tray-Eintrags
`update` `set_text(format!("Version {v} herunterladen"))` und
`set_enabled(true)` aufrufen (Rueckgaben mit `let _ =` ignorieren, wie
bisher).
**Tray-Menue.** Eintraege in dieser Reihenfolge: `open` "Öffnen";
`update` "Update herunterladen" mit `.enabled(false)` beim Bau (wird erst
nach der Pruefung freigeschaltet); Trenner; `autostart` als
`CheckMenuItemBuilder::with_id("autostart", label)` mit `.checked(app.autolaunch().is_enabled().unwrap_or(false))`,
Label `if cfg!(target_os = "windows") { "Mit Windows starten" } else { "Beim Anmelden starten" }`;
Trenner; `quit` "Beenden". Fuer `on_menu_event` vorher
`let server_for_menu = url_for_check.clone();` und
`let autostart_for_menu = autostart.clone();` anlegen (die Handles sind
`Clone + Send + Sync`). Neue `match`-Arme: `"update"` -> wenn eine Adresse
gespeichert ist, `app.opener().open_url(format!("{}/settings/general/desktop", server.trim_end_matches('/')), None::<&str>)`;
`"autostart"` -> `let mgr = app.autolaunch(); let on = mgr.is_enabled().unwrap_or(false);`
dann `mgr.disable()` bzw. `mgr.enable()`, bei Erfolg `set_checked(!on)`,
bei Fehler `set_checked(on)` (Haken bleibt bei der Wahrheit). Die
bestehenden Arme `open`/`quit`, `on_tray_icon_event`, `on_window_event`
(Schliessen-ins-Tray) und `RunEvent::ExitRequested` bleiben unveraendert
(D-14). Bestehender Kommentar "Tray menu" um zwei Saetze zu den neuen
Eintraegen ergaenzen.
Nach dem Umbau `cargo check` und `cargo clippy` ausfuehren; Clippy-Warnungen
in den **geaenderten** Zeilen beheben (bestehende Warnungen andernorts nur
beheben, wenn trivial).
</action>
<acceptance_criteria>
- `grep -c '^tauri-plugin-opener = "2"' apps/desktop/src-tauri/Cargo.toml` ergibt 1; `grep -c 'name = "tauri-plugin-opener"' apps/desktop/src-tauri/Cargo.lock` ergibt 1.
- `grep -c '"opener:allow-open-url"' apps/desktop/src-tauri/capabilities/default.json` ergibt 1.
- `grep -v '^\s*//' apps/desktop/src-tauri/src/lib.rs | grep -c 'fn check_server'` ergibt 1; ebenso `fn save_server_url` 1, `fn api_url` 1, `api-proxy` mindestens 1, `tauri_plugin_opener::init()` 1, `generate_handler!\[check_server, save_server_url\]` 1.
- `grep -c '"Öffnen"' apps/desktop/src-tauri/src/lib.rs` ergibt 1; `grep -c '"Mit Windows starten"' apps/desktop/src-tauri/src/lib.rs` ergibt 1; `grep -c 'CheckMenuItemBuilder::with_id("autostart"' apps/desktop/src-tauri/src/lib.rs` ergibt 1; `grep -c 'settings/general/desktop' apps/desktop/src-tauri/src/lib.rs` ergibt 1.
- `grep -c '/desktop/latest' apps/desktop/src-tauri/src/lib.rs` ergibt 1.
- `cargo check` und `cargo clippy` in `apps/desktop/src-tauri` enden mit Exit 0.
</acceptance_criteria>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl/apps/desktop/src-tauri && cargo check 2>&1 | tail -1 | grep -q Finished && cargo clippy 2>&1 | tail -1 | grep -q Finished && echo RUST-OK</automated>
<fails_when>`cargo check` oder `cargo clippy` endet nicht mit einer `Finished`-Zeile (Kompilier- oder Clippy-Fehler) — `RUST-OK` fehlt.</fails_when>
<automated>cd /home/vicolab/projects/tessera-ctl && grep -v '^\s*//' apps/desktop/src-tauri/src/lib.rs | grep -q 'fn check_server' && grep -q 'fn save_server_url' apps/desktop/src-tauri/src/lib.rs && grep -q 'api-proxy' apps/desktop/src-tauri/src/lib.rs && grep -q '"Öffnen"' apps/desktop/src-tauri/src/lib.rs && grep -q 'CheckMenuItemBuilder::with_id("autostart"' apps/desktop/src-tauri/src/lib.rs && grep -q 'settings/general/desktop' apps/desktop/src-tauri/src/lib.rs && grep -q '"opener:allow-open-url"' apps/desktop/src-tauri/capabilities/default.json && grep -q '^tauri-plugin-opener = "2"' apps/desktop/src-tauri/Cargo.toml && echo WIRING-OK</automated>
<fails_when>Eines der Kennzeichen (Kommandos, Rewrite-Praefix, Umlaut-Label, Autostart-Haken, Update-Link, Capability, Abhaengigkeit) fehlt — `WIRING-OK` fehlt.</fails_when>
</verify>
<done>
`lib.rs` kompiliert mit Opener-Plugin, beiden Kommandos, Versionspruefung
gegen `/api-proxy/desktop/latest`, Tray mit Update-Eintrag und
Autostart-Haken und Umlaut-Texten; Capabilities und Cargo-Dateien sind
nachgezogen; `cargo check`/`cargo clippy` gruen.
</done>
</task>
<task type="auto">
<name>Task 2: Erststart-Seite in Tessera-Gestalt und Sie-Form, echtes App-Icon, lokaler AppImage-Beweis</name>
<precondition>ImageMagick 7 (`magick`) ist auf dem Entwicklungsrechner vorhanden (am 2026-09-16 geprueft: 7.1.1, mit SVG- und ICO-Unterstuetzung); die Tauri-Linux-Abhaengigkeiten aus 18-01 Task 1 sind installiert.</precondition>
<files>
apps/desktop/src/setup.html,
apps/desktop/src-tauri/tauri.conf.json,
apps/desktop/src-tauri/icons/icon.png,
apps/desktop/src-tauri/icons/icon.ico,
apps/desktop/src-tauri/icons/128x128.png,
apps/desktop/src-tauri/icons/128x128@2x.png,
apps/desktop/src-tauri/icons/32x32.png
</files>
<read_first>
apps/desktop/src/setup.html (gesamt, 254 Zeilen),
apps/desktop/src-tauri/tauri.conf.json,
apps/web/src/app/icon.svg (Tessera-Zeichen, 72x72),
apps/web/src/components/brand/brand.ts (BRAND_YELLOW #ffed00, BRAND_OLIVE #9c9440, BRAND_PLATE #1a1a1a),
.gitea/scripts/desktop-collect.sh (aus 18-01)
</read_first>
<behavior>
- Die Seite laedt keinen Modulcode von aussen und importiert kein npm-Paket; sie nutzt `window.__TAURI__.core.invoke`.
- Klick auf "Verbinden" (oder Enter): Adresse pruefen (leer, ungueltig, falsches Schema -> Fehlertext in Sie-Form), dann `invoke('check_server', { url })`; bei Fehler erscheint die Meldung des Kommandos, der Knopf ist wieder bedienbar; bei Erfolg erscheint kurz "Tessera {version} gefunden – Verbindung wird hergestellt …" und `invoke('save_server_url', { url })` fuehrt zur Tessera-Anmeldung.
- Bei http ausserhalb von localhost bleibt die Warnung (Sie-Form) sichtbar, die Verbindung ist erlaubt (D-02).
- Die Seite zeigt das Tessera-Zeichen (inline-SVG aus `icon.svg`) und den Schriftzug "Tessera" in Markengelb auf dunklem Grund; keine vorbelegte Server-Adresse, nur der Platzhalter `https://tessera.example.com`.
- Das App-Icon ist das Tessera-Zeichen in 512x512 (PNG) und als ICO mit den Groessen 16, 32, 48, 64, 128, 256.
- `pnpm --filter @tessera/desktop exec tauri build --bundles appimage` laeuft lokal durch und `desktop-collect.sh` sammelt `Tessera-1.1.0.AppImage` ein.
</behavior>
<action>
**`setup.html` — Skript.** Den `<script type="module">`-Block umschreiben:
kein `import`-Statement mehr; am Anfang `const { invoke } = window.__TAURI__.core;`.
`validateUrl` behalten (Logik unveraendert), Meldungen ersetzen:
leer -> "Bitte geben Sie die Adresse Ihres Tessera-Servers ein.";
ungueltig -> "Diese Adresse ist ungültig. Bitte geben Sie eine vollständige
Adresse ein, z. B. https://tessera.example.com."; Schema -> "Es sind nur
Adressen mit http oder https erlaubt."; Warnung -> "Hinweis: Diese Verbindung
ist unverschlüsselt (http). Für den Produktivbetrieb empfehlen wir https.".
`connect()`: nach der Pruefung Knopf sperren, Text "Prüfe Verbindung …",
`const version = await invoke('check_server', { url: normalizedUrl })` im
`try`; im `catch` `showError(String(err))` und Knopf freigeben ("Verbinden");
bei Erfolg `showInfo('Tessera ' + version + ' gefunden – Verbindung wird
hergestellt …')` (neue Hilfsfunktion und ein `<p id="info-msg">` im gleichen
Stil wie die Warnung, gruenliche Farbe) und `await invoke('save_server_url', { url: normalizedUrl })`;
schlaegt das Speichern fehl: "Die Adresse konnte nicht gespeichert werden: …".
Enter-Taste und Eingabe-Reset bleiben.
**`setup.html` — Markup und Gestalt (Sie-Form, Tessera-Farben).** Ueber der
Ueberschrift das Tessera-Zeichen als inline-SVG (Inhalt von
`apps/web/src/app/icon.svg`, Breite 56px), `<h1>` "Tessera" in `#ffed00`,
Untertitel "Desktop-App einrichten", Label "Adresse Ihres Tessera-Servers",
darunter ein Hilfstext `<p class="hint">` "Das ist die Adresse, unter der
Sie Tessera auch im Browser öffnen." Das `value`-Attribut des Eingabefelds
entfernen (keine vorbelegte Adresse — ein Paket fuer alle Umgebungen, D-02),
Platzhalter `https://tessera.example.com` bleibt. Knopf "Verbinden" in
Markengelb mit dunkler Schrift (`#1a1a1a`), Karte dunkel
(`oklch(0.22 0.01 260)`), Rahmenfarbe in Olive (`#9c9440`) fuer Fokus.
Alle Texte mit echten Umlauten (`<meta charset="UTF-8">` steht bereits).
`<title>` "Tessera – Desktop-App einrichten".
**CSP (`tauri.conf.json`).** In `app.security.csp` bei `script-src` den
Fremdhost-Eintrag entfernen, sodass dort nur noch `'self' 'unsafe-inline' 'unsafe-eval'`
steht (die Seite laedt nichts mehr von aussen; T-18-11). `connect-src *`
bleibt (D-02).
**Icons.** Aus `apps/web/src/app/icon.svg` erzeugen (im Verzeichnis
`apps/desktop/src-tauri/icons`): `magick -background none -density 512 ../../../web/src/app/icon.svg -resize 512x512 icon.png`;
daraus `128x128.png` (128), `128x128@2x.png` (256), `32x32.png` (32) per
`-resize`; `icon.ico` mit `magick icon.png -define icon:auto-resize=256,128,64,48,32,16 icon.ico`.
In `tauri.conf.json` `bundle.icon` auf
`["icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.png", "icons/icon.ico"]`
setzen. Das versehentlich verschachtelte, versionierte Verzeichnis
`apps/desktop/src-tauri/apps/` mit `git rm -r` entfernen (Rest aus Phase 6).
**Lokaler Beweis (D-16).** `rm -rf apps/desktop/src-tauri/target/release/bundle`,
dann `pnpm --filter @tessera/desktop exec tauri build --bundles appimage`
(Version bleibt `1.1.0` aus der Basislinie), danach
`sh .gitea/scripts/desktop-collect.sh --require linux`. Im SUMMARY die
Baudauer und die Groesse des AppImage festhalten. Optional, wenn eine
grafische Sitzung vorhanden ist: das AppImage starten, Erststart-Seite
ansehen, `http://localhost:3000` eingeben, Anmeldung sehen; Ergebnis im
SUMMARY notieren (kein Pflichtschritt — die Windows-Probe macht der Nutzer
am Phasenende).
</action>
<acceptance_criteria>
- `grep -c "window.__TAURI__.core" apps/desktop/src/setup.html` ergibt 1; `grep -c "invoke('check_server'" apps/desktop/src/setup.html` ergibt 1; `grep -c "invoke('save_server_url'" apps/desktop/src/setup.html` ergibt 1.
- `grep -c '^\s*import ' apps/desktop/src/setup.html` ergibt 0 (kein Modul-Import mehr).
- `grep -c 'unpkg.com' apps/desktop/src-tauri/tauri.conf.json` ergibt 0.
- `grep -c 'value="http://localhost:3000"' apps/desktop/src/setup.html` ergibt 0; `grep -c 'Adresse Ihres Tessera-Servers' apps/desktop/src/setup.html` ergibt mindestens 1.
- `magick identify -format '%wx%h\n' apps/desktop/src-tauri/icons/icon.png` ergibt `512x512`; `magick identify apps/desktop/src-tauri/icons/icon.ico | wc -l` ergibt 6.
- `test ! -e apps/desktop/src-tauri/apps` endet mit 0 (verschachteltes Verzeichnis per `git rm -r` entfernt).
- `jq -r '.bundle.icon | length' apps/desktop/src-tauri/tauri.conf.json` ergibt 5.
- `desktop-dist/manifest.json` traegt `Tessera-1.1.0.AppImage` aus dem frischen Bau (Zeitstempel des AppImage neuer als der von Task 1 geaenderten lib.rs).
</acceptance_criteria>
<!-- planner-discipline-allow: unpkg.com -->
<!-- planner-discipline-allow: value="http://localhost:3000" -->
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl && grep -q "window.__TAURI__.core" apps/desktop/src/setup.html && grep -q "invoke('check_server'" apps/desktop/src/setup.html && grep -q "invoke('save_server_url'" apps/desktop/src/setup.html && test "$(grep -c '^\s*import ' apps/desktop/src/setup.html)" = "0" && test "$(grep -c 'unpkg.com' apps/desktop/src-tauri/tauri.conf.json)" = "0" && test "$(magick identify -format '%wx%h' apps/desktop/src-tauri/icons/icon.png)" = "512x512" && test "$(magick identify apps/desktop/src-tauri/icons/icon.ico | wc -l)" = "6" && test ! -e apps/desktop/src-tauri/apps && echo SETUP-OK</automated>
<fails_when>Ein Kennzeichen fehlt, ein Modul-Import ist noch da, der Fremdhost steht noch in der CSP, ein Icon hat die falsche Groesse/Anzahl, oder das verschachtelte Verzeichnis ist noch versioniert — `SETUP-OK` fehlt.</fails_when>
<automated>cd /home/vicolab/projects/tessera-ctl && test -n "$(find apps/desktop/src-tauri/target/release/bundle/appimage -name '*.AppImage' -newer apps/desktop/src-tauri/src/lib.rs)" && sh .gitea/scripts/desktop-collect.sh --require linux && test "$(jq -r .files.linux.name desktop-dist/manifest.json)" = "Tessera-1.1.0.AppImage" && echo APPIMAGE-OK</automated>
<fails_when>Kein AppImage, das neuer als die geaenderte `lib.rs` ist (der lokale Bau lief nicht oder scheiterte), das Sammel-Skript bricht ab, oder das Manifest nennt nicht `Tessera-1.1.0.AppImage` — `APPIMAGE-OK` fehlt.</fails_when>
</verify>
<done>
Die Erststart-Seite spricht nur noch ueber `invoke`, prueft die Adresse
serverseitig, ist in Sie-Form und Tessera-Gestalt; die CSP laedt nichts von
aussen; der Icon-Satz zeigt das Tessera-Zeichen; ein frischer lokaler
AppImage-Bau mit dem neuen Client liegt eingesammelt in `desktop-dist/`.
</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Erststart-Seite -> Rust-Kommandos (`invoke`) | Die vom Anwender eingegebene Adresse wird an `check_server`/`save_server_url` uebergeben. |
| Client -> Server (`/api-proxy/health/version`, `/api-proxy/desktop/latest`) | Ausgehende HTTP-Aufrufe an die gespeicherte Adresse. |
| Tray -> Systembrowser (`opener`) | Der Client oeffnet eine Adresse ausserhalb der App. |
| WebView -> entfernte Web-App | Nach dem Erststart laeuft die Tessera-Web-App im WebView (unveraendert seit Phase 6). |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-18-10 | Spoofing / Tampering | `check_server`/`save_server_url` (beliebige Adresse) | low | mitigate | Nur `http`/`https`, Adresse wird geparst und normalisiert; der Aufruf geht nur an die vom Anwender selbst eingegebene Adresse, ausschliesslich auf dessen Rechner (kein Server-seitiges SSRF). Zeitlimit 8 s. |
| T-18-11 | Tampering | CSP der lokalen Seite (Fremdhost in `script-src`) | low | mitigate | Fremdhost entfernt; die Seite laedt keinen externen Code mehr. |
| T-18-12 | Elevation of Privilege | Tray `update` (Opener) | low | mitigate | Adresse wird aus der gespeicherten `server_url` gebaut, nie aus Serverdaten; Capability auf `http://*`/`https://*` beschraenkt (kein `file:`/Schema-Missbrauch). |
| T-18-13 | Spoofing | Update-Hinweis aus `/desktop/latest` (falsche Version vorgetaeuscht) | low | accept | Der Hinweis fuehrt nur auf die Tessera-Seite; kein Auto-Update, kein Download ohne Nutzeraktion (D-03). |
| T-18-14 | Information Disclosure | Unsignierte Binaries / SmartScreen | low | accept | Keine Code-Signierung in dieser Phase (D-09); Erklaerung im Anwenderhandbuch (18-06). |
| T-18-SC | Tampering | `cargo add tauri-plugin-opener@2` | low | mitigate | Legitimitaetspruefung in RESEARCH: `OK` (tauri-apps/plugins-workspace, 374k Downloads/Woche); Lockfile committet; kein `[ASSUMED]`/`[SUS]`-Paket, daher keine Sperr-Freigabe noetig. |
</threat_model>
<verification>
1. `cargo check` und `cargo clippy` in `apps/desktop/src-tauri` — gruen.
2. Kennzeichen-Greps fuer Kommandos, Rewrite-Praefix, Umlaute, Capability,
Abhaengigkeit, Icon-Groessen — alle erfuellt.
3. Lokaler AppImage-Bau nach dem Umbau erfolgreich, `desktop-collect.sh`
liefert `Tessera-1.1.0.AppImage`.
4. Der Windows-Bau desselben Stands wird in 18-05 in der Pipeline bewiesen;
die Bedienprobe (Erststart, Tray, Anmeldung) macht der Nutzer am
Phasenende (18-06).
</verification>
<success_criteria>
- Erststart-Seite prueft die Adresse wirklich, speichert sie und fuehrt zur
Anmeldung; Sie-Form, Tessera-Gestalt, kein Fremdcode.
- Tray: Öffnen · Update herunterladen · Autostart-Haken · Beenden, mit
Umlauten; Update-Eintrag oeffnet die Download-Seite im Browser.
- Versionspruefung gegen `/api-proxy/desktop/latest` mit Benachrichtigung.
- Echtes App-Icon; `cargo check`/`clippy` und lokaler AppImage-Bau gruen.
</success_criteria>
<output>
Create `.planning/phases/18-desktop-client-fertigstellen/18-04-SUMMARY.md` when done.
Im SUMMARY festhalten: Baudauer und Groesse des AppImage, ob eine grafische
Probe moeglich war, und alle Clippy-Warnungen, die bewusst stehen blieben.
</output>
@@ -0,0 +1,171 @@
---
phase: 18-desktop-client-fertigstellen
plan: 04
subsystem: infra
tags: [tauri, rust, desktop-client, opener-plugin, autostart]
# Dependency graph
requires:
- phase: 18-desktop-client-fertigstellen (Plan 01)
provides: "GET /desktop/latest (@Public), DesktopLatestResponse-Form, Basislinie 1.1.0"
- phase: 18-desktop-client-fertigstellen (Plan 03)
provides: "Route /settings/general/desktop, GET /desktop/latest ueber /api-proxy/* im Web-Container"
provides:
- "Rust-Kommandos check_server/save_server_url fuer die Erststart-Seite (kein Modul-Import mehr)"
- "Versionspruefung gegen /api-proxy/desktop/latest mit Benachrichtigung + Tray-Update-Eintrag"
- "Tray-Menue: Öffnen · Update herunterladen · Autostart-Haken · Beenden, echte Umlaute"
- "Echtes App-Icon (Tessera-Zeichen) in allen Bundle-Groessen"
- "Lokal gebautes AppImage (Tessera-1.1.0.AppImage) als D-16-Beweis"
affects: [18-05-windows-cross-bau-pipeline-beweis, 18-06-freigabe-release-anhang]
actuals:
tokens: 4547
tasks: 2
commits: 2
plan_head_before: 82312ef691c86dedd08f3c71225d3b8be63630df
tech-stack:
added:
- "tauri-plugin-opener 2.5.5 (offizielles Tauri-Plugin, Legitimitaet in 18-RESEARCH.md geprueft: OK)"
patterns:
- "api_url(server, path) als einzige Stelle, die den Next.js-Rewrite-Praefix /api-proxy kennt — alle Rust-seitigen API-Aufrufe (check_server, Versionspruefung) laufen ausschliesslich darueber"
- "Erststart-Seite spricht nur noch ueber window.__TAURI__.core.invoke() mit Rust-Kommandos statt ueber einen ES-Modul-Import eines Tauri-Plugins — vermeidet den 'Failed to resolve module specifier'-Fehler im gebauten Client (kein Bundler/keine Importmap vorhanden)"
key-files:
created: []
modified:
- apps/desktop/src-tauri/src/lib.rs
- apps/desktop/src-tauri/Cargo.toml
- apps/desktop/src-tauri/Cargo.lock
- apps/desktop/src-tauri/capabilities/default.json
- apps/desktop/src-tauri/tauri.conf.json
- apps/desktop/src/setup.html
- apps/desktop/src-tauri/icons/icon.png
- apps/desktop/src-tauri/icons/icon.ico
- apps/desktop/src-tauri/icons/128x128.png
- apps/desktop/src-tauri/icons/128x128@2x.png
- apps/desktop/src-tauri/icons/32x32.png
key-decisions:
- "cargo add tauri-plugin-opener@2 ausgefuehrt statt Cargo.toml/Cargo.lock von Hand zu pflegen — Cargo.lock bleibt damit fuer den echten Dependency-Graphen konsistent (Cargo hat zusaetzlich open, is-docker, is-wsl als transitive Abhaengigkeiten des Plugins aufgeloest)."
- "Autostart-Umschaltung setzt den Haken im Fehlerfall bewusst auf den vor dem Klick gemessenen Ist-Zustand zurueck (set_checked(currently_on) statt eines optimistischen Toggles), damit der Haken nie eine falsche Systemwahrheit anzeigt, wenn enable()/disable() fehlschlaegt."
- "Versionspruefung liest die Zieladresse jetzt ausschliesslich ueber die neue api_url()-Hilfsfunktion, damit /health/version (im Kommando check_server) und /desktop/latest (im Setup-Block) denselben Rewrite-Praefix garantiert konsistent verwenden."
patterns-established: []
requirements-completed: [DESK-01, DESK-02, DESK-05]
coverage:
- id: D1
description: "Versionspruefung gegen /api-proxy/desktop/latest mit Benachrichtigung 'Neue Version X.Y.Z verfuegbar' und freigeschaltetem, umbenanntem Tray-Eintrag 'Update herunterladen', der die Einstellungsseite im Systembrowser oeffnet"
requirement: "DESK-05"
verification:
- kind: other
ref: "grep-Batterie (fn api_url, /desktop/latest, settings/general/desktop, tauri_plugin_opener::init()) + cargo check/cargo clippy gruen"
status: pass
human_judgment: true
rationale: "Das tatsaechliche Ausloesen der Benachrichtigung und das Umschalten des Tray-Eintrags laesst sich nur gegen einen laufenden Server mit abweichender Version und einer grafischen Sitzung beobachten — beides stand in dieser Ausfuehrungsumgebung nicht zur Verfuegung (kopflos, kein Display). Die Bedienprobe macht der Nutzer laut Plan-Verifikation Punkt 4 in 18-06."
- id: D2
description: "Erststart-Seite prueft die Adresse ueber das Rust-Kommando check_server (kein Modul-Import mehr), speichert sie ueber save_server_url und fuehrt zur Tessera-Anmeldung; Sie-Form, Tessera-Farben/-Zeichen, kein vorbelegter Wert"
requirement: "DESK-02"
verification:
- kind: other
ref: "grep-Batterie (SETUP-OK: __TAURI__.core, invoke('check_server'/'save_server_url'), kein import, kein unpkg.com, keine vorbelegte Adresse, Label vorhanden)"
status: pass
human_judgment: true
rationale: "Der volle Ablauf (Adresse eingeben, echten Server erreichen, zur Anmeldeseite navigieren) braucht ein gestartetes AppImage mit grafischer Sitzung und einen laufenden Tessera-Server — nicht Teil dieses Plans (kein Pflichtschritt laut Action-Abschnitt), Bedienprobe folgt in 18-06."
- id: D3
description: "Tray-Menue in der Reihenfolge Öffnen · Update herunterladen · Autostart-Haken (Windows: 'Mit Windows starten', sonst 'Beim Anmelden starten') · Beenden, mit echten Umlauten; Autostart-Haken spiegelt beim Start den Systemzustand"
requirement: "DESK-05"
verification:
- kind: other
ref: "grep-Batterie (\"Öffnen\", \"Mit Windows starten\", CheckMenuItemBuilder::with_id(\"autostart\") je genau 1 Treffer) + cargo check/cargo clippy gruen"
status: pass
human_judgment: true
rationale: "Die sichtbare Tray-Darstellung (Reihenfolge, Umlaute im echten Rendering, Haken-Zustand) laesst sich nur in einer grafischen Sitzung mit laufender App pruefen, nicht headless. Bedienprobe folgt in 18-06."
- id: D4
description: "Client traegt das Tessera-Zeichen als App-/Fenster-Icon in allen Bundle-Groessen (512 PNG, 128/128@2x/32 PNG, ICO 16-256); cargo check, cargo clippy und ein lokaler AppImage-Bau laufen durch"
requirement: "DESK-05"
verification:
- kind: other
ref: "cargo check/cargo clippy (Finished, 0 Warnungen) + magick identify (icon.png 512x512, icon.ico 6 Groessen) + lokaler Bau pnpm --filter @tessera/desktop exec tauri build --bundles appimage + desktop-collect.sh --require linux (Tessera-1.1.0.AppImage im Manifest)"
status: pass
human_judgment: false
duration: 9min
completed: 2026-09-16
status: complete
---
# Phase 18 Plan 04: Client-Fertigstellung — Update-Hinweis, Tray-Autostart, Erststart-Seite, echtes Icon Summary
**`lib.rs` bekommt zwei neue Rust-Kommandos (`check_server`/`save_server_url`) fuer eine modul-import-freie Erststart-Seite, die Versionspruefung laeuft jetzt ueber `/api-proxy/desktop/latest` mit Benachrichtigung und einem sich selbst umbenennenden Tray-Eintrag, das Tray traegt echte Umlaute und einen Autostart-Haken, und der Client hat erstmals ein echtes Tessera-Icon statt der flachen gelben Platzhalter-Quadrate — ein frischer lokaler AppImage-Bau (103 MB) beweist, dass alles zusammen kompiliert und buendelt.**
## Performance
- **Duration:** 9 min
- **Started:** 2026-09-16T14:32:35Z
- **Completed:** 2026-09-16T14:41:21Z
- **Tasks:** 2
- **Files modified:** 15 (davon 4 durch `git rm -r` entfernt)
## Accomplishments
- `lib.rs`: `tauri-plugin-opener` eingebunden, neue Hilfsfunktion `api_url()` als einzige Stelle mit dem `/api-proxy`-Rewrite-Praefix, zwei neue Kommandos `check_server` (prueft `/api-proxy/health/version` mit 8s-Zeitlimit, deutsche Sie-Form-Fehlermeldungen) und `save_server_url` (normalisiert, speichert im Store, navigiert das Fenster); beide ueber `invoke_handler` registriert.
- Versionspruefung beim Start laeuft jetzt gegen `/api-proxy/desktop/latest` statt `/health/version`; bei Abweichung erscheint die Benachrichtigung "Tessera-Update" / "Neue Version X.Y.Z verfuegbar – Download ueber das Symbol im Infobereich." und der Tray-Eintrag "Update herunterladen" wird umbenannt ("Version X.Y.Z herunterladen") und freigeschaltet.
- Tray-Menue neu geordnet: Öffnen · Update herunterladen · — · Autostart-Haken (Windows: "Mit Windows starten", sonst "Beim Anmelden starten") · — · Beenden — mit echten Umlauten (vorher "Oeffnen"/"Beenden"). Der Update-Eintrag oeffnet `{server}/settings/general/desktop` per `tauri-plugin-opener` im Systembrowser; der Autostart-Haken spiegelt beim Start `autolaunch().is_enabled()` und schaltet bei Klick um, faellt bei einem Fehlschlag von `enable()`/`disable()` auf den tatsaechlichen Zustand zurueck.
- `setup.html` spricht nur noch ueber `window.__TAURI__.core.invoke` (kein `<script type="module"> import` mehr — der bisherige Import des Store-Plugins scheiterte im gebauten Client ohne Bundler/Importmap mit "Failed to resolve module specifier"). Texte in Sie-Form, Tessera-Zeichen als inline-SVG, Markengelb/-Olive, keine vorbelegte Server-Adresse mehr.
- Neuer Icon-Satz aus `apps/web/src/app/icon.svg` erzeugt (512 PNG, 128/128@2x/32 PNG, ICO mit 16-256) statt der bisherigen 105-Byte-Platzhalter-Quadrate; CSP ohne `unpkg.com`, da nichts mehr von aussen geladen wird; versehentlich verschachteltes `apps/desktop/src-tauri/apps/`-Verzeichnis aus Phase 6 entfernt.
- `cargo check` und `cargo clippy` liefen beide ohne Fehler und ohne Warnungen durch; ein lokaler `pnpm --filter @tessera/desktop exec tauri build --bundles appimage`-Lauf (Rust-Kompilierung 55,64s, Gesamtlauf rund 1 Minute) erzeugte `Tessera_1.1.0_amd64.AppImage` (107.366.904 Bytes, SHA-256 `41b70efe...`), von `desktop-collect.sh --require linux` erfolgreich eingesammelt und im Manifest als `Tessera-1.1.0.AppImage` gefuehrt.
## Task Commits
Each task was committed atomically:
1. **Task 1: lib.rs — Kommandos fuer die Erststart-Seite, Versionspruefung gegen /desktop/latest, Tray mit Update und Autostart** - `8b130fd` (feat)
2. **Task 2: Erststart-Seite in Tessera-Gestalt und Sie-Form, echtes App-Icon, lokaler AppImage-Beweis** - `2d55f07` (feat)
**Plan metadata:** commit pending (this SUMMARY + STATE.md/ROADMAP.md/REQUIREMENTS.md)
## Files Created/Modified
- `apps/desktop/src-tauri/src/lib.rs` - `api_url`, `check_server`, `save_server_url`, Versionspruefung gegen `/api-proxy/desktop/latest`, Tray mit `update`/`autostart`-Eintraegen, Umlaut-Texte
- `apps/desktop/src-tauri/Cargo.toml` / `Cargo.lock` - `tauri-plugin-opener = "2"` (plus transitive `open`, `is-docker`, `is-wsl`)
- `apps/desktop/src-tauri/capabilities/default.json` - `opener:allow-open-url` mit `http`/`https`-Scope
- `apps/desktop/src-tauri/tauri.conf.json` - CSP ohne `unpkg.com`, `bundle.icon` um `32x32.png`/`128x128.png`/`128x128@2x.png` erweitert
- `apps/desktop/src/setup.html` - `invoke`-basierte Erststart-Seite, Sie-Form, Tessera-Gestalt
- `apps/desktop/src-tauri/icons/{icon.png,icon.ico,128x128.png,128x128@2x.png,32x32.png}` - Neuer Icon-Satz aus `icon.svg`
- `apps/desktop/src-tauri/apps/` (entfernt) - versehentlich verschachteltes Verzeichnis aus Phase 6
## Decisions Made
- `cargo add tauri-plugin-opener@2` statt manueller Cargo.toml/Cargo.lock-Pflege — Cargo aufgeloest transitive Abhaengigkeiten (`open`, `is-docker`, `is-wsl`) korrekt, Lockfile bleibt konsistent zum echten Dependency-Graphen.
- Autostart-Umschaltung setzt den Haken bei einem Fehlschlag von `enable()`/`disable()` explizit auf den vorher gemessenen Ist-Zustand zurueck statt optimistisch umzuschalten — der Haken zeigt nie eine falsche Systemwahrheit.
- Sowohl `check_server` (`/health/version`) als auch die Versionspruefung im Setup-Block (`/desktop/latest`) laufen jetzt ausschliesslich ueber dieselbe `api_url()`-Hilfsfunktion, damit der Rewrite-Praefix `/api-proxy` an genau einer Stelle im Code steht.
## Deviations from Plan
None - plan executed exactly as written.
## Issues Encountered
None. Weder `cargo check` noch `cargo clippy` meldeten Warnungen — es blieb keine Clippy-Warnung bewusst stehen.
## Baudaten (Auftrag des Output-Abschnitts)
- **AppImage:** `Tessera_1.1.0_amd64.AppImage`, 107.366.904 Bytes (~103 MB), SHA-256 `41b70efe1c03ab30b5914e327ee000c1c1f733ec980a53243506618e0204b6a8`.
- **Baudauer:** Rust-Kompilierung 55,64s laut `cargo`-Ausgabe (release-Profil); Gesamtlauf inkl. Bundling rund 1 Minute Wanduhrzeit (Bau gestartet 14:37:58Z, AppImage fertig 14:40:59Z laut Manifest-`buildTime`).
- **Grafische Probe:** Nicht moeglich — diese Ausfuehrungsumgebung ist kopflos (kein Display, kein X11/Wayland-Socket). Das AppImage wurde nicht gestartet; die Bedienprobe (Erststart-Seite, Tray, Anmeldung) macht der Nutzer laut Plan-Verifikation Punkt 4 in 18-06.
- **Clippy-Warnungen:** Keine — `cargo clippy` endete ohne jede Warnung, nichts musste bewusst stehen bleiben.
## User Setup Required
None - no external service configuration required.
## Next Phase Readiness
- `lib.rs`, `setup.html`, Icon-Satz und Capabilities sind auf dem Stand, den 18-05 fuer den Windows-Cross-Bau (`cargo-xwin`, NSIS) uebernehmen kann — derselbe Code, nur ein anderes Bau-Target.
- `desktop-dist/manifest.json` steht lokal auf `Tessera-1.1.0.AppImage`; der naechste `desktop-collect.sh`-Lauf in der Pipeline (18-05) ueberschreibt es mit dem CI-gebauten Paar aus Linux+Windows.
- Kein Blocker. Die grafische Bedienprobe (Tray-Umlaute im echten Rendering, Update-Benachrichtigung gegen einen Server mit abweichender Version, Erststart-Ablauf bis zur Anmeldung) ist laut Plan kein Pflichtschritt dieses Plans und wird in 18-06 durchgefuehrt.
---
*Phase: 18-desktop-client-fertigstellen*
*Completed: 2026-09-16*
## Self-Check: PASSED
All modified/created files verified on disk (`lib.rs`, `Cargo.toml`, `capabilities/default.json`, `tauri.conf.json`, `setup.html`, alle fuenf Icon-Dateien). Verschachteltes `apps/desktop/src-tauri/apps/` bestaetigt entfernt. Beide Task-Commits (`8b130fd`, `2d55f07`) im `git log` gefunden. Plan-Verifikation erneut ausgefuehrt: `RUST-OK` (`cargo check`/`cargo clippy`, beide `Finished`, 0 Warnungen), `WIRING-OK` (Kommandos, Rewrite-Praefix, Umlaut-Label, Autostart-Haken, Update-Link, Capability, Abhaengigkeit), `SETUP-OK` (kein Modul-Import, kein Fremdhost in der CSP, Icon-Groessen/-Anzahl korrekt, verschachteltes Verzeichnis entfernt), `APPIMAGE-OK` (frisches AppImage neuer als `lib.rs`, `desktop-collect.sh` liefert `Tessera-1.1.0.AppImage`).
@@ -0,0 +1,308 @@
---
phase: 18-desktop-client-fertigstellen
plan: 05
type: execute
wave: 3
depends_on: ["18-02", "18-04"]
files_modified:
- .gitea/workflows/ci.yml
- .gitea/scripts/desktop-collect.sh
- .gitea/scripts/publish-release.sh
- apps/desktop/src-tauri/Cargo.toml
- apps/desktop/src-tauri/Cargo.lock
autonomous: false
requirements: [DESK-01, DESK-04, DESK-05]
user_setup: []
estimate:
tokens: 70000
raw_tokens: 70000
tasks: 3
confidence: low
must_haves:
truths:
- "Der CI-Job desktop baut auf dem Linux-Runner zusaetzlich den Windows-Installer per Cross-Bau (cargo-xwin, NSIS) und sammelt `Tessera-Setup-X.Y.Z.exe` neben `Tessera-X.Y.Z.AppImage` ein; das Manifest traegt beide Plattformen (D-04, D-05)."
- "Ein Push auf main endet mit einem gruenen Lauf: Job desktop mit beiden Dateien, Job publish mit Abbildern, die die Pakete tragen (D-06, D-08)."
- "Jeder Fehlschlag der Pipeline wird gelesen, der Job angepasst, erneut gepusht — hoechstens drei Runden, jede als normaler Commit (D-16)."
artifacts:
- path: ".gitea/workflows/ci.yml"
provides: "Windows-Cross-Bau-Schritte im Job desktop, Einsammeln mit --require linux,windows"
contains: "cargo-xwin"
key_links:
- from: ".gitea/workflows/ci.yml (Schritt Windows NSIS Cross-Bau)"
to: ".gitea/scripts/desktop-collect.sh"
via: "Bundle-Verzeichnis target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe -> Tessera-Setup-X.Y.Z.exe"
pattern: "x86_64-pc-windows-msvc"
- from: ".gitea/workflows/ci.yml (desktop)"
to: ".gitea/workflows/ci.yml (publish)"
via: "actions/cache Schluessel desktop-dist-${{ gitea.sha }} (aus 18-02)"
pattern: "desktop-dist-"
---
<objective>
Der Windows-Installer entsteht im selben CI-Job wie das AppImage — als
Cross-Bau auf dem Linux-Runner (`cargo tauri build --runner cargo-xwin --target x86_64-pc-windows-msvc --bundles nsis`,
NSIS aus dem Ubuntu-Paket). Weil dieser Bau nur in der Pipeline beweisbar ist
(kein Windows-Werkzeug auf dem Entwicklungsrechner, siehe RESEARCH), enthaelt
der Plan die in D-16 vorgesehene Iterationsschleife: pushen, Protokoll lesen,
Job anpassen, erneut pushen — hoechstens drei Runden.
Purpose: D-04, D-05, D-06 und D-16 aus 18-CONTEXT.md; Erfolgskriterium 1
(bis auf den Release-Anhang, der erst beim naechsten Freigabe-Tag sichtbar
wird — der Upload-Pfad selbst ist in 18-02 gebaut und per Probelauf geprueft).
Output: Erweiterter Job `desktop`, gruener Pipeline-Lauf mit beiden Dateien,
Beta-Abbilder mit Paketen.
**Rollen:** Der Executor pusht nie. Der Orchestrator pusht (`git push`; die
Push-Adresse zeigt auf `localhost:3002`), beobachtet den Lauf in Gitea und
meldet Status und Protokollauszug zurueck. Der Executor liest, behebt,
committet.
</objective>
## Artifacts this phase produces
Dieser Plan: `.gitea/workflows/ci.yml` (Schritte "Windows-Werkzeuge",
"Windows-Installer bauen (Cross-Bau)", erweiterte Cache-Pfade, Einsammeln
mit `--require linux,windows`); bei Bedarf Korrekturen an
`.gitea/scripts/desktop-collect.sh`, `.gitea/scripts/publish-release.sh`
(`GITEA_API`-Umgehung) und `apps/desktop/src-tauri/Cargo.toml`
(`rustls-tls`-Ausweichlösung). Gesamtliste der Phase: siehe 18-01-PLAN.md.
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/18-desktop-client-fertigstellen/18-CONTEXT.md
@.planning/phases/18-desktop-client-fertigstellen/18-RESEARCH.md
@.planning/phases/18-desktop-client-fertigstellen/18-01-SUMMARY.md
@.planning/phases/18-desktop-client-fertigstellen/18-02-SUMMARY.md
@.planning/phases/18-desktop-client-fertigstellen/18-04-SUMMARY.md
@.gitea/workflows/ci.yml
@.gitea/scripts/desktop-collect.sh
@.gitea/scripts/publish-release.sh
@apps/desktop/src-tauri/Cargo.toml
</context>
<tasks>
<task type="auto">
<name>Task 1: Windows-Cross-Bau in den Job desktop einbauen</name>
<reversibility rating="reversible">Reine Workflow-Schritte; Rueckbau ist ein Commit, kein Zustand ausserhalb des Runners ausser dem Cache.</reversibility>
<files>
.gitea/workflows/ci.yml
</files>
<read_first>
.gitea/workflows/ci.yml (Job desktop aus 18-02),
.gitea/scripts/desktop-collect.sh (Windows-Zweig: Bundle-Pfad und Zielname),
.planning/phases/18-desktop-client-fertigstellen/18-RESEARCH.md (Abschnitte "Pattern 1", "Standard Stack: Installation", "Common Pitfalls 2-5", "Open Questions 2-3"),
.planning/phases/18-desktop-client-fertigstellen/18-CONTEXT.md (Abschnitt "Specific Ideas": Runner 8 Kerne/15 GB, nacheinander im selben Job)
</read_first>
<action>
Im Job `desktop` (Datei `.gitea/workflows/ci.yml`) folgende Aenderungen,
Schrittnamen deutsch:
1. Schritt "Systemabhaengigkeiten": die apt-Liste um `lld llvm clang nsis`
erweitern (alle vier am 2026-09-16 im Runner-Abbild per `apt-cache policy`
bestaetigt: lld/llvm/clang 18, nsis 3.09).
2. Neuer Schritt "Windows-Werkzeuge" nach "Rust-Toolchain":
`rustup target add x86_64-pc-windows-msvc` und
`command -v cargo-xwin >/dev/null 2>&1 || cargo install --locked cargo-xwin`
(Legitimitaetspruefung in RESEARCH: `OK`, rust-cross/cargo-xwin, 0.23.1).
3. Schritt "Cargo-Zwischenspeicher": `path` um `~/.cargo/bin/cargo-xwin`,
`~/.cache/cargo-xwin` (Windows-SDK-Ablage von cargo-xwin, mehrere hundert
MB, soll nur einmal geladen werden) und `~/.local/share/tauri` (NSIS-Plugins,
die der Tauri-Bundler beim ersten Windows-Bau laedt) erweitern.
4. Schritt "Alte Bundles entfernen": zusaetzlich
`rm -rf apps/desktop/src-tauri/target/x86_64-pc-windows-msvc/release/bundle`.
5. Neuer Schritt "Windows-Installer bauen (Cross-Bau)" **nach** dem
AppImage-Schritt (nacheinander, ein Job, ein Cache — CONTEXT "Specific
Ideas"): `pnpm --filter @tessera/desktop exec tauri build --runner cargo-xwin --target x86_64-pc-windows-msvc --bundles nsis`.
6. Schritt "Pakete einsammeln": `--require linux,windows`.
7. Kopfkommentar des Jobs: zwei Saetze zum Cross-Bau und zum Grund, warum
die Version rein numerisch bleibt (Pitfall 2).
Keine `-j`-Begrenzung und keine `CARGO_BUILD_JOBS`-Vorgabe im ersten Anlauf;
beides ist eine Ausweichlösung der Schleife (Task 3), falls der Runner den
Speicher ausschoepft. `desktop-collect.sh` braucht keine Aenderung, wenn der
Windows-Zweig aus 18-01 (desktop-collect.sh) den Pfad
`target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe` bereits kennt —
pruefen, sonst nachziehen.
</action>
<acceptance_criteria>
- `grep -c 'cargo-xwin' .gitea/workflows/ci.yml` ergibt mindestens 3 (Install, Cache-Pfad, Bauschritt).
- `grep -c -- '--target x86_64-pc-windows-msvc --bundles nsis' .gitea/workflows/ci.yml` ergibt 1.
- `grep -c 'desktop-collect.sh --require linux,windows' .gitea/workflows/ci.yml` ergibt 1; `grep -c 'desktop-collect.sh --require linux$' .gitea/workflows/ci.yml` ergibt 0.
- Die apt-Zeile enthaelt `nsis`, `lld`, `llvm` und `clang` (`grep -E 'lld llvm clang nsis|nsis' .gitea/workflows/ci.yml`).
- `grep -c 'x86_64-pc-windows-msvc/release/bundle/nsis' .gitea/scripts/desktop-collect.sh` ergibt mindestens 1.
- `sh -n .gitea/scripts/desktop-collect.sh` endet mit 0.
</acceptance_criteria>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl && test "$(grep -c 'cargo-xwin' .gitea/workflows/ci.yml)" -ge 3 && grep -q -- '--target x86_64-pc-windows-msvc --bundles nsis' .gitea/workflows/ci.yml && grep -q 'desktop-collect.sh --require linux,windows' .gitea/workflows/ci.yml && grep -q 'nsis' .gitea/workflows/ci.yml && grep -q 'x86_64-pc-windows-msvc/release/bundle/nsis' .gitea/scripts/desktop-collect.sh && sh -n .gitea/scripts/desktop-collect.sh && node -e "const y=require('fs').readFileSync('.gitea/workflows/ci.yml','utf8');const d=y.indexOf('\n desktop:'),p=y.indexOf('\n publish:');if(d===-1||p===-1||d>p)process.exit(1);const job=y.slice(d,p);if(job.indexOf('--bundles appimage')>job.indexOf('--bundles nsis'))process.exit(2)" && echo WINDOWS-STEPS-OK</automated>
<fails_when>Ein Kennzeichen fehlt, das Einsammeln fordert Windows nicht, das Sammel-Skript kennt den NSIS-Pfad nicht, oder der Windows-Schritt steht vor dem AppImage-Schritt (Exit 2) — `WINDOWS-STEPS-OK` fehlt.</fails_when>
</verify>
<done>
Der Job `desktop` installiert Windows-Werkzeuge, baut nach dem AppImage den
NSIS-Installer per Cross-Bau, cached SDK und NSIS-Plugins und sammelt beide
Dateien ein. Commit liegt bereit fuer den Push.
</done>
</task>
<task type="checkpoint:human-action" gate="blocking">
<name>Task 2: Push und CI-Lauf beobachten — gruen mit beiden Dateien?</name>
<precondition>Der Commit aus Task 1 (bzw. aus der letzten Runde von Task 3) liegt lokal auf `main`; der Gitea-Runner `gitea-runner` laeuft (Container aktiv), das Secret `REGISTRY_TOKEN` ist gesetzt.</precondition>
<action>Den Stand nach Gitea pushen und den Pipeline-Lauf "Tessera CI/CD" beobachten — der Executor darf nicht pushen (Projektregel: Push nur durch Orchestrator/Nutzer, Push-Adresse zeigt dauerhaft auf `localhost:3002`, nie ueber `git.vicolab.de`).</action>
<instructions>
Der Executor hat den Job `desktop` um den Windows-Cross-Bau erweitert,
Skripte und Workflow statisch geprueft und committet. Was jetzt nur der
Orchestrator kann: `git push` auf `main` und den Lauf in Gitea verfolgen
(Gitea-MCP oder Weboberflaeche). Der erste Lauf dauert deutlich laenger als
bisher (Rust-Toolchain, zwei Release-Baue, Windows-SDK-Download); erst mit
warmem Cache sinkt die Zeit.
Zurueckmelden — je nach Ausgang:
**Gruen:** Aus dem Job `desktop`, Schritt "Pakete einsammeln", die beiden
Ausgabezeilen (`windows: Tessera-Setup-1.1.0-beta.{sha}.exe (…)` und
`linux: Tessera-1.1.0-beta.{sha}.AppImage (…)`), dazu Status des Jobs
`publish` (gruen) und die Zeile mit den gepushten Etiketten.
**Rot:** Name des gescheiterten Jobs und Schritts sowie die letzten rund 60
Protokollzeilen dieses Schritts (mit der eigentlichen Fehlermeldung — bei
Rust die Zeilen ab `error:` bzw. `error[E…]`, bei apt die Zeile `E:`, bei
curl den HTTP-Code und die Antwort). Diese Runde zaehlt (Runde 1 von
hoechstens 3).
</instructions>
<verification>Der Lauf ist gruen; Schritt "Pakete einsammeln" nennt genau eine `.exe` und genau ein `.AppImage`; der Job `publish` hat die Beta-Abbilder gepusht. Der Executor prueft nach der Rueckmeldung zusaetzlich `git status --porcelain` (leer) und dass `git log -1 --format=%H` dem vom Orchestrator genannten Lauf-Commit entspricht.</verification>
<resume-signal>Antworte mit "gruen" plus den beiden Dateizeilen — oder mit "rot" plus Job, Schritt und Protokollauszug.</resume-signal>
<verify>
<human-check>Der Lauf ist gruen; Schritt "Pakete einsammeln" nennt genau eine `.exe` und genau ein `.AppImage`; der Job `publish` hat die Beta-Abbilder gepusht.</human-check>
</verify>
<done>
Rueckmeldung liegt vor. Bei "gruen" ist der Plan fertig (Task 3 entfaellt).
Bei "rot" geht es mit Task 3 weiter.
</done>
</task>
<task type="auto">
<name>Task 3: Iterationsschleife — Fehler lesen, Job anpassen, erneut pushen (hoechstens drei Runden)</name>
<files>
.gitea/workflows/ci.yml,
.gitea/scripts/desktop-collect.sh,
.gitea/scripts/publish-release.sh,
apps/desktop/src-tauri/Cargo.toml,
apps/desktop/src-tauri/Cargo.lock
</files>
<read_first>
Der vom Orchestrator gelieferte Protokollauszug,
.planning/phases/18-desktop-client-fertigstellen/18-RESEARCH.md (Abschnitte "Common Pitfalls 1-5", "Open Questions", "Assumptions Log"),
.gitea/workflows/ci.yml,
.gitea/scripts/desktop-collect.sh
</read_first>
<action>
Nur ausfuehren, wenn Task 2 "rot" gemeldet hat. Je Runde: Ursache aus dem
Protokoll bestimmen, **eine** gezielte Aenderung machen, lokal pruefen
(`sh -n` fuer Skripte, `cargo check` bei Cargo-Aenderungen), committen mit
`ci(desktop): Runde N — {Ursache in fuenf Woertern}`, dann zurueck zu
Task 2 (der Orchestrator pusht und meldet). Nach der dritten roten Runde
**stoppen** und dem Nutzer den Stand mit dem letzten Protokollauszug
vorlegen (kein vierter Versuch ohne Ruecksprache).
Bekannte Fehlerbilder und die jeweils vorgesehene Aenderung (in dieser
Reihenfolge pruefen):
| Signatur im Protokoll | Ursache | Aenderung |
|---|---|---|
| `E: Unable to locate package …` | Paketname falsch/umbenannt | Namen mit `docker run --rm gitea/runner-images:ubuntu-latest sh -c 'apt-get update -qq; apt-cache policy {name}'` pruefen und in der apt-Zeile korrigieren |
| `The system library '…' required by crate '…' was not found` (pkg-config) | dev-Paket fehlt | fehlendes `lib…-dev` in die apt-Zeile aufnehmen (Pitfall 5) |
| `failed to run custom build command for openssl-sys` beim Ziel `x86_64-pc-windows-msvc` | TLS-Backend zieht OpenSSL fuer das Windows-Ziel | in `Cargo.toml` `reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }` (Pitfall 3), lokal `cargo check`, `Cargo.lock` mit committen |
| `makensis: not found` / `NSIS … not installed` | NSIS fehlt im PATH | `nsis` in der apt-Zeile pruefen; sonst Pfad `/usr/bin/makensis` per `which makensis` im Protokoll ausgeben lassen |
| `failed to download NSIS plugin` / `nsis_tauri_utils` | Netz/GitHub | gleicher Stand, erneut pushen (leerer Commit `ci(desktop): Runde N — erneuter Lauf`) |
| `llvm-rc` / `rc.exe` / `winres` / `embed-resource` | Ressourcen-Compiler nicht gefunden | `sudo ln -sf /usr/bin/llvm-rc-18 /usr/bin/llvm-rc` im Schritt "Windows-Werkzeuge" oder `env: RC: llvm-rc-18` am Bauschritt |
| `xwin` / `Failed to download` / `manifest` beim ersten Cross-Bau | Windows-SDK-Download | erneut pushen; falls wiederholt: `env: XWIN_ARCH: x86_64` und `XWIN_CACHE_DIR: ${{ github.workspace }}/.xwin-cache` (dann `.xwin-cache` in die Cache-Pfade) |
| `optional build metadata in app version must be numeric-only` | Version nicht numerisch | `git describe`-Ausgabe im Protokoll pruefen; `desktop-version.sh` haette abbrechen muessen — Regex im Skript nachziehen |
| `genau eine Datei erwartet` (Sammel-Skript) | Bundle-Pfad oder Altbestand | Pfad mit `find apps/desktop/src-tauri/target -name '*.exe' -path '*bundle*'` im Protokoll ermitteln und im Skript anpassen; Aufraeum-Schritt pruefen |
| `Cache service responded with 4xx/5xx` / `Failed to save` / `fail-on-cache-miss` obwohl gespeichert | actions/cache-Version vs. Cache-Server | `actions/cache/save@v3` und `actions/cache/restore@v3` (Open Question 3); bleibt es rot: `target` aus den Cache-Pfaden nehmen (zu gross) |
| `Killed` / `signal: 9` / `memory` waehrend `rustc` | Speicher | `env: CARGO_BUILD_JOBS: 4` am Job (CONTEXT "Specific Ideas") |
| Job-Zeitueberschreitung | Baudauer plus Cache-Sicherung | `target` aus den Cache-Pfaden nehmen, `~/.cargo/registry` und xwin-Ablage behalten |
| `docker build` scheitert an `COPY desktop-dist` | Verzeichnis fehlt im Kontext | Cache-Restore-Pfad und `test -f`-Schritt in `publish` pruefen |
| Release-Upload `413` (nur bei Tags) | Proxy-Groessengrenze vor `git.vicolab.de` | am Release-Schritt `env: GITEA_API: http://172.18.0.1:3002/api/v1` (Host-Adresse, ueber die der Runner auch seinen Cache-Server erreicht) |
| Release-Upload `400` mit "file type" (nur bei Tags) | Gitea `[repository.release] ALLOWED_TYPES` eingeschraenkt | nicht im Repository loesbar — dem Nutzer melden (Server-Einstellung); Voreinstellung der Instanz laesst alle Typen zu (geprueft 2026-09-16) |
| `cargo clippy` Fehler | Code | Stelle beheben, `cargo clippy` lokal gruen |
Jede Runde im SUMMARY festhalten: Signatur, Ursache, Aenderung, Commit.
Trifft keine Signatur zu, die Ursache aus dem Protokoll ableiten und die
kleinste plausible Aenderung waehlen; im Zweifel zuerst mehr Protokoll
anfordern (z. B. `RUST_BACKTRACE=1` oder `--verbose` am Bauschritt), statt
zu raten.
</action>
<acceptance_criteria>
- Jede Runde ist genau ein Commit mit Praefix `ci(desktop): Runde N —` (`git log --oneline -5 | grep -c 'ci(desktop): Runde'` entspricht der Rundenzahl).
- Nach jeder Aenderung: `sh -n` fuer geaenderte Skripte endet mit 0; bei Cargo-Aenderungen endet `cargo check` in `apps/desktop/src-tauri` mit 0.
- Es gibt nie mehr als drei Runden; nach der dritten roten Runde wird der Stand dem Nutzer vorgelegt statt weiter zu pushen.
- Am Ende: Task 2 hat "gruen" mit genau einer `.exe`- und einer `.AppImage`-Zeile gemeldet.
</acceptance_criteria>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl && sh -n .gitea/scripts/desktop-collect.sh && sh -n .gitea/scripts/publish-release.sh && sh -n .gitea/scripts/desktop-version.sh && (cd apps/desktop/src-tauri && cargo check 2>&1 | tail -1 | grep -q Finished) && test "$(git rev-list --count --grep='ci(desktop): Runde' HEAD~6..HEAD)" -le 3 && echo ROUND-OK</automated>
<fails_when>Ein Skript hat einen Syntaxfehler, `cargo check` scheitert nach einer Cargo-Aenderung, oder es gibt mehr als drei Runden-Commits — `ROUND-OK` fehlt.</fails_when>
<human-check>Der Orchestrator bestaetigt nach der letzten Runde einen gruenen Lauf mit beiden Dateizeilen (Task 2).</human-check>
</verify>
<done>
Ein gruener Pipeline-Lauf auf `main` mit `Tessera-Setup-1.1.0-beta.{sha}.exe`
und `Tessera-1.1.0-beta.{sha}.AppImage` im Schritt "Pakete einsammeln" und
gruenem `publish`; hoechstens drei dokumentierte Runden.
</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Runner -> Internet (rustup, crates.io, Microsoft-SDK ueber xwin, NSIS-Plugins von GitHub) | Der Cross-Bau laedt Werkzeuge und das Windows-SDK aus dem Netz. |
| Runner-Cache -> Bau | Aus dem Cache wiederhergestellte Artefakte (SDK, target/) fliessen in das Paket ein. |
| Gebautes `.exe` -> Anwender-PC | Unsigniert; SmartScreen warnt (D-09). |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-18-15 | Tampering | Werkzeugketten-Download (cargo-xwin, SDK, NSIS-Plugins) | medium | mitigate | `cargo install --locked cargo-xwin` (Lockfile des Werkzeugs), rustup von der offiziellen Adresse, SDK ueber cargo-xwin (prueft Microsoft-Manifest-Hashes), NSIS aus dem Ubuntu-Archiv; Tauri laedt seine NSIS-Plugins mit hinterlegten Pruefsummen. |
| T-18-16 | Tampering | Cache-Vergiftung (`target/`, xwin-Ablage) | low | accept | Der Cache-Server laeuft nur lokal fuer diesen Runner (`172.18.0.1`), keine fremden Schreiber; Schluessel haengt am `Cargo.lock`-Hash. |
| T-18-17 | Repudiation | Iterationsschleife | low | mitigate | Jede Runde ist ein eigener Commit mit Ursache im Titel und im SUMMARY dokumentiert. |
| T-18-18 | Information Disclosure | Protokollauszuege (Token) | low | mitigate | Gitea maskiert Secrets im Log; `publish-release.sh` gibt das Token nie aus (T-18-03). |
| T-18-SC | Tampering | `cargo install --locked cargo-xwin` (crates.io) | low | mitigate | Legitimitaetspruefung in RESEARCH: `OK` (rust-cross/cargo-xwin, seit 2022, 63k Downloads/Woche); kein `[ASSUMED]`/`[SUS]`, keine Sperr-Freigabe noetig. |
</threat_model>
<verification>
1. Statische Pruefung des Workflows (Kennzeichen, Reihenfolge AppImage vor
NSIS, Einsammeln mit beiden Plattformen).
2. Gruener Pipeline-Lauf auf `main` (Rueckmeldung des Orchestrators) mit
beiden Dateizeilen und gruenem `publish`.
3. Hoechstens drei dokumentierte Runden.
4. Nach dem Lauf traegt das Beta-Abbild die Pakete — sichtbar, sobald der
Nutzer den Testserver auf den neuen Stand zieht (18-06).
</verification>
<success_criteria>
- Der Job `desktop` erzeugt auf dem Linux-Runner `Tessera-Setup-X.Y.Z[…].exe`
und `Tessera-X.Y.Z[…].AppImage` in einem Lauf.
- Der Lauf ist gruen; `publish` hat Beta-Abbilder mit Paketen gepusht.
- Die Iterationsschleife ist dokumentiert und endete spaetestens nach drei
Runden.
</success_criteria>
<output>
Create `.planning/phases/18-desktop-client-fertigstellen/18-05-SUMMARY.md` when done.
Im SUMMARY festhalten: Dauer des ersten und (falls vorhanden) eines zweiten
Laufs mit warmem Cache, Groesse beider Dateien laut Sammel-Schritt, und die
Tabelle der Runden (Signatur, Ursache, Aenderung, Commit).
</output>
@@ -0,0 +1,446 @@
---
phase: 18-desktop-client-fertigstellen
plan: 06
type: execute
wave: 4
depends_on: ["18-01", "18-02", "18-03", "18-04", "18-05"]
files_modified:
- docs/anleitung-anwender.md
- docs/anleitung-betrieb.md
- docs/anleitung-entwicklung.md
- docs/ci-cd-setup.md
- CHANGELOG.md
- .planning/REQUIREMENTS.md
autonomous: true
requirements: [DESK-01, DESK-02, DESK-03, DESK-04, DESK-05]
user_setup: []
estimate:
tokens: 60000
raw_tokens: 60000
tasks: 3
confidence: low
must_haves:
truths:
- "Das Anwenderhandbuch hat ein Kapitel 'Desktop-App' mit Download in Tessera, Installation (Windows mit SmartScreen-Hinweis, Linux AppImage), Erststart mit Server-Adresse, Infobereich/Schliessen/Beenden, Autostart und Update-Hinweis (D-15)."
- "Das Betriebshandbuch beschreibt den Pipeline-Job, den Cross-Bau, den Ablageort der Pakete im Abbild, die Release-Dateien, die Umgebungsvariable und die Fehlerbilder (D-15)."
- "Das Entwicklungshandbuch fuehrt apps/desktop nicht mehr als Grundgeruest und beschreibt den lokalen Bau samt Voraussetzungen (D-15)."
- "CHANGELOG 'Unveröffentlicht' -> '### Neu' traegt den Stichpunkt zur Desktop-App (D-17); REQUIREMENTS.md fuehrt DESK-01..05 mit Nachverfolgung."
- "Alle Test-Suiten (API, Web) und Typpruefungen sind gruen; der Nutzer hat den Windows-Installer auf seinem PC durchgespielt (Erfolgskriterium 3)."
artifacts:
- path: "docs/anleitung-anwender.md"
provides: "Kapitel '## Desktop-App' mit sieben Unterabschnitten"
contains: "## Desktop-App"
- path: "docs/anleitung-betrieb.md"
provides: "Kapitel '## 10. Desktop-App: Pakete und Release-Dateien'"
contains: "## 10. Desktop-App"
- path: "docs/anleitung-entwicklung.md"
provides: "Abschnitt '### Desktop-App lokal bauen'"
contains: "Desktop-App lokal bauen"
- path: "docs/ci-cd-setup.md"
provides: "Job desktop im Pipeline-Ueberblick, Fehlerbehebung fuer Cross-Bau und Cache"
contains: "desktop"
- path: "CHANGELOG.md"
provides: "Stichpunkt Desktop-App unter Unveröffentlicht/Neu"
contains: "Desktop-App für Windows und Linux"
- path: ".planning/REQUIREMENTS.md"
provides: "Kategorie DESK mit DESK-01..05 und Traceability-Zeilen"
contains: "DESK-05"
key_links:
- from: "docs/anleitung-anwender.md"
to: "apps/web/src/messages/de.json"
via: "Die im Handbuch genannten Beschriftungen entsprechen den de.json-Texten (Link- und Knopftexte, Tray-Eintraege)"
pattern: "Desktop-App herunterladen"
- from: "docs/anleitung-betrieb.md"
to: "apps/api/src/desktop/desktop.service.ts"
via: "Ablageort /app/desktop-dist und Variable DESKTOP_DIST_DIR"
pattern: "DESKTOP_DIST_DIR"
---
<objective>
Die Phase wird abgeschlossen: Handbuecher fuer Anwender, Betrieb und
Entwicklung beschreiben die Desktop-App, die Pipeline und die
Release-Dateien; CHANGELOG und REQUIREMENTS werden nachgezogen; alle Suiten
laufen; und der Nutzer prueft den Windows-Installer auf seinem PC nach
einer genauen Schrittfolge (Erfolgskriterien 3 und 4).
Purpose: D-15 und D-17 aus 18-CONTEXT.md; Nachverfolgung DESK-03/04/05.
Output: Vier Dokumente, CHANGELOG-Stichpunkt, REQUIREMENTS-Abschnitt,
gruene Gesamtlaeufe, Bedienprobe des Nutzers.
Alle Handbuchtexte in Sie-Form, mit echten Umlauten, ohne firmenspezifische
Adressen (Platzhalter `https://tessera.example.com`; die Testserver-Adresse
steht nur in der Bedienprobe fuer den Nutzer, nicht im Handbuch).
</objective>
## Artifacts this phase produces
Dieser Plan: `docs/anleitung-anwender.md` (Kapitel "Desktop-App"),
`docs/anleitung-betrieb.md` (Kapitel 10), `docs/anleitung-entwicklung.md`
(Abschnitt "Desktop-App lokal bauen", Aktualisierung Monorepo-Aufbau und
Tests), `docs/ci-cd-setup.md` (Job `desktop`, Fehlerbehebung),
`CHANGELOG.md` (Stichpunkt), `.planning/REQUIREMENTS.md` (Kategorie DESK).
Gesamtliste der Phase: siehe 18-01-PLAN.md.
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/18-desktop-client-fertigstellen/18-CONTEXT.md
@.planning/phases/18-desktop-client-fertigstellen/18-01-SUMMARY.md
@.planning/phases/18-desktop-client-fertigstellen/18-02-SUMMARY.md
@.planning/phases/18-desktop-client-fertigstellen/18-03-SUMMARY.md
@.planning/phases/18-desktop-client-fertigstellen/18-04-SUMMARY.md
@.planning/phases/18-desktop-client-fertigstellen/18-05-SUMMARY.md
@docs/anleitung-anwender.md
@docs/anleitung-betrieb.md
@docs/anleitung-entwicklung.md
@docs/ci-cd-setup.md
@CHANGELOG.md
@.planning/REQUIREMENTS.md
</context>
<tasks>
<task type="auto">
<name>Task 1: Anwenderhandbuch — Kapitel "Desktop-App"; CHANGELOG-Stichpunkt</name>
<files>
docs/anleitung-anwender.md,
CHANGELOG.md
</files>
<read_first>
docs/anleitung-anwender.md (Inhaltsverzeichnis Zeilen 6-22, Kapitel "Persönliche Einstellungen" ab Zeile 143 und "Einen Fehler melden" ab Zeile 160 als Stilvorlage),
CHANGELOG.md (Zeilen 1-14),
apps/web/src/messages/de.json (Bloecke `auth.desktopDownload` und `settings.desktop` aus 18-03 — Beschriftungen woertlich uebernehmen),
apps/desktop/src-tauri/src/lib.rs (Tray-Texte und Benachrichtigungstext aus 18-04),
apps/desktop/src/setup.html (Texte der Erststart-Seite aus 18-04)
</read_first>
<action>
**Kapitel einfuegen** zwischen `## Persönliche Einstellungen` und
`## Einen Fehler melden`: `## Desktop-App`, im Inhaltsverzeichnis als neuer
Punkt 8 (`[Desktop-App](#desktop-app)`), die folgenden Punkte auf 9-11
umnummerieren. Unterabschnitte (`###`) in dieser Reihenfolge, Sie-Form,
kurze Absaetze, Beschriftungen exakt wie in der Oberflaeche:
1. **Was die Desktop-App ist** — eigenes Fenster statt Browser-Tab, Symbol
im Infobereich der Taskleiste, dieselben Funktionen wie im Browser.
2. **Herunterladen** — auf der Anmeldeseite unter dem Formular
„Desktop-App herunterladen (Windows)" und „Linux-Version"; oder
angemeldet unter Einstellungen → Allgemein → Desktop-App mit Version,
Dateiname und Dateigroesse. Kein Zugang zu Gitea noetig.
3. **Installation unter Windows** — Datei `Tessera-Setup-X.Y.Z.exe`
ausfuehren; Windows-SmartScreen zeigt „Der Computer wurde durch Windows
geschützt": auf „Weitere Informationen" und dann „Trotzdem ausführen"
klicken; Grund in einem Satz (die App ist fuer den internen Gebrauch
nicht signiert, das Paket stammt aus Ihrem Tessera-Server). Danach
Startmenue-Eintrag „Tessera". Eine neuere Version wird einfach
darueber installiert; die Server-Adresse bleibt erhalten.
4. **Installation unter Linux** — `Tessera-X.Y.Z.AppImage` ausfuehrbar
machen (Dateieigenschaften oder `chmod +x`) und starten; keine
Installation noetig.
5. **Erster Start: Server-Adresse** — die Adresse, unter der Sie Tessera im
Browser oeffnen (Beispiel `https://tessera.example.com`); die App prueft
die Adresse und meldet „Tessera X.Y.Z gefunden"; bei `http` erscheint
ein Hinweis, die Verbindung ist trotzdem moeglich; danach die gewohnte
Anmeldung.
6. **Fenster, Infobereich und Beenden** — Schliessen (X) legt Tessera in
den Infobereich; Linksklick auf das Symbol oeffnet das Fenster;
Rechtsklick zeigt „Öffnen", „Update herunterladen", „Mit Windows
starten" (Haken; unter Linux „Beim Anmelden starten") und „Beenden";
nur „Beenden" beendet die App; Fenstergroesse und -position werden
gemerkt.
7. **Automatischer Start** — Haken im Menue setzen/entfernen; ab Werk aus.
8. **Neue Version** — Benachrichtigung „Neue Version X.Y.Z verfügbar" beim
Start, Menueeintrag „Version X.Y.Z herunterladen" oeffnet die Seite
Einstellungen → Desktop-App im Browser; dort herunterladen und wie oben
installieren. Kein automatisches Update.
9. **Wenn etwas nicht klappt** — drei Faelle: „Unter dieser Adresse
antwortet kein Tessera-Server" (Adresse pruefen, es ist die
Browser-Adresse, nicht eine interne API-Adresse); der Download-Link fehlt
auf der Anmeldeseite (der Server traegt noch keine Pakete — Betrieb
fragen); SmartScreen blockiert (siehe Installation).
**CHANGELOG** (`## Unveröffentlicht` → `### Neu`): als neuen Stichpunkt in
der bestehenden Liste `- Desktop-App für Windows und Linux: Download auf der
Anmeldeseite und unter Einstellungen → Desktop-App` (D-17, Wortlaut exakt).
</action>
<acceptance_criteria>
- `grep -c '^## Desktop-App$' docs/anleitung-anwender.md` ergibt 1; `grep -c '(#desktop-app)' docs/anleitung-anwender.md` ergibt 1.
- `grep -c '^### ' docs/anleitung-anwender.md` ist um 9 groesser als vorher (neun Unterabschnitte); die Ueberschriften enthalten `Herunterladen`, `Installation unter Windows`, `Installation unter Linux`, `Erster Start`, `Infobereich`, `Automatischer Start`, `Neue Version`.
- `grep -c 'Trotzdem ausführen' docs/anleitung-anwender.md` ergibt mindestens 1; `grep -c 'Desktop-App herunterladen (Windows)' docs/anleitung-anwender.md` ergibt mindestens 1; `grep -c 'Mit Windows starten' docs/anleitung-anwender.md` ergibt mindestens 1.
- `grep -c 'ctl.de\|vicolab' docs/anleitung-anwender.md` ergibt 0 im neuen Kapitel (keine firmenspezifische Adresse).
- `grep -c '^- Desktop-App für Windows und Linux: Download auf der Anmeldeseite und unter Einstellungen → Desktop-App$' CHANGELOG.md` ergibt 1, und die Zeile steht oberhalb der ersten `## 1.` Versionsueberschrift.
</acceptance_criteria>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl && grep -q '^## Desktop-App$' docs/anleitung-anwender.md && grep -q '(#desktop-app)' docs/anleitung-anwender.md && grep -q 'Trotzdem ausführen' docs/anleitung-anwender.md && grep -q 'Desktop-App herunterladen (Windows)' docs/anleitung-anwender.md && grep -q 'Mit Windows starten' docs/anleitung-anwender.md && test "$(awk '/^## Desktop-App$/{f=1;next} /^## /{f=0} f' docs/anleitung-anwender.md | grep -c '^### ')" -ge 9 && test "$(awk '/^## Desktop-App$/{f=1;next} /^## /{f=0} f' docs/anleitung-anwender.md | grep -ci 'ctl\.de\|vicolab')" = "0" && node -e "const c=require('fs').readFileSync('CHANGELOG.md','utf8');const u=c.indexOf('## Unveröffentlicht'),v=c.search(/\n## [0-9]/);const b=c.indexOf('- Desktop-App für Windows und Linux: Download auf der Anmeldeseite und unter Einstellungen → Desktop-App');if(u===-1||b===-1||b>v||b<u)process.exit(1)" && echo DOCS1-OK</automated>
<fails_when>Kapitel, Inhaltsverzeichnis-Eintrag, eine Pflichtbeschriftung oder ein Unterabschnitt fehlt, das Kapitel nennt eine Firmenadresse, oder der CHANGELOG-Stichpunkt steht nicht unter „Unveröffentlicht" — `DOCS1-OK` fehlt.</fails_when>
</verify>
<done>
Kapitel „Desktop-App" mit neun Unterabschnitten im Anwenderhandbuch samt
Inhaltsverzeichnis; CHANGELOG-Stichpunkt im Wortlaut von D-17.
</done>
</task>
<task type="auto">
<name>Task 2: Betriebshandbuch Kapitel 10, CI/CD-Runbook, Entwicklungshandbuch</name>
<files>
docs/anleitung-betrieb.md,
docs/ci-cd-setup.md,
docs/anleitung-entwicklung.md
</files>
<read_first>
docs/anleitung-betrieb.md (Inhaltsverzeichnis Zeilen 12-22, Kapitel 8 ab Zeile 343, Kapitel 9 "Eine Version freigeben" ab Zeile 430),
docs/ci-cd-setup.md (Abschnitt 4 "Pipeline-Ueberblick" ab Zeile 108, Abschnitt 6 "Fehlerbehebung" ab Zeile 211),
docs/anleitung-entwicklung.md (Zeilen 23-58 Monorepo-Aufbau, "Lokale Entwicklungsumgebung" ab Zeile 58, "Tests" ab Zeile 403),
.gitea/workflows/ci.yml (Endstand nach 18-05),
.gitea/scripts/desktop-version.sh, .gitea/scripts/desktop-collect.sh, .gitea/scripts/publish-release.sh (Kopfkommentare),
apps/api/src/desktop/desktop.service.ts (Variable DESKTOP_DIST_DIR, Vorgabepfad),
.planning/phases/18-desktop-client-fertigstellen/18-05-SUMMARY.md (Rundentabelle — reale Fehlerbilder in die Fehlerbehebung uebernehmen)
</read_first>
<action>
**`docs/anleitung-betrieb.md`** — neues `## 10. Desktop-App: Pakete und
Release-Dateien` am Ende, Inhaltsverzeichnis um Punkt 10 ergaenzen. Der
Sprachstil des Dokuments (Sie-Form, nummerierte Kapitel, `###`-Abschnitte).
Abschnitte: `### Woher die Pakete kommen` (Job `desktop` nach `test`, auf
`main` und bei Tags `v*`; Linux-AppImage und Windows-Installer per
Cross-Bau auf dem Linux-Runner in einem Job; Einzelheiten der Werkzeugkette
in `docs/ci-cd-setup.md`, Abschnitt 4); `### Wo die Pakete im Abbild
liegen` (`/app/desktop-dist/` im API-Abbild mit `manifest.json`, Dateien
`Tessera-Setup-X.Y.Z.exe` und `Tessera-X.Y.Z.AppImage`, auf Beta mit Suffix
`-beta.{commit}`; Kontrolle: `docker compose exec api ls -l /app/desktop-dist`
und `curl -s https://{ihre-adresse}/api-proxy/desktop/latest`; Ausgabe
erklaeren); `### Release-Dateien in Gitea` (bei Tags haengt die Pipeline
beide Dateien an den Release; die Datei am Release ist dieselbe wie im
Abbild — Pruefsumme `sha256` aus dem Manifest); `### Umgebungsvariablen`
(keine neue Pflichtvariable; optional `DESKTOP_DIST_DIR`, Vorgabe
`/app/desktop-dist`; Tabelle im Stil von Kapitel 3); `### Fehlerbilder`
als Tabelle Symptom → Ursache → Massnahme: Download-Link fehlt auf der
Anmeldeseite bzw. `/api-proxy/desktop/latest` liefert 404 → Abbild ohne
Pakete (Job `publish` haette abbrechen muessen; Lauf pruefen, erneut
ausrollen); Download bricht bei grossen Dateien ab → Groessengrenze des
vorgeschalteten Proxys (Nginx Proxy Manager, `client_max_body_size` bzw.
Zeitlimits); Client meldet „Unter dieser Adresse antwortet kein
Tessera-Server" → Anwender hat die API- statt der Web-Adresse eingetragen
oder `/api-proxy` ist vom Client-Rechner nicht erreichbar; Windows warnt
(SmartScreen) → erwartet, keine Signatur (Anwenderhandbuch). In Kapitel 9,
Abschnitt „Eine Version freigeben", einen Satz ergaenzen: der Tag baut auch
die Desktop-Pakete und haengt sie an den Release (Kapitel 10).
**`docs/ci-cd-setup.md`** — Abschnitt 4: aus „drei" werden „vier" Jobs;
Job `desktop` zwischen `test` und `publish` beschreiben: Bedingung (`main`
und Tags `v*`), Schritte (Rust per rustup, apt-Pakete, `cargo-xwin`,
`rustup target add x86_64-pc-windows-msvc`, Version aus dem Tag per
`desktop-version.sh` — immer rein numerisch, Grund Windows-Ressourcen;
AppImage, dann NSIS-Cross-Bau; `desktop-collect.sh` mit Manifest;
Uebergabe an `publish` per `actions/cache` mit Schluessel `desktop-dist-{sha}`
und **warum nicht** upload-artifact (auf Gitea unzuverlaessig);
Cache-Pfade und Schluessel `desktop-cargo-<Cargo.lock-Hash>`); `publish`:
Restore mit hartem Abbruch, Pruefung des Manifests, Release-Upload der
Manifest-Dateien (idempotent: vorhandene Datei gleichen Namens wird
ersetzt). Abschnitt 6 Fehlerbehebung: neue Unterabschnitte „Job desktop
schlaegt fehl" (apt-Paketname, pkg-config, openssl-sys beim Windows-Ziel →
`rustls-tls`, NSIS-Plugin-Download, Speicher → `CARGO_BUILD_JOBS`),
„publish: cache miss" (Schluessel/Cache-Server, Abschnitt 2 Runner-Config
`[cache] enabled`), „Release-Upload 413" (`GITEA_API` auf die Host-Adresse
`http://172.18.0.1:3002/api/v1` — nur, wenn der Proxy die Groesse
abweist). Reale Fehlerbilder aus 18-05-SUMMARY (Rundentabelle) hier
eintragen.
**`docs/anleitung-entwicklung.md`** — (1) Im Monorepo-Aufbau die Zeile zu
`desktop/` und den Absatz bei Zeile 39, der `apps/desktop` als blosses
Grundgeruest mit einer einzelnen `setup.html` beschreibt, ersetzen (das Wort
„Grundgerüst" darf im Dokument danach nicht mehr im Zusammenhang mit Tauri
stehen — Negativ-Tor in `<verify>`): `apps/desktop` ist der fertige
Desktop-Client (Tauri 2): `src-tauri/src/lib.rs` (Tray, Erststart-Kommandos,
Versionspruefung), `src/setup.html` (Erststart-Seite), Pakete entstehen im
CI; `packages/shared` enthaelt jetzt auch die Manifest-Typen der
Desktop-Pakete. (2) Unter „Lokale Entwicklungsumgebung" neuer Abschnitt
`### Desktop-App lokal bauen`: Voraussetzungen (Rust stable per rustup,
Ubuntu/Debian-Pakete `libwebkit2gtk-4.1-dev libjavascriptcoregtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev libgtk-3-dev libssl-dev patchelf`),
Befehle `sh .gitea/scripts/desktop-version.sh` (schreibt die Version des
letzten Tags — die eingecheckten Versionsdateien sind nur eine Basislinie),
`pnpm --filter @tessera/desktop exec tauri build --bundles appimage`,
Ausgabe unter `apps/desktop/src-tauri/target/release/bundle/appimage/`,
`sh .gitea/scripts/desktop-collect.sh --require linux` fuer `desktop-dist/`
(vom Git ausgeschlossen bis auf den Platzhalter), Hinweis: der
Windows-Installer wird nur im CI gebaut (`cargo-xwin`, NSIS), lokal genuegt
`cargo check`/`cargo clippy`; lokaler Docker-Stack: nach `docker compose build api`
liefert die API die Pakete unter `/desktop/latest`. (3) Unter „Tests":
`pnpm --filter @tessera/api exec vitest run src/desktop` (HTTP-Durchstich
ueber `NestFactory`, echtes Temp-Verzeichnis) und die Rust-Pruefungen
ergaenzen.
</action>
<acceptance_criteria>
- `grep -c '^## 10. Desktop-App' docs/anleitung-betrieb.md` ergibt 1; das Inhaltsverzeichnis enthaelt einen Eintrag `10.`; `grep -c 'DESKTOP_DIST_DIR' docs/anleitung-betrieb.md` ergibt mindestens 1; `grep -c '/app/desktop-dist' docs/anleitung-betrieb.md` ergibt mindestens 1; `grep -c '### Fehlerbilder' docs/anleitung-betrieb.md` ergibt 1.
- `grep -c 'vier aufeinander aufbauenden Jobs\|vier Jobs' docs/ci-cd-setup.md` ergibt mindestens 1; `grep -c 'cargo-xwin' docs/ci-cd-setup.md` ergibt mindestens 2; `grep -c 'upload-artifact' docs/ci-cd-setup.md` ergibt mindestens 1 (Begruendung, warum nicht); `grep -c 'desktop-dist-' docs/ci-cd-setup.md` ergibt mindestens 1.
- `grep -c 'Tauri-Grundgerüst' docs/anleitung-entwicklung.md` ergibt 0; `grep -c '### Desktop-App lokal bauen' docs/anleitung-entwicklung.md` ergibt 1; `grep -c 'desktop-version.sh' docs/anleitung-entwicklung.md` ergibt mindestens 1; `grep -c 'vitest run src/desktop' docs/anleitung-entwicklung.md` ergibt mindestens 1.
- Keine firmenspezifische Adresse in den neuen Abschnitten (die bestehenden Nennungen von `git.vicolab.de` im CI/CD-Runbook sind Infrastruktur und bleiben).
</acceptance_criteria>
<!-- planner-discipline-allow: Tauri-Grundgerüst -->
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl && grep -q '^## 10. Desktop-App' docs/anleitung-betrieb.md && grep -q 'DESKTOP_DIST_DIR' docs/anleitung-betrieb.md && grep -q '/app/desktop-dist' docs/anleitung-betrieb.md && grep -q '### Fehlerbilder' docs/anleitung-betrieb.md && grep -Eq '^10\. \[' docs/anleitung-betrieb.md && test "$(grep -c 'cargo-xwin' docs/ci-cd-setup.md)" -ge 2 && grep -q 'desktop-dist-' docs/ci-cd-setup.md && grep -q 'upload-artifact' docs/ci-cd-setup.md && test "$(grep -c 'Tauri-Grundgerüst' docs/anleitung-entwicklung.md)" = "0" && grep -q '### Desktop-App lokal bauen' docs/anleitung-entwicklung.md && grep -q 'desktop-version.sh' docs/anleitung-entwicklung.md && grep -q 'vitest run src/desktop' docs/anleitung-entwicklung.md && echo DOCS2-OK</automated>
<fails_when>Kapitel 10, Inhaltsverzeichnis-Eintrag, Variable, Ablageort, Fehlerbilder, Cross-Bau-Beschreibung, Cache-Schluessel oder der neue Entwicklungsabschnitt fehlen, oder das Entwicklungshandbuch nennt `apps/desktop` noch als Grundgeruest — `DOCS2-OK` fehlt.</fails_when>
</verify>
<done>
Betriebshandbuch mit Kapitel 10 (Pipeline, Ablageort, Release-Dateien,
Variable, Fehlerbilder), CI/CD-Runbook mit Job `desktop` und
Fehlerbehebung, Entwicklungshandbuch mit lokalem Bau und aktualisiertem
Monorepo-Aufbau.
</done>
</task>
<task type="auto">
<name>Task 3: REQUIREMENTS nachziehen, Gesamtlaeufe, Bedienprobe des Nutzers</name>
<files>
.planning/REQUIREMENTS.md
</files>
<read_first>
.planning/REQUIREMENTS.md (Abschnitte "SRC" ab Zeile 58 als Formvorlage, "Traceability" ab Zeile 101),
.planning/ROADMAP.md (Phase 18: Requirements-Zeile und Erfolgskriterien),
.planning/phases/06-desktop-client-ci-cd/06-CONTEXT.md (Ursprung DESK-01/02)
</read_first>
<action>
**REQUIREMENTS.md.** Vor `## Future Requirements (deferred)` einen Abschnitt
`## Phase 18 — Desktop-Client fertigstellen` mit `### DESK — Desktop-Client`
und einem Einleitungssatz („Hinzugefügt 2026-09-16 — DESK-01/02 stammen aus
v1.0 (Phase 6) und werden fortgeführt; DESK-03..05 aus
`18-CONTEXT.md` abgeleitet") einfuegen. Eintraege im Stil der SRC-Zeilen:
`- [x] **DESK-01**: Tauri-basierter Desktop-Wrapper für Windows und Linux (Phase 6, fortgeführt).`;
`- [x] **DESK-02**: Die Desktop-App verbindet sich mit dem Web-Backend; die Server-Adresse wird beim ersten Start abgefragt (Phase 6, fortgeführt; D-02).`;
`- [ ] **DESK-03**: Der Installer ist in Tessera herunterladbar — Link auf der Anmeldeseite und Seite Einstellungen → Desktop-App, Auslieferung über die Tessera-API ohne Gitea-Zugang (D-01, D-10, D-12).`;
`- [ ] **DESK-04**: Ein Freigabe-Tag baut Windows-Installer und Linux-AppImage in der Pipeline und hängt beide als Dateien an den Gitea-Release (D-04..D-08).`;
`- [ ] **DESK-05**: Der Client trägt die Freigabe-Version, vergleicht sie mit `/desktop/latest` und weist mit Download-Link auf eine neuere Version hin (D-07, D-11, D-13).`
In der Traceability-Tabelle fuenf Zeilen ergaenzen: `DESK-01 | Phase 6 / 18 | Complete`,
`DESK-02 | Phase 6 / 18 | Complete`, `DESK-03 | Phase 18 | Pending`,
`DESK-04 | Phase 18 | Pending`, `DESK-05 | Phase 18 | Pending` (auf
Complete setzt sie die Verifikation der Phase). Die Coverage-Zeile um einen
Satz ergaenzen (5/5 DESK auf Phase 18 abgebildet).
**Gesamtlaeufe** (Endstand der Phase): `pnpm --filter @tessera/api exec vitest run`,
`pnpm --filter @tessera/web exec vitest run`, `pnpm --filter @tessera/api type-check`,
`pnpm --filter @tessera/web type-check`, `cargo check` in
`apps/desktop/src-tauri`. Ergebnisse (Anzahl Dateien/Tests) im SUMMARY
festhalten. `biome check` ist kein Tor (bekannter Fehler in der
Wurzel-`biome.json`, nicht anfassen).
**Bedienprobe vorbereiten:** Den Text der `<human-check>` unten als
Schrittfolge in das SUMMARY uebernehmen, damit der Nutzer sie zur Hand hat;
die Testserver-Adresse dort einsetzen (`alpha.tessera.ctl.de`, nur im
SUMMARY/Gespraech, nie im Handbuch).
</action>
<acceptance_criteria>
- `grep -c '\*\*DESK-0[1-5]\*\*' .planning/REQUIREMENTS.md` ergibt 5; `grep -c '^| DESK-0[1-5] |' .planning/REQUIREMENTS.md` ergibt 5.
- `pnpm --filter @tessera/api exec vitest run` und `pnpm --filter @tessera/web exec vitest run` melden 0 fehlgeschlagene Tests; beide Typpruefungen fehlerfrei; `cargo check` gruen.
- Der Nutzer hat die Bedienprobe (human-check) durchgefuehrt und das Ergebnis liegt vor.
</acceptance_criteria>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl && test "$(grep -c '\*\*DESK-0[1-5]\*\*' .planning/REQUIREMENTS.md)" = "5" && test "$(grep -c '^| DESK-0[1-5] |' .planning/REQUIREMENTS.md)" = "5" && echo REQ-OK</automated>
<fails_when>Weniger oder mehr als fuenf DESK-Eintraege bzw. Traceability-Zeilen — `REQ-OK` fehlt.</fails_when>
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/api exec vitest run && pnpm --filter @tessera/web exec vitest run && pnpm --filter @tessera/api type-check && pnpm --filter @tessera/web type-check && (cd apps/desktop/src-tauri && cargo check 2>&1 | tail -1 | grep -q Finished) && echo ALL-GREEN</automated>
<fails_when>Eine Suite meldet "failed", tsc gibt Fehler aus, oder `cargo check` endet ohne `Finished` — `ALL-GREEN` fehlt.</fails_when>
<human-check>
Bedienprobe des Nutzers (Du-Form im Gespraech; Voraussetzung: der Testserver
laeuft auf dem Beta-Stand mit den Paketen — `docker compose pull` und
`docker compose up -d --force-recreate` machst du dort selbst; Windows-PC
mit Browser):
1. Anmeldeseite des Testservers im Browser oeffnen: Unter dem Formular
steht „Desktop-App herunterladen (Windows)", daneben „Linux-Version",
darunter „Version 1.1.0".
2. Auf den Windows-Link klicken: Es laedt `Tessera-Setup-1.1.0-beta.{kennung}.exe`
(wenige MB).
3. Datei ausfuehren. Windows zeigt die SmartScreen-Warnung: „Weitere
Informationen" → „Trotzdem ausführen". Die Installation laeuft ohne
weitere Fragen durch; Tessera startet (sonst ueber das Startmenue).
4. Erststart-Seite: dunkle Karte mit Tessera-Zeichen und gelbem Schriftzug,
Feld „Adresse Ihres Tessera-Servers". Adresse des Testservers eintragen
(`https://…`), „Verbinden": kurz „Tessera 1.1.0 gefunden – Verbindung
wird hergestellt …", dann erscheint die Tessera-Anmeldung **im
App-Fenster**.
5. Anmelden. Fenster mit X schliessen: Die App bleibt im Infobereich
(Symbol mit Tessera-Zeichen). Linksklick auf das Symbol: Fenster ist
wieder da.
6. Rechtsklick auf das Symbol: Menue „Öffnen", „Update herunterladen"
(ausgegraut, weil du die aktuelle Version hast), „Mit Windows starten"
(ohne Haken), „Beenden" — mit Umlauten.
7. „Mit Windows starten" anklicken: Haken erscheint; erneut anklicken:
Haken verschwindet.
8. „Beenden": App ist weg (auch aus dem Infobereich).
9. App erneut starten: Sie geht **direkt** zu Tessera (Adresse gemerkt),
Fenstergroesse und -position wie beim Beenden.
10. In der App: Einstellungen → Allgemein → „Desktop-App": Seite mit
„Aktuelle Version: 1.1.0", „Beta-Ausgabe, Stand {kennung}", zwei gelbe
Knoepfe „Für Windows herunterladen" / „Für Linux herunterladen", darunter
Dateiname und Groesse (z. B. „… · 101,5 MB" fuer Linux), und vier
Saetze Erklaerung.
11. Falls ein Linux-Rechner greifbar ist: AppImage herunterladen,
ausfuehrbar machen, starten — Erststart-Seite wie unter 4.
Zwei Punkte lassen sich erst beim **naechsten Freigabe-Tag** pruefen und
gehoeren in die Abnahme dieser Version, nicht in diese Phase: (a) Nach dem
Tag `v1.2.0` zeigt der installierte 1.1.0-Client beim Start die
Benachrichtigung „Neue Version 1.2.0 verfügbar …", und der Menueeintrag
heisst „Version 1.2.0 herunterladen" und oeffnet die Seite Desktop-App im
Browser. (b) Der Gitea-Release `v1.2.0` traegt `Tessera-Setup-1.2.0.exe`
und `Tessera-1.2.0.AppImage` als Dateien.
</human-check>
</verify>
<done>
REQUIREMENTS.md fuehrt DESK-01..05 mit Nachverfolgung; alle Suiten und
Typpruefungen gruen; die Bedienprobe des Nutzers ist durchgefuehrt und im
SUMMARY dokumentiert (inklusive der zwei auf den naechsten Tag vertagten
Punkte).
</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Handbuecher -> Anwender | Anleitungen praegen das Verhalten der Anwender bei Sicherheitswarnungen (SmartScreen). |
| Testserver -> Nutzer-PC | Der Nutzer installiert ein unsigniertes Paket vom Beta-Kanal. |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-18-19 | Spoofing | SmartScreen-Anleitung („Trotzdem ausführen") | low | mitigate | Das Handbuch koppelt die Anweisung an die Herkunft (Download nur aus dem eigenen Tessera-Server, Dateiname `Tessera-Setup-…`) und nennt keine allgemeine Empfehlung, Warnungen zu ignorieren. |
| T-18-20 | Information Disclosure | Handbuecher mit Server-Adressen | low | mitigate | Nur Platzhalter (`https://tessera.example.com`); die Testserver-Adresse steht ausschliesslich im SUMMARY/Gespraech. |
| T-18-SC | Tampering | Paketinstallationen | low | accept | Dieser Plan installiert kein Paket. |
</threat_model>
<verification>
1. Dokument-Kennzeichen (Kapitel, Inhaltsverzeichnis, Pflichtbegriffe) in
allen vier Dokumenten erfuellt.
2. CHANGELOG-Stichpunkt unter „Unveröffentlicht".
3. REQUIREMENTS.md mit DESK-01..05 und Traceability.
4. Gesamtlaeufe API/Web/Typpruefung/Cargo gruen.
5. Bedienprobe des Nutzers auf Windows (Schritte 1-10) bestanden; Punkte
(a) und (b) auf den naechsten Freigabe-Tag vertagt und so dokumentiert.
</verification>
<success_criteria>
- Anwender-, Betriebs- und Entwicklungshandbuch beschreiben Installation,
Erststart, Tray-Verhalten, Pipeline, Release-Dateien und
Umgebungsvariablen (Erfolgskriterium 4).
- Der installierte Client zeigt nach Eingabe der Server-Adresse die
Anmeldung und verhaelt sich im Infobereich wie beschrieben
(Erfolgskriterium 3, Bedienprobe).
- Alle Suiten gruen; CHANGELOG und REQUIREMENTS nachgezogen.
</success_criteria>
<output>
Create `.planning/phases/18-desktop-client-fertigstellen/18-06-SUMMARY.md` when done.
Im SUMMARY festhalten: Ergebnis der Bedienprobe je Schritt, die zwei
vertagten Punkte, und die Zahlen der Gesamtlaeufe.
</output>
@@ -0,0 +1,77 @@
# Phase 18: Desktop-Client fertigstellen - Context
**Gathered:** 2026-09-16 (Entscheidungen des Users im Gespraech; technische Festlegungen durch Claude)
**Status:** Ready for planning
<domain>
## Phase Boundary
Der Tauri-Desktop-Client aus Phase 6 (`apps/desktop`, Grundgeruest: WebView auf die Tessera-Web-App, Erststart-Seite fuer die Server-Adresse, Tray, Schliessen-ins-Tray, Autostart, Fensterzustand, Benachrichtigung, Versionspruefung, AppImage+NSIS-Ziele) wird zu einem fertigen, verteilbaren Produkt: Pakete aus der Pipeline, Download in Tessera und am Gitea-Release, Versionierung, Update-Hinweis, Handbuecher. KEINE neuen App-Funktionen im Client (keine nativen Kalender-Erinnerungen, kein Auto-Update, keine Code-Signierung).
</domain>
<decisions>
## Implementation Decisions
### Produkt (User)
- **D-01:** Der Installer ist **in Tessera herunterladbar** (Anwender ohne Gitea-Zugang) **und** liegt als Datei am **Gitea-Release** des Freigabe-Tags.
- **D-02:** Server-Adresse wird weiterhin **beim ersten Start abgefragt** (ein Paket fuer alle Umgebungen/Kunden). Kein fest eingebauter Server.
- **D-03:** Updates: **Hinweis + Download-Link**, kein automatisches Aktualisieren.
### Plattformen & Bau (Claude)
- **D-04:** Windows-Installer (NSIS, `Tessera-Setup-X.Y.Z.exe`) ist das Hauptziel; Linux-AppImage (`Tessera-X.Y.Z.AppImage`) wird mitgebaut, weil der Runner ohnehin Linux ist.
- **D-05:** Der Gitea-Runner ist Linux (`gitea/runner-images:ubuntu-latest`, Docker, 8 Kerne/15 GB). Der Windows-Bau laeuft als **Cross-Bau auf Linux** (Tauri: `cargo tauri build --runner cargo-xwin --target x86_64-pc-windows-msvc`, NSIS via `makensis` aus dem Ubuntu-Paket `nsis`, `llvm`/`lld`/`clang`). Kein Windows-Rechner in der Pipeline.
- **D-06:** Neuer CI-Job `desktop` nach `test`, laeuft bei Push auf `main` und bei Tags `v*` (Beta bekommt die Pakete auch, sonst ist nichts testbar). Cargo-Registry, `target/` und das xwin-SDK werden per `actions/cache` zwischengespeichert; Forschung klaert, ob der lokale act_runner den Cache-Server anbietet — wenn nicht, laeuft der Bau ohne Cache (langsamer, aber korrekt).
- **D-07:** Versionsquelle ist der Freigabe-Tag: Ein Skript (`.gitea/scripts/desktop-version.sh`) schreibt vor dem Bau die Version (`X.Y.Z` aus dem letzten Tag) in `apps/desktop/src-tauri/tauri.conf.json` und `Cargo.toml`. Beta-Builds tragen dieselbe `X.Y.Z` wie der letzte Tag plus den Commit-Stempel in einem separaten Feld/Dateinamen-Suffix (Forschung: welche Versionsformen NSIS/Tauri auf Windows akzeptieren; Regel: keine Form waehlen, die den Windows-Installer scheitern laesst).
- **D-08:** **Verteilung ohne Netzabhaengigkeit:** Die gebauten Pakete werden im `publish`-Job in das API-Abbild kopiert (`/app/desktop-dist/` mit `manifest.json`: Version, Dateinamen, Groessen, SHA-256). Die API liefert sie selbst aus — Live-Server brauchen keinen Zugang zu Gitea. Zusaetzlich haengt `publish-release.sh` (nur bei Tags) beide Dateien als Release-Assets an das Gitea-Release (D-01).
- **D-09:** Keine Code-Signierung (intern; SmartScreen-Hinweis wird im Anwenderhandbuch erklaert).
### API (Claude)
- **D-10:** Neues Modul `apps/api/src/desktop/`: `GET /desktop/latest` (oeffentlich, ohne Anmeldung — die Anmeldeseite zeigt den Link) liefert `{ version, files: { windows: { name, size, sha256, url }, linux: {...} } }` aus `manifest.json`; `GET /desktop/download/:platform` (`windows` | `linux`, oeffentlich) streamt die Datei mit `Content-Disposition: attachment`. Fehlt das Verzeichnis/Manifest: `404` mit klarer Meldung; die Web-Oberflaeche blendet den Link dann aus. Nur Dateinamen aus dem Manifest werden geoeffnet (kein Pfad aus der Anfrage), Plattform per Whitelist.
- **D-11:** `/health/version` bleibt unveraendert; der Client vergleicht seine Version kuenftig mit `/desktop/latest`.
### Web (Claude)
- **D-12:** Anmeldeseite: unauffaelliger Link unterhalb des Formulars "Desktop-App herunterladen (Windows)" + kleiner Linux-Link, nur wenn `/desktop/latest` antwortet. Einstellungen: neuer Eintrag **Einstellungen → Allgemein → Desktop-App** mit Version, beiden Download-Knoepfen, Dateigroesse und 3-4 Saetzen (Was ist das, Erststart, Tray). Texte de/en, Sie-Form.
### Client (Claude)
- **D-13:** `lib.rs`: Versionspruefung gegen `{server}/desktop/latest`; bei abweichender Version Benachrichtigung "Neue Version X.Y.Z verfuegbar" und Tray-Menuepunkt "Update herunterladen", der `{server}/settings/general/desktop` im Systembrowser oeffnet (`tauri-plugin-opener` oder `open`-Crate — Forschung waehlt). Erststart-Seite (`setup.html`): Adresse pruefen ueber `/health/version` (bleibt), Texte in Sie-Form, Tessera-Farben; Tray-Texte mit Umlauten ("Öffnen", "Beenden").
- **D-14:** Bestehende Phase-6-Funktionen (Tray, Schliessen-ins-Tray, Autostart, Fensterzustand) bleiben unveraendert; Autostart-Schalter kommt ins Tray-Menue ("Mit Windows starten", Haken), weil es keine Client-Einstellungsseite gibt.
### Doku & Tests (Claude)
- **D-15:** `docs/anleitung-anwender.md`: Kapitel "Desktop-App" (Download in Tessera, Installation, SmartScreen-Hinweis, Erststart mit Server-Adresse, Tray/Schliessen/Beenden, Autostart, Update-Hinweis). `docs/anleitung-betrieb.md`: Pipeline-Job, Cross-Bau, wo die Pakete im Abbild liegen, Release-Dateien, Fehlerbilder. `docs/anleitung-entwicklung.md`: `apps/desktop` ist kein Grundgeruest mehr; lokaler Bau (`pnpm --filter @tessera/desktop build`), Voraussetzungen.
- **D-16:** Tests: API-Modul (Manifest lesen, 404 ohne Manifest, Plattform-Whitelist, Pfad-Traversal abgewiesen), Web (Link erscheint/verschwindet je nach API-Antwort, Einstellungsseite), Rust: `cargo check`/`cargo clippy` im CI-Job; ein lokaler Linux-Bau (`tauri build` AppImage) als Beweis vor dem Push. Der Windows-Cross-Bau wird erst in der Pipeline bewiesen — der Plan sieht eine Iterationsschleife vor (Fehler lesen, Job anpassen, erneut pushen), bis ein gruener Lauf mit beiden Dateien vorliegt.
- **D-17:** CHANGELOG `Unveröffentlicht` → `### Neu`: "Desktop-App für Windows und Linux: Download auf der Anmeldeseite und unter Einstellungen → Desktop-App" (Stichpunkt-Stil).
### Claude's Discretion
- Aufteilung in Plaene (Vorschlag: 18-01 CI/Cross-Bau + Versionsskript + Release-Assets; 18-02 API-Modul + Abbild-Einbau; 18-03 Web-Oberflaeche + Client-Anpassungen + Handbuecher)
- Tray-Menue-Reihenfolge, Icon-Pruefung, Dateinamen-Details
</decisions>
<canonical_refs>
## Canonical References
- `.planning/phases/06-desktop-client-ci-cd/06-CONTEXT.md`, `06-01-SUMMARY.md`, `06-02-SUMMARY.md` — was Phase 6 gebaut hat (Tray, Setup-Seite, Plugins, Bundles)
- `apps/desktop/src-tauri/src/lib.rs`, `apps/desktop/src/setup.html`, `apps/desktop/src-tauri/tauri.conf.json`, `Cargo.toml` — heutiger Stand des Clients
- `.gitea/workflows/ci.yml`, `.gitea/scripts/publish-images.sh`, `.gitea/scripts/publish-release.sh` — Pipeline, Kanalmodell (main=beta, Tag=live), Release-Anlage
- `apps/api/Dockerfile`, `apps/api/src/health/` — Abbild-Aufbau, `/health/version`
- `apps/web/src/app/(auth)/login/` (Anmeldeseite), `apps/web/src/app/(portal)/settings/` (Einstellungen, Navigation "Allgemein → Konto")
- `docs/anleitung-anwender.md`, `docs/anleitung-betrieb.md` (Kap. 9 Freigabe), `docs/anleitung-entwicklung.md`
- Tauri 2 Doku: Cross-Platform Compilation (Windows on Linux via cargo-xwin), NSIS bundler, tauri-plugin-opener; act_runner Cache (`[cache] enabled` in runner config)
</canonical_refs>
<specifics>
## Specific Ideas
- Der Download-Knopf soll wie die uebrigen Tessera-Knoepfe aussehen (Primaerfarbe), mit Windows/Linux-Symbol und Dateigroesse ("Tessera-Setup-1.2.0.exe · 6 MB").
- Der Erststart-Dialog soll sich anfuehlen wie Tessera (Logo, Farben), nicht wie eine Rohseite.
- Runner-Ressourcen sind begrenzt (8 Kerne, 15 GB): Rust-Bau mit `-j 4` falls noetig, kein paralleler Windows+Linux-Bau in zwei Jobs, sondern nacheinander im selben Job (ein Cache).
</specifics>
<deferred>
## Deferred Ideas
- Auto-Update (Tauri Updater, Signaturschluessel) — spaeter, wenn extern verkauft wird
- Code-Signierung — spaeter
- Native Kalender-Erinnerungen ueber den Client — nicht Teil dieser Phase
- macOS-Paket — kein Bedarf
</deferred>
@@ -0,0 +1,27 @@
# API Coverage — Gitea REST API (Releases und Release-Dateien)
> Full coverage by default. Opt-outs are explicit, reasoned decisions.
Einzige externe Schnittstelle dieser Phase: die Gitea-REST-API der eigenen
Instanz (`git.vicolab.de`, Gitea 1.26.2), angesprochen aus
`.gitea/scripts/publish-release.sh` im CI-Job `publish` (nur bei Tags `v*`).
Alle Pfade liegen unter `/api/v1/repos/{owner}/{repo}` (in der Tabelle als `…` abgekuerzt). Der Bereich ist die Releases-Ressource eines Repositories; alles andere in
Gitea (Issues, Pull Requests, Pakete, Wiki, Webhooks, Benutzer) liegt
ausserhalb der Phase. Die drei mit "seit 18-01" markierten Faehigkeiten sind
neu; die uebrigen INTEGRATE-Zeilen bestehen seit quick-260916-dcz.
| capability | decision | reason |
|---|---|---|
| releases: get by tag (`GET …/releases/tags/{tag}`) | INTEGRATE | bestehend — Idempotenz (Release vorhanden?) |
| releases: create (`POST /repos/{owner}/{repo}/releases`) | INTEGRATE | bestehend — Release aus CHANGELOG-Abschnitt |
| releases: update (`PATCH /repos/{owner}/{repo}/releases/{id}`) | INTEGRATE | bestehend — Text nachziehen |
| release assets: list (`GET …/releases/{id}/assets`) | INTEGRATE | seit 18-01 — vorhandene Datei gleichen Namens finden |
| release assets: delete (`DELETE …/releases/{id}/assets/{asset_id}`) | INTEGRATE | seit 18-01 — idempotentes Ersetzen |
| release assets: upload (`POST …/releases/{id}/assets?name=`, multipart) | INTEGRATE | seit 18-01 — `Tessera-Setup-X.Y.Z.exe` und `Tessera-X.Y.Z.AppImage` |
| release assets: edit name (`PATCH …/assets/{asset_id}`) | OPT-OUT | nicht noetig — Name wird beim Upload gesetzt, Ersetzen laeuft ueber delete + upload |
| release assets: download via Gitea (`GET …/assets/{asset_id}`) | OPT-OUT | explizit ausserhalb — Anwender laden ueber die Tessera-API (D-01/D-08), nicht ueber Gitea |
| releases: delete (`DELETE …/releases/{id}`) | OPT-OUT | nicht noetig — Releases werden nie automatisch entfernt |
| releases: list (`GET …/releases`) | OPT-OUT | nicht noetig — Zugriff erfolgt per Tag |
| settings: attachment limits (`GET /api/v1/settings/attachment`) | OPT-OUT | nur einmalig zur Planung abgefragt (2026-09-16); Release-Anhaenge unterliegen `[repository.release]` (Voreinstellung 2048 MB, alle Typen) — keine Laufzeitabfrage |
| actions: runs/jobs/logs (`GET …/actions/...`) | OPT-OUT | explizit ausserhalb — der Orchestrator liest CI-Laeufe ueber Gitea-MCP/Weboberflaeche (18-04), kein Skript spricht diese Endpunkte |
| packages / container registry API | OPT-OUT | nicht Teil der Phase — der Registry-Push laeuft weiterhin ueber `docker push` (Phase 6) |
@@ -0,0 +1,755 @@
# Phase 18: Desktop-Client fertigstellen - Pattern Map
**Mapped:** 2026-09-16
**Files analyzed:** 24 (new/modified)
**Analogs found:** 22 / 24 (2 have no direct in-repo analog — see "No Analog Found")
## File Classification
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|-------------------|------|-----------|-----------------|---------------|
| `.gitea/scripts/desktop-version.sh` | utility (CI script) | transform (write version into files) | `.gitea/scripts/publish-images.sh` | role-match (same POSIX-sh CI-script family) |
| `.gitea/workflows/ci.yml` (new `desktop` job) | config (CI pipeline) | batch | same file, `publish`/`test` jobs | exact (extend existing job list) |
| `.gitea/scripts/publish-images.sh` (modify: copy `desktop-dist/` into API build context) | utility (CI script) | file-I/O | itself (existing) | exact |
| `.gitea/scripts/publish-release.sh` (modify: upload 2 release assets) | utility (CI script) | request-response (Gitea API) | itself (existing, idempotent GET→PATCH/POST shape) | exact |
| `apps/api/src/desktop/desktop.module.ts` | module | — | `apps/api/src/health/health.module.ts` | exact |
| `apps/api/src/desktop/desktop.controller.ts` | controller | request-response + streaming | `apps/api/src/health/health.controller.ts` (public-route shape) + `apps/api/src/dkv/dkv.controller.ts` (file-download route) | exact (composite of two analogs) |
| `apps/api/src/desktop/desktop.service.ts` | service | file-I/O | `apps/api/src/dkv/dkv.service.ts` (`getExportFile`, lines 703-732) | exact |
| `apps/api/src/desktop/desktop.service.spec.ts` | test | — | `apps/api/src/dkv/dkv.service.spec.ts` (fs-mocking pattern) + `apps/api/src/health/health.controller.spec.ts` (`@Public()` assertion pattern) | role-match (composite) |
| `apps/api/Dockerfile` (modify: `COPY desktop-dist/`) | config | file-I/O | itself (existing multi-stage Dockerfile) | exact |
| `packages/shared/src/index.ts` (add `DesktopManifest`/`DesktopManifestFile`) | model (shared types) | — | itself (existing `VersionResponse`/`HealthResponse` interfaces) | exact |
| `apps/web/src/lib/desktop.ts` | service (client-side fetch helper) | request-response | `apps/web/src/lib/app-version.ts` (`loadApiVersion`, lines 50-63) | exact |
| `apps/web/src/lib/desktop.test.ts` | test | — | `apps/web/src/lib/app-version.test.ts` | exact |
| `apps/web/src/app/(auth)/login/page.tsx` (add download link block) | component | request-response | itself (existing login page) | exact |
| `apps/web/src/app/(portal)/settings/general/desktop/page.tsx` | component (page) | request-response | `apps/web/src/app/(portal)/settings/general/account/page.tsx` | exact |
| `apps/web/src/components/settings/settings-sidebar.tsx` (add "Desktop-App" nav item) | component | — | itself (existing sidebar, "Konto" item lines 48-60) | exact |
| `apps/web/src/messages/de.json` / `en.json` (add `settings.desktop.*`, `auth.desktopDownload.*` keys) | config (i18n) | — | itself (existing `settings.account.*` block) | exact |
| `apps/web/src/app/(portal)/settings/general/desktop/desktop-settings.test.tsx` | test | — | `apps/web/src/components/settings/widget-settings-panel.test.tsx` (next-intl mock + de.json import pattern) | role-match |
| `apps/desktop/src-tauri/src/lib.rs` (modify: `/desktop/latest` check, opener call, autostart tray item, umlaut texts) | provider (Tauri app setup) | event-driven | itself (existing version-check block, lines 82-101; tray menu, lines 41-66) | exact |
| `apps/desktop/src/setup.html` (polish: Sie-Form, Tessera-Farben) | component (static HTML) | — | itself (existing setup.html, already Tessera-oklch-themed) | exact |
| `apps/desktop/src-tauri/capabilities/default.json` (add `opener:allow-open-url`, `autostart` toggle perms already present) | config | — | itself (existing permissions list) | exact |
| `apps/desktop/src-tauri/Cargo.toml` (add `tauri-plugin-opener`) | config | — | itself | exact |
| `docs/anleitung-anwender.md` (new "Desktop-App" chapter) | doc | — | itself (existing "Die Module" chapter pattern, e.g. "DKV-Rechnung" §120) | role-match |
| `docs/anleitung-betrieb.md` (pipeline/desktop-dist/release section) | doc | — | itself (existing §9 "Zwei Kanäle: Live und Beta") | role-match |
| `docs/anleitung-entwicklung.md` (update `apps/desktop` description, §39) | doc | — | itself (existing paragraph at line 39) | exact |
| `CHANGELOG.md` (Unveröffentlicht → ### Neu bullet) | doc | — | itself (existing `### Neu` bullet style) | exact |
## Pattern Assignments
### `.gitea/scripts/desktop-version.sh` (utility, transform)
**Analog:** `.gitea/scripts/publish-images.sh`
**Style pattern to copy** (whole file is the model — POSIX `sh`, `set -eu`, German header comment explaining the "why", decision driven only by git state so it's testable locally):
```sh
#!/bin/sh
# <script-name>.sh -- <one-line purpose> (phase-18)
#
# <what it decides and why, in German, matching the existing header style>
set -eu
TAG_VERSION="$(git describe --tags --abbrev=0 2>/dev/null || echo v0.0.0)"
VERSION="${TAG_VERSION#v}" # plain X.Y.Z only — NSIS numeric-version constraint (Pitfall 2)
CONF="apps/desktop/src-tauri/tauri.conf.json"
CARGO="apps/desktop/src-tauri/Cargo.toml"
jq --arg v "$VERSION" '.version = $v' "$CONF" > "$CONF.tmp" && mv "$CONF.tmp" "$CONF"
sed -i "s/^version = \".*\"/version = \"$VERSION\"/" "$CARGO"
echo "Desktop version set to $VERSION (from tag $TAG_VERSION)"
```
**Reusable conventions from `publish-images.sh`** (lines 22-46 of that file): `set -eu` at top; `REF="${GITHUB_REF:-}"`-style env-var-with-default reads; a `case` statement deciding behavior from `$REF` alone (never from a runtime API call) so the script is offline-testable; every echoed status line prefixed with what happened, not just a bare value. This script never touches secrets, matching `publish-images.sh`'s own closing comment ("Dieses Skript kennt kein Secret").
---
### `.gitea/workflows/ci.yml` (config, batch — new `desktop` job)
**Analog:** same file, existing `test`/`publish` job shape (lines 35-74)
**Job skeleton pattern** (copy the `needs`/`runs-on`/step-naming convention):
```yaml
test:
name: Tests
runs-on: ubuntu-latest
needs: quality
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
- name: Enable pnpm via corepack
run: corepack enable && corepack prepare pnpm@9.15.0 --activate
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run tests
run: pnpm test
```
New `desktop` job: `needs: test`, add `if: gitea.ref == 'refs/heads/main' || startsWith(gitea.ref, 'refs/tags/v')` (same conditional shape reasoning as the `case "$REF"` branches in `publish-images.sh`). `publish` job gains `needs: desktop` (currently `needs: test`, line 58) and a cache-restore step before its existing `docker build` invocation inside `publish-images.sh`. Step names stay in German, matching every existing step name in this file ("Enable pnpm via corepack" is the one English exception already present — follow whichever is already there per step, don't invent a third style).
---
### `.gitea/scripts/publish-images.sh` (utility, file-I/O — modify to copy `desktop-dist/`)
**Analog:** itself
**Insertion point** (before the existing build loop, lines 57-68):
```sh
for IMG in web api; do
docker build -t "$REGISTRY/$IMG:$APP_CHANNEL" \
--build-arg APP_VERSION="$APP_VERSION" \
--build-arg APP_CHANNEL="$APP_CHANNEL" \
--build-arg APP_COMMIT="$APP_COMMIT" \
--build-arg APP_BUILD_TIME="$APP_BUILD_TIME" \
-f "apps/$IMG/Dockerfile" .
for TAG in $TAGS; do
docker tag "$REGISTRY/$IMG:$APP_CHANNEL" "$REGISTRY/$IMG:$TAG"
docker push "$REGISTRY/$IMG:$TAG"
done
done
```
`desktop-dist/manifest.json` (sha256/size/commit per D-08) must be generated and `desktop-dist/` must exist in the build context (project root `.`) before this loop runs, since the `docker build ... -f apps/api/Dockerfile .` context is the repo root — the API Dockerfile's new `COPY desktop-dist/ /app/desktop-dist/` step reads from there. Keep the "no secrets in this script" invariant (top-of-file comment, line 21) — manifest generation needs no secret.
---
### `.gitea/scripts/publish-release.sh` (utility, request-response — modify for asset upload)
**Analog:** itself (idempotent GET→PATCH/POST pattern, lines 125-155)
**Idempotency pattern to extend** (verbatim, this is the shape new asset-upload logic must match):
```sh
CODE=$(curl -sS --header @"$HDR" -o "$RESP" -w '%{http_code}' "$TAG_URL")
case "$CODE" in
200)
ID=$(jq -r .id "$RESP")
printf '%s' "$UPDATE_JSON" > "$JSONFILE"
CODE=$(curl -sS --header @"$HDR" -X PATCH --data @"$JSONFILE" -o "$RESP" -w '%{http_code}' "$RELEASES_URL/$ID")
if [ "$CODE" = "200" ]; then
echo "Release $TAG aktualisiert (id $ID)"
else
echo "PATCH $RELEASES_URL/$ID antwortete mit $CODE:" >&2
cat "$RESP" >&2
exit 1
fi
;;
404)
...
;;
*)
echo "GET $TAG_URL antwortete mit $CODE:" >&2
cat "$RESP" >&2
exit 1
;;
esac
```
**Secret-handling pattern to reuse exactly** (lines 117-123 — cited directly in RESEARCH.md's Security Domain section):
```sh
umask 077
TMPDIR_REL=$(mktemp -d)
trap 'rm -rf "$TMPDIR_REL"' EXIT INT TERM
HDR="$TMPDIR_REL/headers"
RESP="$TMPDIR_REL/response.json"
JSONFILE="$TMPDIR_REL/payload.json"
printf 'Authorization: token %s\nContent-Type: application/json\n' "$GITEA_TOKEN" > "$HDR"
```
New `upload_asset()` function (per RESEARCH.md Code Example #6) should follow the same "GET, decide by HTTP code via `case`, act" shape — for assets: `GET .../assets`, find existing by `name` via `jq`, `DELETE` if found, then `POST` multipart. This keeps one idiom in the file instead of introducing a second (per RESEARCH.md's "Don't Hand-Roll" table).
---
### `apps/api/src/desktop/desktop.module.ts` (module)
**Analog:** `apps/api/src/health/health.module.ts` (entire file, 7 lines)
```typescript
import { Module } from '@nestjs/common';
import { HealthController } from './health.controller';
@Module({
controllers: [HealthController],
})
export class HealthModule {}
```
Copy verbatim, swap names. Since `DesktopController` needs `DesktopService` (unlike the dependency-free `HealthController`), add `providers: [DesktopService]` — no other analog needed, this is the standard NestJS module shape used throughout `apps/api/src/*` (confirmed by `DkvModule`'s equivalent `controllers`+`providers` shape).
---
### `apps/api/src/desktop/desktop.controller.ts` (controller, request-response + streaming)
**Analog A — public-route shape:** `apps/api/src/health/health.controller.ts` (whole file, 25 lines)
```typescript
import { Controller, Get } from '@nestjs/common';
import type { HealthResponse, VersionResponse } from '@tessera/shared';
import { Public } from '../auth/decorators/public.decorator';
import { getAppVersion } from './app-version';
@Controller('health')
export class HealthController {
@Public()
@Get()
check(): HealthResponse {
return { status: 'ok', timestamp: new Date().toISOString() };
}
// Bewusst oeffentlich (T-KU1-03): Betreiber-Kontrolle per `curl` auf dem
// Server ohne Anmeldung. ...
@Public()
@Get('version')
getVersion(): VersionResponse {
return getAppVersion();
}
}
```
`DesktopController` follows the identical `@Public() @Get(...)` shape for `GET /desktop/latest`, with the same style of a comment explaining *why* it's public (D-10: login page shows the link before auth exists).
**Analog B — file-download route + error mapping:** `apps/api/src/dkv/dkv.controller.ts` (lines 133-160)
```typescript
@Get('exports/:filename')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async downloadExport(
@Req() req: any,
@Param('filename') filename: string,
@Res() res: any,
) {
const tenantId = this._requireTenant(req);
try {
const buffer = await this.dkvService.getExportFile(tenantId, filename);
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
res.send(buffer);
} catch (error) {
if (error instanceof NotFoundException || error instanceof BadRequestException) throw error;
throw error;
}
}
```
**Difference to apply deliberately:** `dkv.controller.ts` buffers the whole file in memory (`fs.readFileSync` inside the service, `res.send(buffer)`). Installer files are much larger than xlsx exports, so `desktop.controller.ts` should stream instead — use NestJS's `StreamableFile` (no in-repo precedent; follow RESEARCH.md Code Example #2 / NestJS official docs verbatim: `fs.createReadStream`, `res.set({...})`, `return new StreamableFile(stream)`). Keep `@Public()` (no `@Roles()`!) on both new routes — this is the one deliberate deviation from the `dkv.controller.ts` analog, which is `@Roles(Role.ADMIN, Role.SUPER_ADMIN)`-gated.
**Auth pattern (what NOT to add):** confirm via `apps/api/src/auth/decorators/public.decorator.ts` (whole file):
```typescript
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
```
The global `JwtAuthGuard` checks this metadata to skip auth — both new routes need `@Public()`, matching `HealthController`.
---
### `apps/api/src/desktop/desktop.service.ts` (service, file-I/O)
**Analog:** `apps/api/src/dkv/dkv.service.ts`, `getExportFile()` (lines 703-732, verbatim)
```typescript
async getExportFile(tenantId: string, filename: string): Promise<Buffer> {
// Stage 1 (unchanged, T-07-09): traversal guard, whitelist-validate the
// filename before doing anything else with it.
if (
filename.includes('/') ||
filename.includes('\\') ||
filename.includes('..') ||
!/^(RG-DKV-|DKV_)[\w\-]+\.xlsx$/.test(filename)
) {
throw new BadRequestException('Invalid export filename');
}
// Stage 2: ownership/whitelist gate ...
const filePath = path.join(this.userFilesDir, filename);
if (!fs.existsSync(filePath)) {
throw new NotFoundException(`Export file not found: ${filename}`);
}
return fs.readFileSync(filePath);
}
```
**Direct application (per RESEARCH.md Code Example #2 and D-10):** whitelist `platform` against a fixed `const PLATFORMS = ['windows', 'linux'] as const` enum (equivalent to the regex-whitelist stage above, just simpler since there's no dynamic filename from the request at all), resolve the filename **exclusively** from `manifest.json` (never from `:platform` directly — stronger than the DKV pattern, which at least regex-validates a request-supplied filename; here the request never supplies a filename at all), then `fs.existsSync`/stream. Imports pattern to copy (`dkv.service.ts` lines 1-9):
```typescript
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import * as fs from 'fs';
import * as path from 'path';
```
**Manifest-reading + platform-whitelist shape** (already fully worked out in RESEARCH.md Code Examples §5, cite as-is):
```typescript
const PLATFORMS = ['windows', 'linux'] as const;
type Platform = (typeof PLATFORMS)[number];
async getManifest(): Promise<DesktopManifest | null> {
const manifestPath = path.join(this.desktopDistDir, 'manifest.json');
if (!fs.existsSync(manifestPath)) return null;
return JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
}
async getPackageStream(platform: string): Promise<{ stream: fs.ReadStream; entry: ManifestFileEntry }> {
if (!PLATFORMS.includes(platform as Platform)) {
throw new BadRequestException(`Unknown platform: ${platform}`);
}
const manifest = await this.getManifest();
if (!manifest) throw new NotFoundException('Desktop packages not available');
const entry = manifest.files[platform as Platform];
if (!entry) throw new NotFoundException(`No package for platform: ${platform}`);
const filePath = path.join(this.desktopDistDir, entry.name);
if (!fs.existsSync(filePath)) throw new NotFoundException(`Package file missing: ${entry.name}`);
return { stream: fs.createReadStream(filePath), entry };
}
```
---
### `apps/api/src/desktop/desktop.service.spec.ts` (test)
**Analog A — fs mocking under ESM:** `apps/api/src/dkv/dkv.service.spec.ts` (lines 27-31, verbatim — this exact technique is required, `vi.spyOn(fs, ...)` does not work under this project's ESM setup)
```typescript
// `import * as fs from 'fs'` under ESM has a non-configurable module
// namespace — vi.spyOn(fs, 'existsSync') fails with "Cannot redefine
// property". vi.mock() replaces the module at import time instead, which
// works regardless of namespace configurability (Tests 8-10, Aufgabe 3).
vi.mock('fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('fs')>();
return { ...actual, existsSync: vi.fn(), readFileSync: vi.fn() };
});
```
**Analog B — `@Public()` metadata assertion + header-comment style + numbered `it()` naming:** `apps/api/src/health/health.controller.spec.ts` (whole file, especially Test 6, lines 94-97)
```typescript
it('Test 6 (bewusst oeffentlich, T-KU1-03): getVersion und check tragen @Public()', () => {
expect(Reflect.getMetadata(IS_PUBLIC_KEY, HealthController.prototype.getVersion)).toBe(true);
expect(Reflect.getMetadata(IS_PUBLIC_KEY, HealthController.prototype.check)).toBe(true);
});
```
Required test cases per D-16/RESEARCH.md Test Map: manifest present → 200 JSON; manifest/dir missing → 404; unknown platform → 400 (BadRequestException); traversal-style input (`../../etc/passwd` as `:platform` value) rejected by the whitelist before any `fs` call — assert `fs.existsSync`/`readFileSync` mocks were never called with a traversal string, same spirit as the DKV spec's bound-vs-unbound-client double-mock technique for proving isolation.
---
### `apps/api/Dockerfile` (config, file-I/O — modify)
**Analog:** itself (existing multi-stage `runner` stage, lines 28-52)
**Insertion pattern** — follow the existing `COPY --from=builder ... ./`-then-chown convention (lines 36-49):
```dockerfile
RUN addgroup --system --gid 1001 nestjs && \
adduser --system --uid 1001 nestjs && \
mkdir -p /app/user-files && \
chown nestjs:nestjs /app/user-files
...
COPY --from=builder /app/packages/shared/src ./packages/shared/src
COPY apps/api/scripts ./apps/api/scripts
USER nestjs
```
Add `COPY desktop-dist ./desktop-dist` (build context is repo root, matching `publish-images.sh`'s `docker build ... -f "apps/$IMG/Dockerfile" .`) before `USER nestjs`, and extend the `mkdir`/`chown` line if the runtime reads need write-free but readable-by-`nestjs` permissions (it's read-only at runtime, so a plain `COPY` — which defaults to root-owned, world-readable — is sufficient; no `chown` needed unless the file server needs to write, which D-08 says it doesn't).
---
### `packages/shared/src/index.ts` (model — add types)
**Analog:** itself (existing `HealthResponse`/`VersionResponse` interfaces, lines 3-20ish)
```typescript
export interface HealthResponse {
status: string;
timestamp: string;
}
export interface VersionResponse {
name: string;
version: string;
channel: string;
commit: string;
buildTime: string;
}
```
Add `DesktopManifestFile`/`DesktopManifest` in the same file, same flat-interface style (per RESEARCH.md Code Example #7):
```typescript
export interface DesktopManifestFile {
name: string;
size: number;
sha256: string;
}
export interface DesktopManifest {
version: string;
commit: string;
buildTime: string;
files: {
windows: DesktopManifestFile;
linux: DesktopManifestFile;
};
}
```
---
### `apps/web/src/lib/desktop.ts` (service — client fetch helper)
**Analog:** `apps/web/src/lib/app-version.ts`, `loadApiVersion()` (lines 50-63, verbatim)
```typescript
let apiVersionPromise: Promise<ApiVersionInfo | null> | null = null;
export function loadApiVersion(): Promise<ApiVersionInfo | null> {
if (!apiVersionPromise) {
apiVersionPromise = fetch(`${API_URL}/health/version`, { credentials: 'include' })
.then((res) => (res.ok ? (res.json() as Promise<ApiVersionInfo>) : null))
.catch(() => null);
}
return apiVersionPromise;
}
```
Copy the memoized-single-promise, fail-silent-to-`null` shape exactly for `loadDesktopLatest()`. Note: the login page renders unauthenticated, so **omit** `credentials: 'include'` (or keep it — the file's own doc-comment at lines 8-14 explains it's harmless either way since `/desktop/latest` is `@Public()`). `API_URL` constant pattern to reuse (line 37):
```typescript
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
```
---
### `apps/web/src/lib/desktop.test.ts` (test)
**Analog:** `apps/web/src/lib/app-version.test.ts` (whole file, 84 lines)
```typescript
async function importFresh() {
vi.resetModules();
return import('./app-version');
}
...
it('Test 4 (Laden, memoisiert): zwei Aufrufe liefern das Objekt, fetch laeuft genau einmal mit Cookie', async () => {
const fetchMock = vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve(payload) }));
vi.stubGlobal('fetch', fetchMock);
const mod = await importFresh();
const first = await mod.loadApiVersion();
const second = await mod.loadApiVersion();
expect(first).toEqual(payload);
expect(second).toEqual(payload);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('Test 5 (still bei Fehler): Netzfehler und ok=false liefern null, nichts wird geworfen', async () => {
vi.stubGlobal('fetch', vi.fn(() => Promise.reject(new Error('netz'))));
const rejected = await importFresh();
await expect(rejected.loadApiVersion()).resolves.toBeNull();
});
```
Same `vi.resetModules()` + dynamic re-import pattern is required because the module-level promise is memoized — reuse verbatim for `loadDesktopLatest()` (module-reset-per-test, fetch mocked once/twice/error cases).
---
### `apps/web/src/app/(auth)/login/page.tsx` (component — add download link block)
**Analog:** itself (existing file, `'use client'`, `useTranslations('auth')`, structure lines 1-38 + submit button area ~150-165)
Insertion pattern — new block below the `<form>`, following the existing `Link`+`useTranslations` conventions already used for `forgotPassword` (lines 143-150):
```tsx
<div className="flex justify-end">
<Link
href="/reset-password"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
{t('forgotPassword')}
</Link>
</div>
```
The new desktop-download block needs a client-side `useEffect`+`useState` pair calling `loadDesktopLatest()` (unlike the rest of the page, which is a synchronous form) — mirror the `AppVersionBadge` component's consumption of `loadApiVersion()` for that async-render-then-hide-if-null pattern (`apps/web/src/components/layout/app-version-badge.tsx`, cited in RESEARCH.md Sources, not independently re-read this session since the shape is identical to the `lib/desktop.ts` mirror above — read it before writing this component if the exact hook shape is needed).
---
### `apps/web/src/app/(portal)/settings/general/desktop/page.tsx` (component — page)
**Analog:** `apps/web/src/app/(portal)/settings/general/account/page.tsx` (whole file, 21 lines)
```tsx
'use client';
import { useTranslations } from 'next-intl';
import { AccountSettingsForm } from '@/components/settings/account-settings-form';
/**
* Account settings page — /settings/general/account.
* Shows avatar upload and (for local users only) password change form.
*/
export default function AccountSettingsPage() {
const t = useTranslations('settings');
return (
<div>
<h1 className="mb-6 text-lg font-semibold text-foreground">
{t('account.title')}
</h1>
<AccountSettingsForm />
</div>
);
}
```
Copy this exact page-shell shape: `'use client'`, `useTranslations('settings')`, `<h1>` title, then delegate the real content to a dedicated component (`DesktopAppSettings` or similar, under `apps/web/src/components/settings/`, matching the codebase's page-vs-component split already used for `account`/`calendar`/`widget` settings).
---
### `apps/web/src/components/settings/settings-sidebar.tsx` (component — add nav item)
**Analog:** itself (existing "Konto" nav item under "Allgemein" category, lines 41-61)
```tsx
{/* Allgemein category — above Dashboard (Surface C, 07-06) */}
<div className="p-4 pb-2">
<h2 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{t('categoryGeneral')}
</h2>
</div>
<nav className="mb-2 flex flex-col gap-1 px-3">
<Link
href="/settings/general/account"
className={`flex items-center rounded-md px-2 py-1.5 text-sm transition-colors ${
isActive('/settings/general/account')
? 'bg-sidebar-accent text-sidebar-accent-foreground font-medium'
: 'text-sidebar-foreground hover:bg-muted'
}`}
aria-current={isActive('/settings/general/account') ? 'page' : undefined}
>
{t('categoryAccount')}
</Link>
</nav>
```
Add a second `<Link href="/settings/general/desktop">` inside the same `<nav>` under "Allgemein", using `t('categoryDesktopApp')` (new i18n key) — same `isActive()`/`aria-current` pattern, since `isActive()` (lines 27-33) already does a generic `pathname.startsWith(href)` fallback that works unmodified for the new route.
---
### `apps/web/src/messages/de.json` / `en.json` (i18n)
**Analog:** itself — existing `settings.account.*` nested block
```json
"account": {
"title": "Konto",
"avatarLabel": "Profilbild",
...
}
```
Add `settings.desktop.*` (title, version label, download buttons, file-size format, 3-4 explanatory sentences, all in Sie-Form per D-12/D-13) and `settings.categoryDesktopApp` (nav label) plus `auth.desktopDownload.*` (login-page link labels) following the identical flat-nested-object convention. Mirror every German key 1:1 into `en.json` (confirmed both files share identical key structure across all existing namespaces).
---
### `apps/web/src/app/(portal)/settings/general/desktop/desktop-settings.test.tsx` (test)
**Analog:** `apps/web/src/components/settings/widget-settings-panel.test.tsx` (next-intl mock, lines 1-30, and `de.json`-driven text assertions)
```tsx
vi.mock('next-intl', async () => {
const messages = (await import('@/messages/de.json')).default as Record<string, unknown>;
const lookup = (path: string): string | undefined =>
path.split('.').reduce<unknown>((o, k) => (o && typeof o === 'object' ? (o as any)[k] : undefined), messages) as
| string
| undefined;
return {
useTranslations:
(ns?: string) =>
(key: string, values?: Record<string, unknown>) => {
const raw = lookup(ns ? `${ns}.${key}` : key) ?? key;
return values ? raw.replace(/\{(\w+)\}/g, (_: string, n: string) => String(values[n] ?? '')) : raw;
},
};
});
```
Combine with `apps/web/src/lib/app-version.test.ts`'s `vi.stubGlobal('fetch', ...)` pattern to mock `/desktop/latest` responses for the two required cases (DESK-03 test map): link/section renders with version+size+buttons when the API responds 200; link/section is absent when the API 404s. Same combination applies to the login-page test (new or extended file — none found for `login` in this research pass per RESEARCH.md Wave 0 Gaps).
---
### `apps/desktop/src-tauri/src/lib.rs` (provider, event-driven — modify)
**Analog:** itself, existing version-check block (lines 82-101) and tray menu (lines 41-66)
**Existing version-check block to redirect** (verbatim, current state):
```rust
if let Some(server_url) = url_for_check {
let app_handle = app.handle().clone();
let app_version = env!("CARGO_PKG_VERSION").to_string();
tauri::async_runtime::spawn(async move {
let url = format!("{}/health/version", server_url.trim_end_matches('/'));
if let Ok(resp) = reqwest::get(&url).await {
if let Ok(info) = resp.json::<VersionResponse>().await {
if info.version != app_version {
let _ = app_handle
.notification()
.builder()
.title("Tessera Update")
.body("Eine neue Version ist verfuegbar.")
.show();
}
}
}
});
}
```
Change target URL to `/desktop/latest`, update the notification body per D-13 ("Neue Version X.Y.Z verfuegbar" — interpolate `info.version`), and enable the tray "Update herunterladen" item on version mismatch (needs holding a `MenuItem` handle created during `.setup()`, same builder family as `open`/`quit` below).
**Existing tray-menu pattern to extend** (verbatim, lines 41-66 — note current "Oeffnen"/"Beenden" lack umlauts, D-13 requires fixing to "Öffnen"/"Beenden"):
```rust
let open = MenuItemBuilder::with_id("open", "Oeffnen").build(app)?;
let quit = MenuItemBuilder::with_id("quit", "Beenden").build(app)?;
let menu = MenuBuilder::new(app)
.item(&open)
.separator()
.item(&quit)
.build()?;
...
.on_menu_event(|app, event| match event.id().as_ref() {
"open" => { ... }
"quit" => { app.exit(0); }
_ => {}
})
```
Add `update` (opener call, RESEARCH.md Code Example #4) and `autostart` (`CheckMenuItemBuilder`, RESEARCH.md Code Example #5) items into this same `MenuBuilder` chain and `match` arm list — same builder/match idiom, no new pattern needed.
**Imports to add** at the top (alongside existing `use tauri_plugin_...` lines 7-9):
```rust
use tauri_plugin_opener::OpenerExt;
use tauri_plugin_autostart::ManagerExt;
```
---
### `apps/desktop/src/setup.html` (component — polish)
**Analog:** itself — already Tessera-themed (oklch brand colors, e.g. `oklch(0.91 0.19 102)` for the `<h1>`, `oklch(0.17 0.01 260)` background, lines 1-60). D-13's "Tessera-Farben" requirement is largely already satisfied; the remaining work is auditing body-text strings for Du-form and converting to Sie-form (per project convention: app texts always use Sie-form, per user's global memory `feedback_anrede_du.md`). No structural analog change needed — read the full 254-line file directly when executing, since it's small enough for one `Read` call, and grep for `du/dein/dich/deine` occurrences to fix.
---
### `apps/desktop/src-tauri/capabilities/default.json` (config)
**Analog:** itself (existing permissions array, whole file)
```json
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Tessera desktop capabilities",
"windows": ["main"],
"permissions": [
"core:default",
"store:default",
"notification:default",
"notification:allow-is-permission-granted",
"notification:allow-request-permission",
"notification:allow-notify",
"autostart:allow-enable",
"autostart:allow-disable",
"autostart:allow-is-enabled",
"window-state:default"
]
}
```
Append a scoped opener permission object (not a bare string, since it needs a URL scope) per RESEARCH.md Code Example #4:
```json
{ "identifier": "opener:allow-open-url", "allow": [{ "url": "https://*" }, { "url": "http://*" }] }
```
`http://*` is required because D-02 permits non-HTTPS server addresses for internal LAN use (same reasoning already present in `setup.html`'s existing HTTP warning). Autostart permissions (`allow-enable`/`allow-disable`/`allow-is-enabled`) are already present — no change needed there.
---
### `apps/desktop/src-tauri/Cargo.toml` (config)
**Analog:** itself (existing `[dependencies]` block, lines 13-21)
```toml
[dependencies]
tauri = { version = "2", features = ["tray-icon"] }
tauri-plugin-store = "2"
tauri-plugin-notification = "2"
tauri-plugin-autostart = "2"
tauri-plugin-window-state = "2"
reqwest = { version = "0.12", features = ["json"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
```
Add `tauri-plugin-opener = "2"` in the same unpinned-major style as every other `tauri-plugin-*` line (no lockfile hand-editing — `cargo add tauri-plugin-opener` regenerates `Cargo.lock`, matching how the other four plugins were presumably added in Phase 6).
---
### Docs (`docs/anleitung-anwender.md`, `docs/anleitung-betrieb.md`, `docs/anleitung-entwicklung.md`)
**Analog for anwender.md:** existing `### DKV-Rechnung` module sub-chapter (line 120) under `## Die Module` (line 95) — same H2/H3 nesting and "what it is / how to use it" narrative tone in Sie-Form. New "Desktop-App" content per D-15 fits better as its own `##` chapter (parallel to `## Dashboard`, `## Marktplatz`) since it's not a module in the marketplace sense — insert after `## Persönliche Einstellungen` (line 143) and before `## Einen Fehler melden` (line 160), and add it to the `## Inhaltsverzeichnis` (line 6) in the same list style as every other chapter entry there.
**Analog for betrieb.md:** existing `## 9. Zwei Kanäle: Live und Beta` (line 357), specifically its `### Die eine Zeile je Server` (line 385) and `### Eine Version freigeben` (line 430) sub-sections — same numbered-`##`-chapter, `###`-subsection, imperative-instruction style. New pipeline/desktop-dist/release content fits as a new numbered section (e.g. `## 10.`) or a new `###` under an existing pipeline-adjacent section (`## 8. Abgrenzung zur CI/CD-Pipeline`, line 343) — follow whichever the phase's plan decides, but match this file's existing numbered-heading + Inhaltsverzeichnis-list convention (line 12).
**Analog for entwicklung.md:** existing paragraph at line 39 (exact text to replace):
```
`apps/desktop` besteht bislang nur aus dem Tauri-Grundgerüst (`src-tauri/`) und einer einzelnen
```
Replace this sentence to reflect the finished state (no longer "nur ... Grundgerüst") and add the local-build instructions (`pnpm --filter @tessera/desktop build`) per D-15, matching this file's existing code-block + prose style used elsewhere in `## Lokale Entwicklungsumgebung` (line 58, `### Stack starten`, line 82).
---
### `CHANGELOG.md` (doc)
**Analog:** itself — existing `## Unveröffentlicht` → `### Neu` bullet list (lines 5-9)
```markdown
## Unveröffentlicht
### Neu
- Kalender-Widget: Monatsübersicht mit Terminanzahl je Tag, Termine beim Überfahren, darunter „Nächste Termine“
- Kalender-Widget: Einstellungen für Monatsansicht, Anzahl und Zeitraum der Termine
- Favoriten-Widget: optionaler Titel (ohne Titel keine Kopfzeile)
```
Add per D-17, same bullet style (bold-free, colon-separated feature:description shape):
```markdown
- Desktop-App für Windows und Linux: Download auf der Anmeldeseite und unter Einstellungen → Desktop-App
```
## Shared Patterns
### Public, unauthenticated route (`@Public()`)
**Source:** `apps/api/src/auth/decorators/public.decorator.ts` (whole file) + `apps/api/src/health/health.controller.ts` (lines 8-9, 20-21)
**Apply to:** Both `apps/api/src/desktop/desktop.controller.ts` routes (`GET /desktop/latest`, `GET /desktop/download/:platform`)
```typescript
@Public()
@Get('version')
getVersion(): VersionResponse {
return getAppVersion();
}
```
Pin with a spec test asserting `Reflect.getMetadata(IS_PUBLIC_KEY, DesktopController.prototype.getLatest)` (and `.download`) `=== true`, matching `health.controller.spec.ts` Test 6 — this is explicitly called out in RESEARCH.md's V4 Access Control row as the negative case to guard (routes must NOT accidentally inherit tenant/role checks).
### Whitelist-then-lookup file access (never trust request input for a filesystem path)
**Source:** `apps/api/src/dkv/dkv.service.ts:703-729` (`getExportFile`)
**Apply to:** `apps/api/src/desktop/desktop.service.ts` (`getPackageStream`)
Two-stage gate: (1) reject the identifier via a fixed whitelist before any filesystem touch (regex for DKV filenames; a 2-item `const PLATFORMS` array for desktop platforms — stricter, since desktop never even accepts a filename from the request), (2) resolve the actual file path only from a trusted, non-request-derived source (DKV: an ownership row in the DB; desktop: `manifest.json`, written only by CI). Both throw `BadRequestException` for the whitelist failure and `NotFoundException` for the missing-file case — reuse these same two exception types.
### Memoized public fetch, fail-silent-to-null
**Source:** `apps/web/src/lib/app-version.ts:50-63` (`loadApiVersion`)
**Apply to:** `apps/web/src/lib/desktop.ts` (`loadDesktopLatest`), and by extension every component consuming it (login page, settings page) which should treat `null` as "hide this UI", never as an error to surface
```typescript
let apiVersionPromise: Promise<ApiVersionInfo | null> | null = null;
export function loadApiVersion(): Promise<ApiVersionInfo | null> {
if (!apiVersionPromise) {
apiVersionPromise = fetch(`${API_URL}/health/version`, { credentials: 'include' })
.then((res) => (res.ok ? (res.json() as Promise<ApiVersionInfo>) : null))
.catch(() => null);
}
return apiVersionPromise;
}
```
### CI script idempotency (GET → decide by HTTP code → PATCH-or-POST)
**Source:** `.gitea/scripts/publish-release.sh:125-155`
**Apply to:** New asset-upload logic in the same script (D-08); any future CI script touching the Gitea API
```sh
CODE=$(curl -sS --header @"$HDR" -o "$RESP" -w '%{http_code}' "$TAG_URL")
case "$CODE" in
200) ... PATCH ... ;;
404) ... POST ... ;;
*) echo "... antwortete mit $CODE:" >&2; cat "$RESP" >&2; exit 1 ;;
esac
```
### fs mocking under ESM (Vitest)
**Source:** `apps/api/src/dkv/dkv.service.spec.ts:27-31`
**Apply to:** `apps/api/src/desktop/desktop.service.spec.ts` (manifest read + platform whitelist + traversal tests all need `fs.existsSync`/`readFileSync` mocked)
```typescript
vi.mock('fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('fs')>();
return { ...actual, existsSync: vi.fn(), readFileSync: vi.fn() };
});
```
`vi.spyOn(fs, 'existsSync')` fails under this project's ESM setup ("Cannot redefine property") — `vi.mock()` is mandatory, not optional style.
### German-first documentation and UI copy in Sie-Form
**Source:** every file in `docs/`, every `apps/web/src/messages/de.json` string, every CI script's German header comments
**Apply to:** all new docs chapters, all new i18n keys, all new `lib.rs`/`setup.html` user-facing strings (tray texts, notifications, setup-page copy) — matches the user's standing global instruction (Sie-Form for app texts, Du-form only in conversation) and this repo's own established convention.
## No Analog Found
| File | Role | Data Flow | Reason |
|------|------|-----------|--------|
| `apps/api/src/desktop/desktop.controller.ts` (streaming half only — `StreamableFile` usage) | controller | streaming | No route in this codebase currently streams a file via `StreamableFile`; `dkv.controller.ts`'s equivalent buffers the whole file with `res.send(buffer)` instead. Use RESEARCH.md Code Example #2 (cites `docs.nestjs.com` Techniques > Streaming Files directly) rather than an in-repo precedent. |
| `apps/desktop/src-tauri/src/lib.rs` (`CheckMenuItemBuilder` for the autostart tray toggle) | provider | event-driven | No existing `CheckMenuItem` (checkbox-style tray item) exists in `lib.rs` today — only plain `MenuItemBuilder` items (`open`, `quit`). RESEARCH.md Code Example #5 (cites `v2.tauri.app/plugin/autostart/`) is the reference; the builder/match-arm *shape* to slot it into is still the existing tray-menu pattern above. |
## Metadata
**Analog search scope:** `apps/api/src/health/`, `apps/api/src/dkv/`, `apps/api/src/auth/decorators/`, `apps/api/Dockerfile`, `packages/shared/src/`, `apps/web/src/lib/`, `apps/web/src/app/(auth)/login/`, `apps/web/src/app/(portal)/settings/`, `apps/web/src/components/settings/`, `apps/web/src/messages/`, `.gitea/workflows/`, `.gitea/scripts/`, `apps/desktop/src-tauri/`, `apps/desktop/src/`, `docs/`, `CHANGELOG.md`
**Files scanned:** ~30 (all read fully or via targeted `sed -n`/`grep -n` ranges; no re-reads of the same line range)
**Pattern extraction date:** 2026-09-16
**Tracked-source gate:** all 27 analog paths verified via `git ls-files` — all tracked, none are gitignored mirrors.
@@ -0,0 +1,749 @@
# Phase 18: Desktop-Client fertigstellen - Research
**Researched:** 2026-09-16
**Domain:** Tauri 2 cross-compilation (Windows NSIS on Linux), Gitea Actions CI/CD (self-hosted act_runner), NestJS 11 public file distribution, Next.js 15 desktop-download UI
**Confidence:** MEDIUM (cross-compile toolchain and act_runner caching verified against the live runner and official docs; the Windows-installer end-to-end run itself can only be proven inside the pipeline, per D-16)
<user_constraints>
## User Constraints (from CONTEXT.md)
### Locked Decisions
**Produkt (User)**
- **D-01:** Der Installer ist **in Tessera herunterladbar** (Anwender ohne Gitea-Zugang) **und** liegt als Datei am **Gitea-Release** des Freigabe-Tags.
- **D-02:** Server-Adresse wird weiterhin **beim ersten Start abgefragt** (ein Paket fuer alle Umgebungen/Kunden). Kein fest eingebauter Server.
- **D-03:** Updates: **Hinweis + Download-Link**, kein automatisches Aktualisieren.
**Plattformen & Bau (Claude)**
- **D-04:** Windows-Installer (NSIS, `Tessera-Setup-X.Y.Z.exe`) ist das Hauptziel; Linux-AppImage (`Tessera-X.Y.Z.AppImage`) wird mitgebaut, weil der Runner ohnehin Linux ist.
- **D-05:** Der Gitea-Runner ist Linux (`gitea/runner-images:ubuntu-latest`, Docker, 8 Kerne/15 GB). Der Windows-Bau laeuft als **Cross-Bau auf Linux** (Tauri: `cargo tauri build --runner cargo-xwin --target x86_64-pc-windows-msvc`, NSIS via `makensis` aus dem Ubuntu-Paket `nsis`, `llvm`/`lld`/`clang`). Kein Windows-Rechner in der Pipeline.
- **D-06:** Neuer CI-Job `desktop` nach `test`, laeuft bei Push auf `main` und bei Tags `v*` (Beta bekommt die Pakete auch, sonst ist nichts testbar). Cargo-Registry, `target/` und das xwin-SDK werden per `actions/cache` zwischengespeichert; Forschung klaert, ob der lokale act_runner den Cache-Server anbietet — wenn nicht, laeuft der Bau ohne Cache (langsamer, aber korrekt).
- **D-07:** Versionsquelle ist der Freigabe-Tag: Ein Skript (`.gitea/scripts/desktop-version.sh`) schreibt vor dem Bau die Version (`X.Y.Z` aus dem letzten Tag) in `apps/desktop/src-tauri/tauri.conf.json` und `Cargo.toml`. Beta-Builds tragen dieselbe `X.Y.Z` wie der letzte Tag plus den Commit-Stempel in einem separaten Feld/Dateinamen-Suffix (Forschung: welche Versionsformen NSIS/Tauri auf Windows akzeptieren; Regel: keine Form waehlen, die den Windows-Installer scheitern laesst).
- **D-08:** **Verteilung ohne Netzabhaengigkeit:** Die gebauten Pakete werden im `publish`-Job in das API-Abbild kopiert (`/app/desktop-dist/` mit `manifest.json`: Version, Dateinamen, Groessen, SHA-256). Die API liefert sie selbst aus — Live-Server brauchen keinen Zugang zu Gitea. Zusaetzlich haengt `publish-release.sh` (nur bei Tags) beide Dateien als Release-Assets an das Gitea-Release (D-01).
- **D-09:** Keine Code-Signierung (intern; SmartScreen-Hinweis wird im Anwenderhandbuch erklaert).
**API (Claude)**
- **D-10:** Neues Modul `apps/api/src/desktop/`: `GET /desktop/latest` (oeffentlich, ohne Anmeldung — die Anmeldeseite zeigt den Link) liefert `{ version, files: { windows: { name, size, sha256, url }, linux: {...} } }` aus `manifest.json`; `GET /desktop/download/:platform` (`windows` | `linux`, oeffentlich) streamt die Datei mit `Content-Disposition: attachment`. Fehlt das Verzeichnis/Manifest: `404` mit klarer Meldung; die Web-Oberflaeche blendet den Link dann aus. Nur Dateinamen aus dem Manifest werden geoeffnet (kein Pfad aus der Anfrage), Plattform per Whitelist.
- **D-11:** `/health/version` bleibt unveraendert; der Client vergleicht seine Version kuenftig mit `/desktop/latest`.
**Web (Claude)**
- **D-12:** Anmeldeseite: unauffaelliger Link unterhalb des Formulars "Desktop-App herunterladen (Windows)" + kleiner Linux-Link, nur wenn `/desktop/latest` antwortet. Einstellungen: neuer Eintrag **Einstellungen → Allgemein → Desktop-App** mit Version, beiden Download-Knoepfen, Dateigroesse und 3-4 Saetzen (Was ist das, Erststart, Tray). Texte de/en, Sie-Form.
**Client (Claude)**
- **D-13:** `lib.rs`: Versionspruefung gegen `{server}/desktop/latest`; bei abweichender Version Benachrichtigung "Neue Version X.Y.Z verfuegbar" und Tray-Menuepunkt "Update herunterladen", der `{server}/settings/general/desktop` im Systembrowser oeffnet (`tauri-plugin-opener` oder `open`-Crate — Forschung waehlt). Erststart-Seite (`setup.html`): Adresse pruefen ueber `/health/version` (bleibt), Texte in Sie-Form, Tessera-Farben; Tray-Texte mit Umlauten ("Öffnen", "Beenden").
- **D-14:** Bestehende Phase-6-Funktionen (Tray, Schliessen-ins-Tray, Autostart, Fensterzustand) bleiben unveraendert; Autostart-Schalter kommt ins Tray-Menue ("Mit Windows starten", Haken), weil es keine Client-Einstellungsseite gibt.
**Doku & Tests (Claude)**
- **D-15:** `docs/anleitung-anwender.md`: Kapitel "Desktop-App" (Download in Tessera, Installation, SmartScreen-Hinweis, Erststart mit Server-Adresse, Tray/Schliessen/Beenden, Autostart, Update-Hinweis). `docs/anleitung-betrieb.md`: Pipeline-Job, Cross-Bau, wo die Pakete im Abbild liegen, Release-Dateien, Fehlerbilder. `docs/anleitung-entwicklung.md`: `apps/desktop` ist kein Grundgeruest mehr; lokaler Bau (`pnpm --filter @tessera/desktop build`), Voraussetzungen.
- **D-16:** Tests: API-Modul (Manifest lesen, 404 ohne Manifest, Plattform-Whitelist, Pfad-Traversal abgewiesen), Web (Link erscheint/verschwindet je nach API-Antwort, Einstellungsseite), Rust: `cargo check`/`cargo clippy` im CI-Job; ein lokaler Linux-Bau (`tauri build` AppImage) als Beweis vor dem Push. Der Windows-Cross-Bau wird erst in der Pipeline bewiesen — der Plan sieht eine Iterationsschleife vor (Fehler lesen, Job anpassen, erneut pushen), bis ein gruener Lauf mit beiden Dateien vorliegt.
- **D-17:** CHANGELOG `Unveröffentlicht` → `### Neu`: "Desktop-App für Windows und Linux: Download auf der Anmeldeseite und unter Einstellungen → Desktop-App" (Stichpunkt-Stil).
### Claude's Discretion
- Aufteilung in Plaene (Vorschlag: 18-01 CI/Cross-Bau + Versionsskript + Release-Assets; 18-02 API-Modul + Abbild-Einbau; 18-03 Web-Oberflaeche + Client-Anpassungen + Handbuecher)
- Tray-Menue-Reihenfolge, Icon-Pruefung, Dateinamen-Details
### Deferred Ideas (OUT OF SCOPE)
- Auto-Update (Tauri Updater, Signaturschluessel) — spaeter, wenn extern verkauft wird
- Code-Signierung — spaeter
- Native Kalender-Erinnerungen ueber den Client — nicht Teil dieser Phase
- macOS-Paket — kein Bedarf
</user_constraints>
<phase_requirements>
## Phase Requirements
| ID | Description | Research Support |
|----|-------------|------------------|
| DESK-01 | Tauri-basierter Desktop-Wrapper fuer Windows und Linux (Fortfuehrung aus Phase 6) | Cross-Build toolchain (§ Standard Stack, § Code Examples §1–2), current NSIS+AppImage bundle targets already configured in `tauri.conf.json:29` |
| DESK-02 | Desktop-App verbindet sich mit dem Web-Backend, Server-Adresse beim Erststart (Fortfuehrung) | Unchanged `setup.html` flow; only umlaut/branding polish (D-13) — no new research needed, confirmed unchanged in `lib.rs`/`setup.html` reads |
| DESK-03 | Download in Tessera (Login-Seite + Einstellungen) | `GET /desktop/latest` + `GET /desktop/download/:platform` design (§ Architecture Patterns, § Code Examples §5–6), `loadApiVersion()` precedent in `apps/web/src/lib/app-version.ts` |
| DESK-04 | Release-Dateien in Gitea | `publish-release.sh` extension for multipart asset upload (§ Code Examples §7), idempotent re-upload |
| DESK-05 | Client-Versionierung + Update-Hinweis | `desktop-version.sh` version-injection script (§ Code Examples §3), NSIS version-format pitfall (§ Common Pitfalls #3), `tauri-plugin-opener` for the update link (§ Code Examples §8) |
</phase_requirements>
## Summary
Phase 18 turns the Phase-6 Tauri scaffold into a distributable product without adding new client behavior. The hard technical edge is cross-compiling the Windows NSIS installer on the existing Linux `act_runner` (`gitea/runner-images:ubuntu-latest`, confirmed present on the Docker host, Ubuntu 24.04, **no Rust, no `nsis`, no `webkit2gtk`/`appindicator` dev headers pre-installed** — every dependency must be installed in the job). `cargo-xwin` is the correct, currently-maintained tool for this (`cargo tauri build --runner cargo-xwin --target x86_64-pc-windows-msvc`); its Windows SDK download is cached via `XWIN_CACHE_DIR`. `reqwest`'s default `native-tls` backend resolves to Windows' built-in `schannel` crate for the Windows target (not OpenSSL), so no extra TLS wrangling is needed — the existing `Cargo.toml` `reqwest = { version = "0.12", features = ["json"] }` cross-compiles as-is.
The second edge is version-string safety: NSIS's `VIProductVersion` requires numeric-only `X.X.X.X`. Tauri's bundler (shipped since `tauri-bundler` 2.2.3, well below the installed 2.11.3) now coerces non-numeric build metadata to `.0` with a warning instead of hard-failing, but the safer, deterministic choice per D-07 is to **never put non-numeric data in the `version` field at all** — always write the plain `X.Y.Z` of the latest tag into `tauri.conf.json`/`Cargo.toml`, and carry the beta/commit distinction only in the output **filename** and in `manifest.json` (which already needs a `sha256`/`size`/`commit` per D-08).
The third edge is cross-job artifact handoff on this specific Gitea instance: `actions/upload-artifact@v4`/`download-artifact@v4` are documented to abort on Gitea (GHES-detection check), and `v3` has open reports of `500`/`400` errors on act_runner. The runner's cache server, by contrast, is confirmed **enabled and reachable** (`cache: {enabled: true, host: "172.18.0.1", port: 42641}` read directly from the running `gitea-runner` container's `/data/config.yaml`) — the recommended pattern is to reuse `actions/cache@v4`, keyed on the exact commit SHA, as the transfer mechanism between the `desktop` and `publish` jobs instead of the artifact actions.
Everything downstream of the built files (`GET /desktop/latest`, `GET /desktop/download/:platform`, the login-page link, the settings page, the tray "Update herunterladen" item) has a direct precedent already in this codebase (`DkvService.getExportFile` for path-safety, `apps/web/src/lib/app-version.ts` for the memoized public-fetch pattern, `@Public()` + global `JwtAuthGuard` for making two new routes unauthenticated).
**Primary recommendation:** Keep `tauri.conf.json`/`Cargo.toml` `version` as a plain `X.Y.Z` always (never pre-release/build metadata); do the beta-vs-release distinction entirely in the CI script layer (filename suffix + `manifest.json` fields) and pass the built Windows/Linux artifacts from the `desktop` job to the `publish` job via `actions/cache@v4` keyed on `gitea.sha`, not via the artifact-upload actions.
## Architectural Responsibility Map
| Capability | Primary Tier | Secondary Tier | Rationale |
|------------|-------------|----------------|-----------|
| Windows/Linux package build | CI / Build (Gitea Actions, self-hosted act_runner) | — | Cross-compilation only makes sense at build time; no runtime tier owns it |
| Package storage & serving | API / Backend (`apps/api/src/desktop/`) | CDN/Static (Gitea Release assets, D-01 secondary path) | D-08 explicitly makes the API the primary distribution path so live servers need no Gitea reachability; Gitea Release is the secondary/no-Tessera-account path |
| Download link visibility | Frontend Server (SSR/CSR mix, Next.js client components) | API (provides the data the link renders from) | Login page and Settings page are `'use client'` components fetching `/desktop/latest`; the API is the source of truth, the frontend only renders/hides |
| Version comparison & update notice | Client / Desktop (Tauri `lib.rs`, Rust) | API (`/desktop/latest` as the oracle) | The comparison logic runs inside the installed desktop binary; the API only serves the current truth |
| Release asset publication | CI / Build (`publish-release.sh`) | — | Gitea Release API call, same job that already creates the release text from `CHANGELOG.md` |
| Autostart toggle | Client / Desktop (Tauri tray, `tauri-plugin-autostart`) | OS (Windows registry / Linux desktop autostart entry, via the plugin) | No client settings page exists (D-14); the tray is the only UI surface, but the actual OS registration is done by the plugin, not by Tessera code |
## Standard Stack
### Core (already installed — Phase 6, confirmed by reading `Cargo.lock`/`package.json` this session)
| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| tauri | 2.11.3 [VERIFIED: apps/desktop/src-tauri/Cargo.lock:3660-3662 — `name = "tauri"` / `version = "2.11.3"`] | Desktop shell | Already the project's chosen wrapper (Phase 6); NSIS bundler fix for build-metadata (tauri-bundler 2.2.3+) is included |
| reqwest | 0.12.28 [VERIFIED: apps/desktop/src-tauri/Cargo.lock:2907-2911] | HTTP calls to `/desktop/latest` and `/health/version` | Already used for the existing version check; default `native-tls` feature resolves to `schannel` (pure Rust FFI, no OpenSSL) when the compile target is `x86_64-pc-windows-msvc`, so cross-compiling needs no extra TLS configuration |
| tauri-plugin-autostart | 2.5.1 [VERIFIED: apps/desktop/src-tauri/Cargo.lock:3789-3791] | Autostart toggle in tray (D-14) | Already installed; `ManagerExt` trait exposes `app.autolaunch().enable()/disable()/is_enabled()` [CITED: v2.tauri.app/plugin/autostart/] |
| tauri-plugin-notification | 2.3.3 [VERIFIED: apps/desktop/src-tauri/Cargo.lock:3803-3805] | Update-available toast | Already installed and used in `lib.rs:82-101` |
| tauri-plugin-store | 2.4.3 [VERIFIED: apps/desktop/src-tauri/Cargo.lock:3822-3824] | Persisted `server_url` | Already installed (Phase 6) |
| tauri-plugin-window-state | 2.4.1 [VERIFIED: apps/desktop/src-tauri/Cargo.lock:3838-3840] | Window size/position | Already installed (Phase 6) |
### New for this phase
| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| tauri-plugin-opener | 2.5.5 stable [VERIFIED: crates.io registry API `max_stable_version` field, and `cargo` metadata `repoUrl: github.com/tauri-apps/plugins-workspace`, `weeklyDownloads: 374325`, package-legitimacy verdict `OK`] | Opens `{server}/settings/general/desktop` in the system browser from the tray "Update herunterladen" item (D-13) | Official Tauri plugin, purpose-built for exactly this (`app.opener().open_url(url, None::<&str>)`); the alternative named in D-13 ("`open`-crate") is a third-party general-purpose crate with no Tauri capability-system integration — `tauri-plugin-opener` is the maintained, capability-scoped choice |
| cargo-xwin | 0.23.1 stable [VERIFIED: crates.io registry API `max_stable_version`, `repoUrl: github.com/rust-cross/cargo-xwin`, `weeklyDownloads: 63889`, package-legitimacy verdict `OK`] | Cross-compile runner for `cargo tauri build --runner cargo-xwin --target x86_64-pc-windows-msvc` | Official Tauri-documented cross-compile path [CITED: v2.tauri.app "Cross-Platform Compilation" — Ubuntu install steps: `apt install lld llvm nsis`, `rustup target add x86_64-pc-windows-msvc`, `cargo install --locked cargo-xwin`] |
| `nsis`, `lld`, `llvm` (apt packages) | Ubuntu 24.04 repo versions (not independently pinned; `apt-get install` resolves current) | NSIS installer generation + linker/toolchain for the MSVC cross-target | Same official doc as above |
### Alternatives Considered
| Instead of | Could Use | Tradeoff |
|------------|-----------|----------|
| `tauri-plugin-opener` | `open` crate (named as an option in D-13) | `open` has no Tauri capability/permission integration (any Rust code can call it unscoped) and is not part of the audited plugin workspace; `tauri-plugin-opener` is the maintained official path with an explicit `opener:allow-open-url` capability that can be scoped to `https://*` only |
| `actions/cache@v4` for cross-job artifact transfer | `actions/upload-artifact` / `download-artifact` (v3 or v4) | Documented to fail on Gitea: v4 aborts on a GHES-detection check, v3 has open `500`/`400` error reports specifically on act_runner [CITED: github.com/go-gitea/gitea issues #28853, #31256, #27314, #25590]; the cache server, by contrast, was read directly from the running `gitea-runner` container config and confirmed enabled |
| Plain `X.Y.Z` version always in `tauri.conf.json` | Semver pre-release/build metadata (`X.Y.Z-beta+<sha>`) for beta builds | Technically survives on tauri-bundler ≥2.2.3 (coerced with a warning) [CITED: github.com/tauri-apps/tauri PR #12136], but D-07 explicitly forbids any form that risks failing the Windows build — plain numeric is the zero-risk choice and keeps `Cargo.toml`'s own semver validation trivially satisfied too |
| Single combined desktop-build-and-publish job | Separate `desktop` job (as D-06 requires) | D-06 is a locked decision; documented here only as the reason the cache-based artifact-transfer pattern above is needed |
**Installation (CI job, apt + cargo):**
```bash
# Runner image (ubuntu-latest, confirmed Ubuntu 24.04, ~nothing of this preinstalled)
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
lld llvm clang nsis \
libwebkit2gtk-4.1-dev libjavascriptcoregtk-4.1-dev \
libayatana-appindicator3-dev librsvg2-dev \
libgtk-3-dev libssl-dev patchelf file xdg-utils
rustup target add x86_64-pc-windows-msvc
cargo install --locked cargo-xwin
```
**Version verification note:** `nsis`/`lld`/`llvm`/`clang` come from Ubuntu 24.04's own apt repos and are not independently version-pinned by this project (consistent with how `node:24-alpine` and other base images are handled elsewhere in this repo) — the CI log itself is the record of exact resolved versions.
## Package Legitimacy Audit
| Package | Registry | Age | Downloads | Source Repo | Verdict | Disposition |
|---------|----------|-----|-----------|--------------|---------|-------------|
| tauri-plugin-opener | crates | published 2024-11-11 | 374,325/wk | github.com/tauri-apps/plugins-workspace | OK | Approved |
| cargo-xwin | crates | published 2022-03-06 | 63,889/wk | github.com/rust-cross/cargo-xwin | OK | Approved |
**Packages removed due to [SLOP] verdict:** none
**Packages flagged as suspicious [SUS]:** none
All other packages used in this phase (`tauri`, `reqwest`, `tauri-plugin-autostart`, `tauri-plugin-notification`, `tauri-plugin-store`, `tauri-plugin-window-state`) are already installed dependencies from Phase 6, read directly from `Cargo.lock` this session — no new legitimacy check needed for already-vendored, already-audited packages.
## Architecture Patterns
### System Architecture Diagram
```
Release tag vX.Y.Z pushed
│
▼
┌─────────────────┐ needs ┌──────────────────────┐
│ quality / test │ ─────────────▶ │ desktop (NEW) │
│ (existing jobs) │ │ 1. desktop-version.sh: │
└─────────────────┘ │ write X.Y.Z into │
│ tauri.conf.json + │
│ Cargo.toml │
│ 2. apt install nsis/ │
│ lld/llvm/webkit2gtk │
│ 3. cargo tauri build │
│ (AppImage, Linux) │
│ 4. cargo tauri build │
│ --runner cargo-xwin │
│ --target …-msvc │
│ (NSIS, Windows) │
│ 5. rename outputs to │
│ canonical filenames │
│ 6. actions/cache SAVE │
│ key: desktop-dist- │
│ ${{ gitea.sha }} │
└──────────┬────────────┘
│ needs
▼
┌──────────────────────┐
│ publish (existing) │
│ 1. actions/cache │
│ RESTORE same key │
│ (hard-fail if miss) │
│ 2. build manifest.json │
│ (version/name/size/ │
│ sha256) │
│ 3. docker build (api) │
│ COPY desktop-dist/ │
│ → /app/desktop-dist/│
│ 4. docker push api/web │
│ 5. publish-release.sh: │
│ create/update Gitea │
│ Release text (exist)│
│ + upload 2 assets │
│ (NEW) │
└──────────┬────────────┘
│
┌──────────────────────┼──────────────────────┐
▼ ▼
┌───────────────────────┐ ┌───────────────────────┐
│ Gitea Release assets │ │ Running API container│
│ Tessera-Setup-X.Y.Z │ │ /app/desktop-dist/ │
│ .exe, Tessera-X.Y.Z │ │ manifest.json + 2 │
│ .AppImage (D-01) │ │ package files (D-08) │
└───────────────────────┘ └──────────┬────────────┘
│ serves
┌───────────────────┼───────────────────┐
▼ ▼
GET /desktop/latest GET /desktop/download/:platform
(public, manifest→JSON) (public, streams file, Content-Disposition)
│ │
┌─────────────────────────┼───────────────────────────────────────┤
▼ │
Login page + Settings→Desktop-App │
(Next.js client components fetch │
/desktop/latest, hide link on 404) │
│
Installed desktop client (lib.rs) │
fetches /desktop/latest on startup, ─── opens {server}/settings/… in browser ─┘
compares CARGO_PKG_VERSION, via tauri-plugin-opener when user
shows notification + tray item clicks "Update herunterladen"
```
### Recommended Project Structure
```
apps/api/src/desktop/
├── desktop.module.ts # registers controller + service
├── desktop.controller.ts # GET /desktop/latest, GET /desktop/download/:platform (both @Public())
├── desktop.service.ts # reads manifest.json, validates platform whitelist, resolves file path
└── desktop.service.spec.ts # manifest missing → 404, platform whitelist, path-traversal rejection
.gitea/scripts/
├── desktop-version.sh # NEW — writes X.Y.Z into tauri.conf.json + Cargo.toml pre-build
├── publish-images.sh # MODIFIED — copies desktop-dist/ into API build context before docker build
└── publish-release.sh # MODIFIED — uploads 2 release assets after creating/updating the release text
apps/web/src/
├── lib/desktop.ts # NEW — loadDesktopLatest(), mirrors lib/app-version.ts pattern
├── app/(auth)/login/page.tsx # MODIFIED — small download link block
└── app/(portal)/settings/general/desktop/ # NEW — page.tsx, mirrors settings/general/account/
└── page.tsx
apps/desktop/src-tauri/src/lib.rs # MODIFIED — /desktop/latest check, opener call, autostart tray item
```
### Pattern 1: Cross-compile Windows NSIS on the Linux runner
**What:** Use `cargo-xwin` as the Cargo "runner" so `rustc`/`link.exe` calls are transparently redirected to `lld-link` against a downloaded Windows SDK/MSVC CRT, then Tauri's bundler shells out to `makensis` (from the `nsis` apt package) to produce the `.exe`.
**When to use:** Any CI job building a Windows Tauri installer without a Windows machine.
**Example:**
```bash
# Source: v2.tauri.app "Distribute > Windows Installer" (Cross-Compiling section)
sudo apt install lld llvm nsis
rustup target add x86_64-pc-windows-msvc
cargo install --locked cargo-xwin
cd apps/desktop
pnpm tauri build --runner cargo-xwin --target x86_64-pc-windows-msvc
# Output: apps/desktop/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe
```
Set `XWIN_CACHE_DIR` to a stable, cacheable path so the Windows SDK (multi-hundred-MB download) is reused across CI runs [CITED: v2.tauri.app cross-compile docs].
### Pattern 2: Public, whitelist-guarded file streaming (NestJS)
**What:** A `@Public()` controller route that resolves a filename **only** from a trusted manifest — never from the request path directly — and streams it with `Content-Disposition: attachment`.
**When to use:** Any unauthenticated download endpoint serving files from disk.
**Example (adapted from the existing `DkvService.getExportFile` traversal-guard pattern, read this session — `apps/api/src/dkv/dkv.service.ts:703-729`):**
```typescript
// apps/api/src/desktop/desktop.service.ts
const PLATFORMS = ['windows', 'linux'] as const;
type Platform = (typeof PLATFORMS)[number];
async getManifest(): Promise<DesktopManifest | null> {
const manifestPath = path.join(this.desktopDistDir, 'manifest.json');
if (!fs.existsSync(manifestPath)) return null;
return JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
}
async getPackageStream(platform: string): Promise<{ stream: fs.ReadStream; entry: ManifestFileEntry }> {
if (!PLATFORMS.includes(platform as Platform)) {
throw new BadRequestException(`Unknown platform: ${platform}`);
}
const manifest = await this.getManifest();
if (!manifest) throw new NotFoundException('Desktop packages not available');
const entry = manifest.files[platform as Platform];
if (!entry) throw new NotFoundException(`No package for platform: ${platform}`);
// entry.name comes ONLY from manifest.json (written by CI, never from the request)
const filePath = path.join(this.desktopDistDir, entry.name);
if (!fs.existsSync(filePath)) throw new NotFoundException(`Package file missing: ${entry.name}`);
return { stream: fs.createReadStream(filePath), entry };
}
```
```typescript
// apps/api/src/desktop/desktop.controller.ts
@Public()
@Get('download/:platform')
async download(@Param('platform') platform: string, @Res({ passthrough: true }) res: Response) {
const { stream, entry } = await this.desktopService.getPackageStream(platform);
res.set({
'Content-Disposition': `attachment; filename="${entry.name}"`,
'Content-Type': 'application/octet-stream',
'Content-Length': String(entry.size),
});
return new StreamableFile(stream);
}
```
[CITED: docs.nestjs.com Techniques > Streaming Files, for the `StreamableFile` + `passthrough: true` requirement]
### Pattern 3: Memoized public fetch, fail-silent-to-null (already established in this codebase)
**What:** A single in-module promise that fetches a public API endpoint once per page load and resolves to `null` on any error — the caller uses `null` to hide UI rather than show an error.
**When to use:** Exactly the login-page/settings download-link visibility rule in D-12 ("nur wenn `/desktop/latest` antwortet").
**Example (this is the EXISTING file, read verbatim this session — `apps/web/src/lib/app-version.ts:50-63` — the new `lib/desktop.ts` should follow the identical shape):**
```typescript
// Source: apps/web/src/lib/app-version.ts (existing pattern, verbatim)
let apiVersionPromise: Promise<ApiVersionInfo | null> | null = null;
export function loadApiVersion(): Promise<ApiVersionInfo | null> {
if (!apiVersionPromise) {
apiVersionPromise = fetch(`${API_URL}/health/version`, { credentials: 'include' })
.then((res) => (res.ok ? (res.json() as Promise<ApiVersionInfo>) : null))
.catch(() => null);
}
return apiVersionPromise;
}
```
Note: the login page renders **before** authentication, so `credentials: 'include'` is irrelevant there (no cookie yet) but harmless — `/desktop/latest` is `@Public()` so it responds regardless of cookie presence.
### Anti-Patterns to Avoid
- **Relying on Tauri's default NSIS/AppImage output filename:** The exact default naming convention was not confirmed against an authoritative source this session (see Open Questions). Do not hardcode an assumption about it in the CI script — instead, `find` the produced `.exe`/`.AppImage` in the bundle output directory and explicitly copy/rename it to the canonical `Tessera-Setup-X.Y.Z.exe` / `Tessera-X.Y.Z.AppImage` name before it enters `manifest.json` or gets uploaded anywhere.
- **Using `actions/upload-artifact`/`download-artifact` for the desktop→publish handoff:** documented failure modes on Gitea (see Standard Stack alternatives table). Use `actions/cache` instead.
- **Putting build metadata / pre-release identifiers in `tauri.conf.json` `version`:** even though newer tauri-bundler versions coerce rather than fail, D-07 forbids any risk here — keep it plain `X.Y.Z` always.
- **Resolving the download filename from the request's `:platform` param directly:** always resolve through `manifest.json`'s `files[platform].name`, matching D-10's explicit instruction and the `DkvService` precedent.
## Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| Windows cross-compilation toolchain wiring (linker selection, target CRT, SDK download) | A custom Docker image or manual `lld-link` invocation script | `cargo-xwin` | It already solves SDK download, caching (`XWIN_CACHE_DIR`), and Cargo `[target.x86_64-pc-windows-msvc] linker/runner` wiring; reinventing this is exactly the kind of "weeks of work" the project's own CLAUDE.md warns against for infra |
| Opening a URL in the user's default browser from Rust | Manual `std::process::Command::new("xdg-open"/"cmd /C start")` platform branching | `tauri-plugin-opener` | Official plugin already handles per-OS differences and integrates with Tauri's capability/permission system, so the allowed URL scope (`https://*`) is declared, not implicit |
| Cross-job build artifact passing on a fragile CI backend | A home-grown "upload to a scratch S3/webdav and curl it back down" script | `actions/cache@v4` (already confirmed enabled on this runner) keyed on `gitea.sha` | The cache backend was directly verified running and reachable; building a bespoke artifact-transfer mechanism duplicates infrastructure that already exists and works, for no benefit |
| Release asset upload retry/idempotency logic | Custom "check if uploaded, else force-overwrite via unusual heuristics" | Gitea's release-assets API: `GET` the release, if an asset with the same `name` exists `DELETE` it first (`DELETE /repos/{owner}/{repo}/releases/{id}/assets/{asset_id}`), then `POST` fresh — same idempotent create/update-by-lookup shape `publish-release.sh` already uses for the release itself | Keeps the new logic consistent with the existing script's own idempotency pattern (GET-by-tag → PATCH-or-POST), rather than inventing a second idiom in the same file |
**Key insight:** Every piece of new infrastructure in this phase (cross-compile toolchain, URL-opening, cross-job caching, release-asset upload) already has an official, maintained, or in-repo precedent. The research effort here is almost entirely "find the existing tool/pattern and confirm it actually works on *this* runner" rather than designing anything new.
## Common Pitfalls
### Pitfall 1: `actions/upload-artifact`/`download-artifact` silently or loudly fail on this Gitea instance
**What goes wrong:** The `desktop` job builds packages but the `publish` job can't see them; CI either errors outright (`v4` GHES-detection abort) or the job "succeeds" with an empty artifact.
**Why it happens:** Gitea's Actions artifact backend does not fully match GitHub's; `actions/upload-artifact@v4`+ explicitly checks for GHES and refuses to run on non-GitHub-recognized servers, and `v3` has multiple open upstream issues specific to act_runner (`400`/`500` errors) [CITED: github.com/go-gitea/gitea issues #28853, #31256, #27314, #25590].
**How to avoid:** Use `actions/cache@v4` save/restore keyed on the exact commit SHA (`desktop-dist-${{ gitea.sha }}`, no `restore-keys` fallback) as the transfer mechanism instead. The `publish` job's cache-restore step must hard-fail (e.g. `test -f desktop-dist/manifest.json || exit 1`) if the cache misses, rather than silently building an API image without desktop packages.
**Warning signs:** `publish` job succeeds but `/app/desktop-dist/` is empty in the built image; `/desktop/latest` returns 404 in production despite a tag having been pushed.
### Pitfall 2: NSIS numeric-only version field
**What goes wrong:** A `tauri.conf.json` `version` containing pre-release/build metadata (e.g. `1.2.0-beta+abc1234`) either hard-fails the Windows build (older tauri-bundler) or gets silently coerced with a warning (tauri-bundler ≥2.2.3, which is what 2.11.3 ships).
**Why it happens:** NSIS's `VIProductVersion`/`VIFileVersion` map to Windows' `VS_FixedFileInfo`, which is numeric-only `X.X.X.X` by OS-level requirement — this is not a Tauri choice, it's inherited from the Windows resource format [CITED: github.com/tauri-apps/tauri issue #8038].
**How to avoid:** `desktop-version.sh` always writes plain `X.Y.Z` (the latest tag, stripped of `v`) into both `tauri.conf.json` and `Cargo.toml`, for every build — tag builds and beta/main builds alike. The beta-vs-tag distinction lives only in: (a) the output filename suffix appended by the CI script after the build (e.g. `Tessera-Setup-1.2.0-beta.<7-char-sha>.exe` for main-branch builds, `Tessera-Setup-1.2.0.exe` for the tag build), and (b) `manifest.json`'s `commit`/`buildTime` fields (same shape as the existing `VersionResponse`/`app-version.ts` API pattern).
**Warning signs:** CI log contains `optional build metadata in app version must be numeric-only` or a coercion warning; installed `.exe`'s file-properties version differs from what was expected.
### Pitfall 3: `reqwest`'s TLS backend resolving differently per target — verify, don't assume
**What goes wrong:** A naive assumption that cross-compiling any Rust crate with TLS to Windows requires bundling OpenSSL for the *build host*.
**Why it happens:** `reqwest`'s default-tls feature is target-conditional: Linux/Unix → `openssl`, Windows → `schannel`, macOS → `security-framework`. Cargo resolves dependencies per **target** triple, so cross-compiling to `x86_64-pc-windows-msvc` only pulls in `schannel` (a pure-Rust FFI crate against Windows' built-in Cryptography API), not `openssl-sys` — confirmed by reading this project's own `Cargo.lock`, which lists both `native-tls`/`openssl-sys` (for the host's linux-gnu default target) and `schannel`/`rustls` (present as target-conditional deps in the same lockfile) [VERIFIED: apps/desktop/src-tauri/Cargo.lock — `native-tls` at line 2114, `openssl-sys` at line 2445, `rustls` at line 3024, `schannel` at line 3078].
**How to avoid:** No action needed — the existing `Cargo.toml` `reqwest = { version = "0.12", features = ["json"] }` (default-tls) should cross-compile to Windows without an OpenSSL cross-build step. If the CI run proves otherwise (D-16's iteration loop), the fallback is adding `default-features = false, features = ["json", "rustls-tls"]` to force a pure-Rust TLS stack.
**Warning signs:** A build error mentioning `openssl-sys` failing to find `libssl`/`pkg-config` when cross-compiling — this would indicate the assumption above needs revisiting for this specific dependency graph.
### Pitfall 4: Assuming the default Tauri bundle output filename
**What goes wrong:** CI script hardcodes an assumed filename pattern (e.g. `tessera-desktop_1.2.0_x64_en-US.msi`-style guesses) that doesn't match what the installed `tauri-bundler` 2.11.3 actually produces, so the `find`/copy step in the CI script silently finds nothing or the wrong file.
**Why it happens:** The exact default naming convention was not confirmed against an authoritative primary source this session (see Open Questions) — training-data recall of Tauri's naming scheme conflicts across versions and is not reliable enough to hardcode.
**How to avoid:** Never hardcode the exact default filename. Instead: `find target/release/bundle/appimage -name '*.AppImage'` and `find target/x86_64-pc-windows-msvc/release/bundle/nsis -name '*.exe'`, taking whatever single file matches (the bundle directories are exclusive to their target/format), then explicitly `cp`/`mv` to the canonical name. This is format/version-independent by construction.
**Warning signs:** CI script's copy step errors with "no such file" even though the build itself succeeded.
### Pitfall 5: `apt-get install` list incompleteness on the bare `ubuntu-latest` runner image
**What goes wrong:** The Linux AppImage build (needed even on the `desktop` job, not just locally) fails partway through `cargo build` with missing `pkg-config`-resolved headers, because the runner image ships **none** of the GTK/WebKit dev packages the dev machine happens to already have installed.
**Why it happens:** Confirmed by directly running `dpkg -l` inside a fresh `gitea/runner-images:ubuntu-latest` container this session — it has `librsvg2-dev` and `file` but **not** `libwebkit2gtk-4.1-dev`, `libayatana-appindicator3-dev`, `libgtk-3-dev`, `patchelf`, `nsis`, or a Rust toolchain. The dev machine (where a local build was previously proven per `06-02-SUMMARY.md`) is a different, more fully-provisioned environment and is not representative of the CI runner.
**How to avoid:** The `desktop` job's apt-install step must be complete and explicit (see Standard Stack "Installation" above) — do not assume anything beyond `librsvg2-dev` and `file` is present.
**Warning signs:** `cargo build` fails with `The system library 'javascriptcoregtk-4.1' required by crate 'javascriptcore-rs-sys' was not found` or similar `pkg-config` errors.
## Code Examples
### 1. `desktop-version.sh` (new script, mirrors `publish-images.sh`'s POSIX-`sh` style)
```sh
#!/bin/sh
# Source: pattern adapted from .gitea/scripts/publish-images.sh (read this session,
# same set -eu / GITHUB_REF-only-decision style, same repo).
set -eu
TAG_VERSION="$(git describe --tags --abbrev=0 2>/dev/null || echo v0.0.0)"
VERSION="${TAG_VERSION#v}" # plain X.Y.Z, per Pitfall 2 — never pre-release/build metadata
CONF="apps/desktop/src-tauri/tauri.conf.json"
CARGO="apps/desktop/src-tauri/Cargo.toml"
jq --arg v "$VERSION" '.version = $v' "$CONF" > "$CONF.tmp" && mv "$CONF.tmp" "$CONF"
sed -i "s/^version = \".*\"/version = \"$VERSION\"/" "$CARGO"
echo "Desktop version set to $VERSION (from tag $TAG_VERSION)"
```
### 2. CI workflow job additions (`.gitea/workflows/ci.yml`)
```yaml
# Source: pattern follows the existing quality/test/publish job shape in this file (read this session)
desktop:
name: Desktop-Pakete bauen
runs-on: ubuntu-latest
needs: test
if: gitea.ref == 'refs/heads/main' || startsWith(gitea.ref, 'refs/tags/v')
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 24
- name: Cargo/xwin Zwischenspeicher
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
~/.cargo/bin
apps/desktop/src-tauri/target
~/.cache/cargo-xwin
key: desktop-cargo-${{ hashFiles('apps/desktop/src-tauri/Cargo.lock') }}
restore-keys: desktop-cargo-
- name: Systemabhaengigkeiten
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
lld llvm clang nsis \
libwebkit2gtk-4.1-dev libjavascriptcoregtk-4.1-dev \
libayatana-appindicator3-dev librsvg2-dev \
libgtk-3-dev libssl-dev patchelf file xdg-utils
- name: Rust-Ziel + cargo-xwin
run: |
rustup target add x86_64-pc-windows-msvc
command -v cargo-xwin >/dev/null 2>&1 || cargo install --locked cargo-xwin
- name: Enable pnpm via corepack
run: corepack enable && corepack prepare pnpm@9.15.0 --activate
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Version in tauri.conf.json/Cargo.toml setzen
run: sh .gitea/scripts/desktop-version.sh
- name: Linux AppImage bauen
working-directory: apps/desktop
run: pnpm tauri build --bundles appimage
- name: Windows NSIS Cross-Bau
working-directory: apps/desktop
env:
XWIN_CACHE_DIR: ${{ github.workspace }}/.xwin-cache
run: pnpm tauri build --runner cargo-xwin --target x86_64-pc-windows-msvc --bundles nsis
- name: Pakete einsammeln und umbenennen
run: |
mkdir -p desktop-dist
APPIMAGE=$(find apps/desktop/src-tauri/target/release/bundle/appimage -name '*.AppImage' | head -1)
EXE=$(find apps/desktop/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis -name '*.exe' | head -1)
VERSION=$(jq -r .version apps/desktop/src-tauri/tauri.conf.json)
cp "$APPIMAGE" "desktop-dist/Tessera-$VERSION.AppImage"
cp "$EXE" "desktop-dist/Tessera-Setup-$VERSION.exe"
- name: In Zwischenspeicher ablegen (Uebergabe an publish-Job)
uses: actions/cache/save@v4
with:
path: desktop-dist
key: desktop-dist-${{ gitea.sha }}
```
Then in the `publish` job, before `docker build`:
```yaml
- name: Desktop-Pakete aus dem Zwischenspeicher holen
uses: actions/cache/restore@v4
with:
path: desktop-dist
key: desktop-dist-${{ gitea.sha }}
fail-on-cache-miss: true
```
### 3. `tauri.conf.json` version override via CLI (alternative to sed/jq, for reference — not the chosen approach since D-07 wants the file itself updated)
```bash
# Source: v2.tauri.app Configuration Files docs (RFC 7396 JSON merge)
tauri build --config '{"version":"1.2.0"}'
```
Not used here because `Cargo.toml`'s `version` (read at compile time via `env!("CARGO_PKG_VERSION")` in `lib.rs:85`) also needs updating, and `--config` only patches the Tauri-side config, not `Cargo.toml`.
### 4. Rust: version check against `/desktop/latest` + opener (replaces the current `/health/version` compare in `lib.rs:82-101`)
```rust
// Adapts the EXISTING async version-check block in lib.rs (read this session), redirected
// to /desktop/latest and adding the opener call + tray menu item.
use tauri_plugin_opener::OpenerExt;
#[derive(serde::Deserialize)]
struct DesktopLatest {
version: String,
}
// inside the existing async_runtime::spawn block, replace the /health/version call:
let url = format!("{}/desktop/latest", server_url.trim_end_matches('/'));
if let Ok(resp) = reqwest::get(&url).await {
if let Ok(info) = resp.json::<DesktopLatest>().await {
if info.version != app_version {
let _ = app_handle.notification().builder()
.title("Tessera Update")
.body(format!("Neue Version {} verfuegbar", info.version))
.show();
// enable the tray "Update herunterladen" item here (menu item toggling
// requires holding a handle to it created during setup, not shown here)
}
}
}
// on tray menu event "update":
"update" => {
let url = format!("{}/settings/general/desktop", server_url);
let _ = app.opener().open_url(url, None::<&str>);
}
```
Capability addition needed in `apps/desktop/src-tauri/capabilities/default.json`:
```json
{ "identifier": "opener:allow-open-url", "allow": [{ "url": "https://*" }, { "url": "http://*" }] }
```
(`http://*` included because D-02 allows non-HTTPS server addresses for internal LAN use, same reasoning already documented in `setup.html`'s HTTP warning.)
### 5. Autostart tray checkbox (D-14)
```rust
// Source: v2.tauri.app plugin/autostart/ (fetched this session)
use tauri_plugin_autostart::ManagerExt;
let autostart_manager = app.autolaunch();
let is_enabled = autostart_manager.is_enabled().unwrap_or(false);
let autostart_item = CheckMenuItemBuilder::with_id("autostart", "Mit Windows starten")
.checked(is_enabled)
.build(app)?;
// on_menu_event "autostart":
"autostart" => {
let mgr = app.autolaunch();
if mgr.is_enabled().unwrap_or(false) {
let _ = mgr.disable();
} else {
let _ = mgr.enable();
}
}
```
### 6. `publish-release.sh` extension — idempotent asset upload
```sh
# Source: pattern extends the existing idempotent GET-then-PATCH-or-POST shape
# already in this file (read this session, lines 125-155) to asset upload.
upload_asset() {
FILE="$1"; NAME="$2"; RELEASE_ID="$3"
# Idempotency: find + delete any existing asset with the same name first.
ASSETS=$(curl -sS --header @"$HDR" "$RELEASES_URL/$RELEASE_ID/assets")
EXISTING_ID=$(echo "$ASSETS" | jq -r --arg n "$NAME" '.[] | select(.name==$n) | .id')
if [ -n "$EXISTING_ID" ]; then
curl -sS --header @"$HDR" -X DELETE "$RELEASES_URL/$RELEASE_ID/assets/$EXISTING_ID" >/dev/null
fi
curl -sS --header @"$HDR" -X POST \
-F "attachment=@${FILE};filename=${NAME}" \
"$RELEASES_URL/$RELEASE_ID/assets?name=${NAME}"
}
```
[CITED: Gitea forum "Create new Release via API with attachment" — `POST /repos/{owner}/{repo}/releases/{id}/assets?name=...` with multipart `attachment` field]
### 7. Manifest schema (`/app/desktop-dist/manifest.json`, D-08)
```typescript
// packages/shared/src/index.ts — new interface, same file/pattern as VersionResponse
export interface DesktopManifestFile {
name: string;
size: number;
sha256: string;
}
export interface DesktopManifest {
version: string;
commit: string;
buildTime: string;
files: {
windows: DesktopManifestFile;
linux: DesktopManifestFile;
};
}
```
Generated in the `publish` job after the cache-restore step:
```sh
sha256sum desktop-dist/Tessera-Setup-*.exe | awk '{print $1}'
```
### 8. `apps/web/src/lib/desktop.ts` (mirrors `lib/app-version.ts` exactly)
```typescript
// Mirrors the EXISTING apps/web/src/lib/app-version.ts pattern (read this session, verbatim structure)
export interface DesktopLatestInfo {
version: string;
files: {
windows: { name: string; size: number; sha256: string; url: string };
linux: { name: string; size: number; sha256: string; url: string };
};
}
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
let desktopLatestPromise: Promise<DesktopLatestInfo | null> | null = null;
export function loadDesktopLatest(): Promise<DesktopLatestInfo | null> {
if (!desktopLatestPromise) {
desktopLatestPromise = fetch(`${API_URL}/desktop/latest`)
.then((res) => (res.ok ? (res.json() as Promise<DesktopLatestInfo>) : null))
.catch(() => null);
}
return desktopLatestPromise;
}
```
## Assumptions Log
| # | Claim | Section | Risk if Wrong |
|---|-------|---------|---------------|
| A1 | Tauri's exact default bundle output filename pattern for AppImage/NSIS in 2.11.3 | Pitfall 4, Code Example #2 | Low — the recommended `find`-then-rename pattern is deliberately filename-agnostic, so this assumption has no load-bearing effect on the plan |
| A2 | `actions/cache@v4` (not an older pinned minor) works correctly against this runner's local cache server for both `save` and `restore` sub-actions with `fail-on-cache-miss` | Code Example #2, Pitfall 1 | Medium — if the exact cache-action version/flag set behaves differently, the `publish` job could silently build without desktop packages instead of hard-failing; D-16's iteration loop is the designed safety net for exactly this |
| A3 | `reqwest` cross-compiling to `x86_64-pc-windows-msvc` will not need OpenSSL, based on Cargo.lock's target-conditional dependency graph rather than an actual cross-build having been run this session | Pitfall 3 | Low-Medium — if wrong, the fallback (`rustls-tls` feature) is already documented and simple to apply within D-16's iteration loop |
| A4 | Ubuntu 24.04 apt package names (`libwebkit2gtk-4.1-dev`, `libayatana-appindicator3-dev`, etc.) are current/correct for Tauri 2 on this exact runner image, based on the dev machine's already-installed package list plus official Tauri Linux prerequisites docs, not a fresh `apt-get install` actually run inside the runner container this session | Standard Stack Installation, Pitfall 5 | Low — `apt-get install` will error clearly and immediately if a package name is wrong/renamed, easily caught in D-16's iteration loop |
**If this table is empty:** N/A — see above.
## Open Questions (RESOLVED in plans: Q1 → 18-01 desktop-collect.sh find-then-rename; Q2 → 18-05 Iterationsschleife; Q3 → 18-05 actions/cache@v3-Fallback)
1. **Exact default filename Tauri 2.11.3's bundler gives the NSIS `.exe` and the AppImage**
- What we know: Tauri v1 used `${productName}_${version}_${arch}-setup.exe`-style names; v2's exact current default was not confirmed against an authoritative primary source this session.
- What's unclear: Whether that pattern still holds in 2.11.3, and whether `productName: "Tessera"` (with no space) changes it.
- Recommendation: Don't rely on it — the CI script `find`s the single produced file by extension in the known bundle output directory (`target/.../bundle/appimage/*.AppImage`, `target/.../bundle/nsis/*.exe`) and explicitly renames it. Already reflected in Code Example #2 and Pitfall 4.
2. **Whether the Windows cross-build actually succeeds end-to-end on the first pipeline run**
- What we know: Every individual piece (cargo-xwin, apt packages, reqwest TLS target-resolution, NSIS numeric-version handling) is verified/cited individually; nothing here was run as a full end-to-end Windows cross-build in this research session (no Windows target build was executed — only inspected via Cargo.lock and official docs).
- What's unclear: Whether some interaction between Tauri's own build.rs (icon embedding, resource compilation via `llvm-rc`) and the cross-toolchain surfaces an issue not visible from documentation alone.
- Recommendation: This is exactly what D-16's "iteration loop" is designed for (push, read the CI failure, adjust, repeat) — the plan should budget explicit time/tasks for this rather than assuming a first-try green run.
3. **Whether `actions/cache@v4`'s save/restore matches Gitea's cache-server protocol version without any special pinning**
- What we know: The cache server is confirmed enabled and reachable; general Gitea docs describe `actions/cache` compatibility as generally working, with some version-specific nuance around cache-service v1 vs v2 API detection.
- What's unclear: Whether this specific Gitea/act_runner version (not independently version-checked this session beyond confirming the container is running) needs a specific `actions/cache` action version pin.
- Recommendation: Use `actions/cache@v4` as the first attempt (matches `actions/checkout@v4`/`actions/setup-node@v4` versioning already proven working in this repo's CI); if it fails, the fallback is `actions/cache@v3` — this should be a fast, cheap thing to discover in the D-16 iteration loop, not something to pre-solve via more research.
## Environment Availability
| Dependency | Required By | Available | Version | Fallback |
|------------|------------|-----------|---------|----------|
| Rust/Cargo (dev machine) | Local `cargo check`/AppImage proof before push (D-16) | ✓ | cargo 1.96.0, rustc 1.96.0 | — |
| webkit2gtk-4.1-dev, appindicator3-dev, librsvg2-dev, libgtk-3-dev (dev machine) | Local Linux AppImage build | ✓ | already installed system-wide (`libwebkit2gtk-4.1-dev 2.52.6`, `libayatana-appindicator3-dev 0.5.94`, `librsvg2-dev 2.60.0`, `libgtk-3-dev 3.24.49`) | — |
| Rust toolchain (CI runner, `gitea/runner-images:ubuntu-latest`) | `desktop` CI job | ✗ | — | Install via CI step (not preinstalled in the runner image, confirmed by running a fresh container this session) |
| webkit2gtk/appindicator/gtk dev headers (CI runner) | `desktop` CI job (AppImage step) | ✗ (only `librsvg2-dev`, `file` present) | — | `apt-get install` step, full list in Standard Stack |
| `nsis`, `lld`, `llvm`, `cargo-xwin` (CI runner) | `desktop` CI job (NSIS cross-build step) | ✗ | — | `apt-get install` + `cargo install --locked cargo-xwin` step |
| act_runner cache server | `actions/cache` for both the Cargo/xwin cache and the desktop-dist cross-job handoff | ✓ | enabled, `host: 172.18.0.1, port: 42641` (read directly from the running `gitea-runner` container's `/data/config.yaml` this session) | — |
| Docker (dev machine, for inspecting the runner image) | Research verification only, not part of the shipped pipeline | ✓ | 29.8.0 | — |
**Missing dependencies with no fallback:** none — everything missing on the CI runner is installable within the job itself.
**Missing dependencies with fallback:** none beyond the installable-in-job items above.
## Validation Architecture
### Test Framework
| Property | Value |
|----------|-------|
| Framework | Vitest (apps/api: 3.2.6, apps/web: 4.1.9 — different majors, pre-existing, not this phase's concern) |
| Config file | `apps/api/vitest.config.ts` (`environment: 'node'`, `include: ['src/**/*.spec.ts']`), `apps/web/vitest.config.ts` (`environment: 'jsdom'`) |
| Quick run command | `pnpm --filter @tessera/api test -- src/desktop`, `pnpm --filter @tessera/web test -- desktop` |
| Full suite command | `pnpm test` (Turborepo, all workspaces) |
### Phase Requirements → Test Map
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|-------------|
| DESK-03 | `GET /desktop/latest` returns manifest JSON when present | unit | `pnpm --filter @tessera/api test -- desktop.service.spec.ts` | ❌ Wave 0 |
| DESK-03 | `GET /desktop/latest` returns 404 when manifest/directory missing | unit | same file | ❌ Wave 0 |
| DESK-10 (platform whitelist, part of D-10) | `GET /desktop/download/:platform` rejects unknown platform with 400 | unit | same file | ❌ Wave 0 |
| DESK-10 (path safety, part of D-10) | Filename never taken from request, only from manifest — traversal attempt (`../../etc/passwd`) rejected before any filesystem access | unit | same file | ❌ Wave 0 |
| DESK-03 | Login page shows/hides download link based on `/desktop/latest` response | component | `pnpm --filter @tessera/web test -- login` | ❌ Wave 0 (extends existing login test file if present, else new) |
| DESK-03 | Settings → Desktop-App page renders version/size/buttons | component | `pnpm --filter @tessera/web test -- settings/general/desktop` | ❌ Wave 0 |
| DESK-01/05 | Rust compiles cleanly with new plugin/capability changes | manual (cargo check/clippy in CI, per D-16) | `cd apps/desktop/src-tauri && cargo check && cargo clippy` | N/A — not a Vitest test, CI step |
| DESK-01 | Local Linux AppImage builds successfully before push (D-16 proof step) | manual | `cd apps/desktop && pnpm tauri build --bundles appimage` | N/A — manual proof, not automated test |
| DESK-04/05 | Windows NSIS cross-build produces a valid `.exe` in CI | manual (only provable in pipeline, per D-16) | pipeline run, inspect `desktop` job logs + artifact | N/A — cannot be proven locally without a Windows toolchain |
### Sampling Rate
- **Per task commit:** `pnpm --filter @tessera/api test -- desktop`, `pnpm --filter @tessera/web test -- desktop`
- **Per wave merge:** `pnpm test` (full Turborepo suite)
- **Phase gate:** Full suite green before `/gsd-verify-work`; additionally, per D-16, a green CI pipeline run producing both `Tessera-Setup-X.Y.Z.exe` and `Tessera-X.Y.Z.AppImage` is a hard phase-gate requirement, not just a test-suite requirement
### Wave 0 Gaps
- [ ] `apps/api/src/desktop/desktop.service.spec.ts` — covers manifest-present/absent, platform whitelist, path-traversal rejection
- [ ] `apps/web/src/app/(portal)/settings/general/desktop/desktop-settings.test.tsx` (or co-located, matching `calendar-settings.test.tsx` naming convention already in this repo) — covers link visibility and rendered fields
- [ ] Login page test extension for the download-link visibility rule (D-12) — check whether an existing `login` test file exists first; none was found in this research pass, so this may be a new file
- [ ] Framework install: none — Vitest is already configured in both apps
## Security Domain
### Applicable ASVS Categories
| ASVS Category | Applies | Standard Control |
|---------------|---------|-------------------|
| V2 Authentication | No | The two new routes are deliberately `@Public()` per D-10 — no auth applies by design, matching the existing `/health/version` precedent |
| V3 Session Management | No | No session state involved in file download |
| V4 Access Control | Yes (negative case) | The two new routes must NOT accidentally inherit tenant/role checks that would break the public download — verify `@Public()` is applied to both, matching `HealthController`'s pattern (`apps/api/src/health/health.controller.ts:8,20`, read this session) |
| V5 Input Validation | Yes | `:platform` param validated against a hardcoded whitelist (`['windows', 'linux']`), never used to construct a filesystem path directly; filename comes only from `manifest.json`, matching the `DkvService.getExportFile` whitelist-then-lookup pattern (read this session, `apps/api/src/dkv/dkv.service.ts:703-729`) |
| V6 Cryptography | Partial | `sha256` checksums in `manifest.json` are integrity metadata, not a security control on their own (no signature) — this is explicitly acceptable scope per D-09 (no code signing this phase); do not present the sha256 field as a security guarantee in user-facing docs |
### Known Threat Patterns for this stack
| Pattern | STRIDE | Standard Mitigation |
|---------|--------|----------------------|
| Path traversal via `:platform` or a crafted filename | Tampering / Information Disclosure | Whitelist-validate `:platform` against a fixed enum before any filesystem access; resolve the actual filename exclusively from `manifest.json`, never from request input — exact precedent already in this codebase (`DkvService.getExportFile`) |
| Serving an unexpectedly large/wrong file due to a stale or tampered `manifest.json` | Tampering | `manifest.json` is written only by the CI pipeline (never user-writable, lives inside the built Docker image, not a mounted/writable volume) — no runtime code path writes to `/app/desktop-dist/` |
| SmartScreen / unsigned-binary user confusion (not a Tessera vulnerability, but a support-burden risk) | — | Explicitly out of scope for code-signing (D-09) — mitigated only via documentation (D-15's SmartScreen explanation in the Anwenderhandbuch), not a technical control |
| CI secret exposure via the new release-asset-upload script | Information Disclosure | Reuse the existing `publish-release.sh` pattern of writing the `Authorization` header to a temp file with `umask 077` rather than passing the token as a CLI argument (visible in process listings/logs) — already the established pattern in this file, read this session (`apps/api/.gitea/scripts/publish-release.sh:117-123`) |
## Sources
### Primary (HIGH confidence)
- `apps/desktop/src-tauri/Cargo.lock` (read this session) — exact installed versions of `tauri`, `reqwest`, all four plugins, `native-tls`/`openssl-sys`/`rustls`/`schannel`
- `apps/desktop/src-tauri/lib.rs`, `tauri.conf.json`, `Cargo.toml`, `capabilities/default.json`, `setup.html` (read this session) — current Phase-6 state
- `.gitea/workflows/ci.yml`, `.gitea/scripts/publish-images.sh`, `.gitea/scripts/publish-release.sh` (read this session) — existing pipeline shape and idempotency patterns to extend
- `apps/api/src/health/*.ts`, `apps/api/src/auth/decorators/public.decorator.ts`, `apps/api/src/app.module.ts` (read this session) — `@Public()` + global-guard mechanism
- `apps/api/src/dkv/dkv.service.ts:695-729`, `dkv.controller.ts:128-155` (read this session) — file-download and path-traversal-guard precedent
- `apps/web/src/lib/app-version.ts`, `apps/web/src/components/layout/app-version-badge.tsx` (read this session) — memoized public-fetch pattern to mirror
- `apps/web/src/app/(auth)/login/page.tsx`, `apps/web/src/app/(portal)/settings/layout.tsx`, `settings-sidebar.tsx`, `settings/general/account/page.tsx` (read this session) — UI insertion points
- `packages/shared/src/index.ts` (read this session) — existing `VersionResponse`/`HealthResponse` shape to mirror for `DesktopManifest`
- Live `gitea-runner` container `/data/config.yaml` (inspected this session via `docker exec`) — confirms cache server enabled at `172.18.0.1:42641`
- Live `gitea/runner-images:ubuntu-latest` container (inspected this session via `docker run`) — confirms Ubuntu 24.04, absence of Rust/nsis/webkit2gtk-dev/appindicator-dev
- crates.io registry API responses (fetched this session via WebFetch) — `tauri-plugin-opener` 2.5.5, `cargo-xwin` 0.23.1
- `gsd_run query package-legitimacy check` (run this session) — `OK` verdicts for both new crates
### Secondary (MEDIUM confidence)
- v2.tauri.app "Distribute > Windows Installer" cross-compiling section (fetched this session) — apt packages, `rustup target add`, `cargo install cargo-xwin`, build command, `XWIN_CACHE_DIR`, output path
- v2.tauri.app "Plugin > Opener" (fetched this session) — `cargo add tauri-plugin-opener`, capability permission shape, `OpenerExt`/`open_url` signature
- v2.tauri.app "Plugin > Autostart" (fetched this session) — `ManagerExt`, `app.autolaunch()`, `enable`/`disable`/`is_enabled`
- v2.tauri.app "Configuration Files" (fetched this session) — `--config` JSON-merge-patch override semantics
- github.com/tauri-apps/tauri PR #12136 (fetched this session) — NSIS build-metadata coercion fix, shipped in tauri-bundler 2.2.3
- github.com/tauri-apps/tauri issue #8038 (web search, title/summary only) — root cause of the NSIS numeric-version requirement
- Gitea forum "Create new Release via API with attachment" (web search) — multipart asset-upload endpoint shape
- docs.nestjs.com Techniques > Streaming Files (general training knowledge, common NestJS idiom, not fetched verbatim this session) — `StreamableFile` + `passthrough: true` requirement
### Tertiary (LOW confidence)
- github.com/go-gitea/gitea issues #28853, #31256, #27314, #25590 (web search summaries only, not individually read in full) — evidence for the artifact-action fragility claim; treated as directional/corroborating rather than definitive, hence the recommendation to use `actions/cache` instead rather than attempting to pin a "known good" artifact-action version
- Exact default Tauri 2.11.3 NSIS/AppImage output filename — not confirmed against a primary source this session (see Open Questions #1); mitigated by filename-agnostic `find`-then-rename design, not by resolving the question
## Metadata
**Confidence breakdown:**
- Standard stack (crate versions, cross-compile toolchain): HIGH — read directly from `Cargo.lock` and official Tauri docs, plus a legitimacy check on the two new crates
- Cross-job CI artifact handoff strategy: MEDIUM — the cache server was directly confirmed enabled on the live runner, but the specific `actions/cache@v4` compatibility with this exact Gitea/act_runner version was not itself executed this session, only reasoned from general Gitea documentation
- NSIS version-format safety: HIGH — the underlying Windows constraint and the Tauri coercion-fix PR are both directly cited; the recommended mitigation (plain X.Y.Z always) is conservative by construction and doesn't depend on the coercion fix working
- Windows cross-build actually succeeding end-to-end: MEDIUM-LOW — no Windows cross-build was executed in this research session; this is explicitly flagged as needing D-16's iteration loop, not resolved by research alone
- API/Web/security patterns: HIGH — every pattern has a direct, freshly-read precedent in this exact codebase
**Research date:** 2026-09-16
**Valid until:** 2026-10-16 (30 days — Tauri/cargo-xwin/crates.io versions move fast enough that a re-check is warranted if planning is delayed; the runner-image and act_runner findings are environment-specific and should be re-verified if the CI infrastructure changes)
@@ -0,0 +1,96 @@
---
phase: "18"
slug: "desktop-client-fertigstellen"
# status lifecycle: draft (seeded by plan-phase) → validated (set by validate-phase §6)
# audit-milestone §5.5 distinguishes NOT-VALIDATED (draft) from PARTIAL (validated + nyquist_compliant: false) (#2117)
status: draft
nyquist_compliant: false
wave_0_complete: false
created: "2026-09-16"
---
# Phase 18 — Validation Strategy
> Per-phase validation contract for feedback sampling during execution.
---
## Test Infrastructure
| Property | Value |
|----------|-------|
| **Framework** | Vitest 3.2.6 (`apps/api`, `environment: node`), Vitest 4.1.9 (`apps/web`, `environment: jsdom`), Cargo/Clippy 1.96 (`apps/desktop/src-tauri`), POSIX `sh -n` fuer CI-Skripte |
| **Config file** | `apps/api/vitest.config.ts`, `apps/web/vitest.config.ts`, `apps/desktop/src-tauri/Cargo.toml` |
| **Quick run command** | `pnpm --filter @tessera/api exec vitest run src/desktop` · `pnpm --filter @tessera/web exec vitest run src/lib/desktop.test.ts src/components/desktop src/components/settings/desktop-app-settings.test.tsx` · `cd apps/desktop/src-tauri && cargo check` |
| **Full suite command** | `pnpm --filter @tessera/api exec vitest run && pnpm --filter @tessera/web exec vitest run && pnpm --filter @tessera/api type-check && pnpm --filter @tessera/web type-check` |
| **Estimated runtime** | ~18 seconds (Quick), ~90 seconds (Full; Web-Suite 52 Dateien / 354 Tests am 2026-09-16 plus die neuen) |
`biome check` ist kein Tor (bekannter Fehler in der Wurzel-`biome.json`, nicht anfassen).
---
## Sampling Rate
- **After every task commit:** Run the quick command of the touched workspace (siehe Verification Map)
- **After every plan wave:** Run `pnpm --filter @tessera/api exec vitest run && pnpm --filter @tessera/web exec vitest run`
- **Before `/gsd-verify-work`:** Full suite must be green; zusaetzlich ein gruener Pipeline-Lauf mit beiden Paketen (18-05, Phasen-Tor per D-16)
- **Max feedback latency:** 18 seconds (Quick); der lokale AppImage-Bau (18-01 T1/T2, 18-04 T2) und der Docker-Neubau (18-01 T1) sind bewusste Ausnahmen von mehreren Minuten
---
## Per-Task Verification Map
| Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status |
|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------|
| 18-01-01 | 01 | 1 | DESK-03, DESK-05 | T-18-01 / T-18-02 | Plattform-Whitelist vor Dateisystemzugriff; Dateiname nur aus Manifest; Namensmuster-Pruefung | HTTP-Durchstich (NestFactory) + Unit | `pnpm --filter @tessera/api exec vitest run src/desktop` | ❌ W0 (`apps/api/src/desktop/desktop.service.spec.ts`) | ⬜ pending |
| 18-01-01 | 01 | 1 | DESK-03 | T-18-06 | Manifest-Hash stimmt mit Datei ueberein | Skript-Probe | `sh .gitea/scripts/desktop-collect.sh --require linux` + sha256-Vergleich | ✅ (Skript entsteht in der Task) | ⬜ pending |
| 18-01-01 | 01 | 1 | DESK-03 | T-18-01 | Abbild liefert nur Manifest-Dateien, `attachment`-Header | Integration (lokaler Docker-Stack) | `curl -sf http://localhost:3001/desktop/latest` + Header-Check `/desktop/download/linux` + `/api-proxy/desktop/latest` | ✅ | ⬜ pending |
| 18-01-02 | 01 | 1 | DESK-05 | — | Nur rein numerische Versionen werden geschrieben (NSIS) | Skript-Probe (positiv + negativ) | `sh .gitea/scripts/desktop-version.sh --print` = `1.1.0`; `DESKTOP_TAG=v1.2.3-beta … --print` endet mit Exit 1 | ✅ (Skript entsteht in der Task) | ⬜ pending |
| 18-02-01 | 02 | 2 | DESK-01, DESK-04 | T-18-06 / T-18-21 | publish bricht ohne Manifest ab; kein upload-artifact; Cache-Schluessel exakt am SHA | Statisch (Workflow-Greps, `sh -n`, Probelauf) | `grep` auf `fail-on-cache-miss`, `needs: desktop`, `desktop-dist-${{ gitea.sha }}` (2x), `upload-artifact`=0; `publish-images.sh --print-plan` (4 push-Zeilen) | ✅ | ⬜ pending |
| 18-02-02 | 02 | 2 | DESK-04 | T-18-03 | Token nur ueber Header-Datei, nie in einer curl-Zeile | Statisch (`sh -n`, Probelauf, Greps) | `sh -n publish-release.sh`; `publish-release.sh --dry-run --tag v1.1.0` nennt `assets?name=Tessera-1.1.0.AppImage`; `grep -c 'curl.*GITEA_TOKEN'`=0 | ✅ | ⬜ pending |
| 18-03-01 | 03 | 2 | DESK-03 | T-18-07 | Linkziel nur aus `API_URL` + relativem `url` | Unit + Komponente | `pnpm --filter @tessera/web exec vitest run src/lib/desktop.test.ts src/components/desktop` | ❌ W0 (`apps/web/src/lib/desktop.test.ts`, `apps/web/src/components/desktop/desktop-download-links.test.tsx`) | ⬜ pending |
| 18-03-02 | 03 | 2 | DESK-03 | T-18-08 | Hinweistext statt Knoepfe ohne Manifest; Text escaped | Komponente + i18n-Paritaet/Umlaut-Guard + Web-Suite | `pnpm --filter @tessera/web exec vitest run src/components/settings/desktop-app-settings.test.tsx …`; node-Paritaetsskript (`i18n OK`); `pnpm --filter @tessera/web exec vitest run` | ❌ W0 (`apps/web/src/components/settings/desktop-app-settings.test.tsx`) | ⬜ pending |
| 18-04-01 | 04 | 2 | DESK-02, DESK-05 | T-18-10 / T-18-12 | Nur http/https; Opener nur mit gespeicherter `server_url`; Capability-Scope | Compile + Clippy + Kennzeichen-Greps | `cargo check && cargo clippy` (in `apps/desktop/src-tauri`); Greps auf `fn check_server`, `api-proxy`, `"Öffnen"`, `opener:allow-open-url` | ✅ (kein Vitest; Rust-Toolchain vorhanden) | ⬜ pending |
| 18-04-02 | 04 | 2 | DESK-01, DESK-02 | T-18-11 | Kein Fremdcode in CSP; kein Modul-Import; keine vorbelegte Adresse | Statisch + lokaler Bau | Greps auf `window.__TAURI__.core`, `invoke('check_server'`, `unpkg.com`=0; `magick identify` Icon-Groessen; AppImage neuer als `lib.rs`; `desktop-collect.sh --require linux` | ✅ | ⬜ pending |
| 18-05-01 | 05 | 3 | DESK-01, DESK-04 | T-18-15 | `--locked` Werkzeuginstallation; Reihenfolge AppImage vor NSIS | Statisch | Greps auf `cargo-xwin` (≥3), `--target x86_64-pc-windows-msvc --bundles nsis`, `--require linux,windows`; node-Reihenfolgepruefung | ✅ | ⬜ pending |
| 18-05-02 | 05 | 3 | DESK-01, DESK-04, DESK-05 | T-18-18 | Secrets im Log maskiert | Manuell (Checkpoint: Orchestrator pusht und liest den Lauf) | — (human-action) | N/A | ⬜ pending |
| 18-05-03 | 05 | 3 | DESK-01, DESK-04 | T-18-17 | Jede Runde ein Commit mit Ursache | Statisch + Compile | `sh -n` (drei Skripte); `cargo check`; `git rev-list --count --grep='ci(desktop): Runde' HEAD~6..HEAD` ≤ 3 | ✅ | ⬜ pending |
| 18-06-01 | 06 | 4 | DESK-03, DESK-05 | T-18-19 / T-18-20 | SmartScreen-Hinweis an Herkunft gekoppelt; keine Firmenadresse | Doku-Greps | Greps auf `## Desktop-App`, `(#desktop-app)`, `Trotzdem ausführen`, ≥9 `###` im Kapitel, 0 Firmenadressen; CHANGELOG-Position (node) | ✅ | ⬜ pending |
| 18-06-02 | 06 | 4 | DESK-04 | T-18-20 | Keine Firmenadresse in neuen Abschnitten | Doku-Greps | Greps auf `## 10. Desktop-App`, `DESKTOP_DIST_DIR`, `/app/desktop-dist`, `### Fehlerbilder`, `cargo-xwin` (≥2), `### Desktop-App lokal bauen`, `Tauri-Grundgerüst`=0 | ✅ | ⬜ pending |
| 18-06-03 | 06 | 4 | DESK-01..05 | — | — | Gesamtlauf + Bedienprobe | `grep -c` DESK-Eintraege = 5 und Traceability = 5; Full suite + `cargo check` (`ALL-GREEN`) | ✅ | ⬜ pending |
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
---
## Wave 0 Requirements
- [ ] `apps/api/src/desktop/desktop.service.spec.ts` — HTTP-Durchstich ueber `NestFactory.create(DesktopModule)` mit echtem Temp-Verzeichnis: Manifest vorhanden (200), fehlt (404), unbekannte Plattform und Traversal (400, vor jedem Dateisystemzugriff), fehlende Plattform im Manifest (404), Manifest-Name mit Pfadzeichen (404), `@Public()`-Metadaten — entsteht in 18-01 Task 1 (DESK-03, DESK-05, T-18-01/02)
- [ ] `apps/web/src/lib/desktop.test.ts` — memoisiertes Laden, still bei Fehler, `desktopDownloadUrl`, `formatFileSize` — 18-03 Task 1 (DESK-03)
- [ ] `apps/web/src/components/desktop/desktop-download-links.test.tsx` — Link erscheint/verschwindet je nach API-Antwort, nur-Linux-Fall — 18-03 Task 1 (DESK-03, D-12)
- [ ] `apps/web/src/components/settings/desktop-app-settings.test.tsx` — Version/Knoepfe/Groesse, Beta-Zeile, Hinweisfall — 18-03 Task 2 (DESK-03, D-12)
- [ ] Framework install: none — Vitest ist in beiden Apps konfiguriert; Rust/Clippy und ImageMagick sind auf dem Entwicklungsrechner vorhanden (18-RESEARCH.md, Environment Availability; am 2026-09-16 geprueft)
---
## Manual-Only Verifications
| Behavior | Requirement | Why Manual | Test Instructions |
|----------|-------------|------------|-------------------|
| Windows-NSIS-Cross-Bau erzeugt eine gueltige `.exe` | DESK-01, DESK-04 | Kein Windows-Werkzeug lokal (kein `makensis`, kein `cargo-xwin` auf dem Entwicklungsrechner); nur in der Pipeline beweisbar (D-16) | 18-05 Task 2: Orchestrator pusht, liest den Job `desktop`, meldet beide Dateizeilen aus "Pakete einsammeln"; Iterationsschleife max. 3 Runden |
| Installer laeuft auf einem Windows-PC, Erststart zeigt die Anmeldung, Tray/Schliessen/Autostart/Beenden, Einstellungsseite | DESK-01, DESK-02, DESK-03 | Bedienung eines echten Windows-Systems | 18-06 Task 3 `<human-check>`, Schritte 1-10 (Nutzer) |
| Update-Hinweis bei neuerer Client-Version | DESK-05 | Braucht einen Server mit hoeherer Version als der installierte Client — erst nach dem naechsten Freigabe-Tag | 18-06 Task 3 `<human-check>` Punkt (a): nach Tag `v1.2.0` zeigt der 1.1.0-Client die Benachrichtigung und den Menueeintrag "Version 1.2.0 herunterladen" |
| Release-Dateien am Gitea-Release | DESK-04 | Upload laeuft nur bei Tags; ein Test-Tag wuerde den Live-Kanal ausloesen | 18-06 Task 3 `<human-check>` Punkt (b): nach Tag `v1.2.0` traegt der Release `Tessera-Setup-1.2.0.exe` und `Tessera-1.2.0.AppImage`; bis dahin: `publish-release.sh --dry-run --tag v1.1.0` nennt die Uploads (18-02 Task 2) |
---
## Validation Sign-Off
- [ ] All tasks have `<automated>` verify or Wave 0 dependencies
- [ ] Sampling continuity: no 3 consecutive tasks without automated verify
- [ ] Wave 0 covers all MISSING references
- [ ] No watch-mode flags
- [ ] Feedback latency < 18s
- [ ] `nyquist_compliant: true` set in frontmatter
**Approval:** pending
+98
View File
@@ -0,0 +1,98 @@
{
"contract": "1.0.0",
"flavor": "core",
"milestone": "v1.2",
"phases": [
{
"number": "1",
"name": "Foundation & Portal Shell",
"status": "complete"
},
{
"number": "2",
"name": "Authentication & Multi-Tenancy",
"status": "pending"
},
{
"number": "3",
"name": "Module System & Domaincheck",
"status": "complete"
},
{
"number": "4",
"name": "Marketplace & Portal Navigation",
"status": "complete"
},
{
"number": "5",
"name": "Dashboard & Calendar",
"status": "complete"
},
{
"number": "6",
"name": "Desktop Client & CI/CD",
"status": "complete"
},
{
"number": "7",
"name": "DKV Fleet Module",
"status": "complete"
},
{
"number": "8",
"name": "Dashboard Widgets Vollimplementierung",
"status": "complete"
},
{
"number": "9",
"name": "Cert Manager Module",
"status": "complete"
},
{
"number": "10",
"name": "Ausschreibungs-Radar Foundation & DÖE Ingestion",
"status": "complete"
},
{
"number": "11",
"name": "Filter Engine, Results UI & Saved Searches",
"status": "complete"
},
{
"number": "12",
"name": "Tender Notifications",
"status": "complete"
},
{
"number": "13",
"name": "Scraping Adapters & Cross-Source Deduplication",
"status": "complete"
},
{
"number": "14",
"name": "RSS, Email-Alert Ingestion & Module Rollout",
"status": "pending"
},
{
"number": "15",
"name": "Modul-Berechtigungen: Gruppen & User-Grants",
"status": "complete"
},
{
"number": "16",
"name": "AD-Gruppen-Synchronisation",
"status": "complete"
},
{
"number": "17",
"name": "Eigene Ausschreibungs-Quellen je Nutzer",
"status": "complete"
}
],
"next": {
"command": "/gsd:progress --next",
"label": "Advance to the next step (verify)",
"reason": "Phase 18 of 18 · ready to verify"
},
"updated_at": "2026-09-16T14:43:23.832Z"
}
+4
View File
@@ -47,6 +47,10 @@ COPY --from=builder /app/node_modules/.pnpm/@prisma+client@6.19.3_prisma@6.19.3_
COPY --from=builder /app/apps/api/prisma ./apps/api/prisma COPY --from=builder /app/apps/api/prisma ./apps/api/prisma
COPY --from=builder /app/packages/shared/src ./packages/shared/src COPY --from=builder /app/packages/shared/src ./packages/shared/src
COPY apps/api/scripts ./apps/api/scripts COPY apps/api/scripts ./apps/api/scripts
# Desktop-Pakete (Phase 18, D-08): im CI legt desktop-collect.sh Pakete +
# manifest.json in diesen Ordner, lokal liegt nur der Platzhalter. Nur
# lesend zur Laufzeit -- kein chown noetig.
COPY desktop-dist ./desktop-dist
USER nestjs USER nestjs
EXPOSE 3001 EXPOSE 3001
CMD ["sh", "apps/api/scripts/migrate-and-start.sh"] CMD ["sh", "apps/api/scripts/migrate-and-start.sh"]
+2
View File
@@ -7,6 +7,7 @@ import { BugReportsModule } from './bug-reports/bug-reports.module';
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard'; import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
import { RolesGuard } from './auth/guards/roles.guard'; import { RolesGuard } from './auth/guards/roles.guard';
import { ForcePasswordChangeInterceptor } from './auth/interceptors/force-password-change.interceptor'; import { ForcePasswordChangeInterceptor } from './auth/interceptors/force-password-change.interceptor';
import { DesktopModule } from './desktop/desktop.module';
import { HealthModule } from './health/health.module'; import { HealthModule } from './health/health.module';
import { LdapModule } from './ldap/ldap.module'; import { LdapModule } from './ldap/ldap.module';
import { MailModule } from './mail/mail.module'; import { MailModule } from './mail/mail.module';
@@ -36,6 +37,7 @@ import { UserModule } from './user/user.module';
UserModule, UserModule,
TenantModule, TenantModule,
HealthModule, HealthModule,
DesktopModule,
MailModule, MailModule,
LdapModule, LdapModule,
ModuleRegistryModule, ModuleRegistryModule,
@@ -0,0 +1,37 @@
import { Controller, Get, Inject, Param, StreamableFile } from '@nestjs/common';
import type { DesktopLatestResponse } from '@tessera/shared';
import { Public } from '../auth/decorators/public.decorator';
import { DesktopService } from './desktop.service';
@Controller('desktop')
export class DesktopController {
// `@Inject()` explizit (nicht nur der Konstruktor-Typ): Vitest transpiliert
// ueber esbuild, das `emitDecoratorMetadata` nicht respektiert -- ohne den
// expliziten Token findet Nests DI in diesem einen HTTP-Durchstich-Test
// (desktop.service.spec.ts) keinen Provider und `desktopService` bleibt
// `undefined`. Im echten Build (tsc via `nest build`) waere das auch ohne
// `@Inject()` korrekt aufgeloest worden.
constructor(@Inject(DesktopService) private readonly desktopService: DesktopService) {}
// Bewusst oeffentlich (D-10, gleicher Grund wie HealthController.getVersion,
// T-KU1-03): die Anmeldeseite zeigt den Download-Link, bevor eine Anmeldung
// existiert.
@Public()
@Get('latest')
getLatest(): DesktopLatestResponse {
return this.desktopService.getLatest();
}
// Bewusst oeffentlich (D-10): der Download selbst braucht keine Anmeldung,
// gleicher Grund wie getLatest oben.
@Public()
@Get('download/:platform')
download(@Param('platform') platform: string): StreamableFile {
const { stream, entry } = this.desktopService.getPackage(platform);
return new StreamableFile(stream, {
type: 'application/octet-stream',
disposition: `attachment; filename="${entry.name}"`,
length: entry.size,
});
}
}
+9
View File
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { DesktopController } from './desktop.controller';
import { DesktopService } from './desktop.service';
@Module({
controllers: [DesktopController],
providers: [DesktopService],
})
export class DesktopModule {}
@@ -0,0 +1,168 @@
import 'reflect-metadata';
import * as crypto from 'crypto';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { NestFactory } from '@nestjs/core';
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
import { IS_PUBLIC_KEY } from '../auth/decorators/public.decorator';
import { DesktopController } from './desktop.controller';
import { DesktopModule } from './desktop.module';
import { DesktopService } from './desktop.service';
/**
* DesktopService/DesktopController.spec — HTTP-Durchstich ueber
* NestFactory (Phase 18, Task 1). Kein `fs`-Mock: ein echtes
* Temp-Verzeichnis mit einer kleinen Zufallsdatei und einem von Hand
* geschriebenen manifest.json, dessen sha256 unabhaengig ueber
* crypto.createHash berechnet wird -- der Pruefling erzeugt den
* Erwartungswert nicht selbst.
*
* `DesktopService.getManifest()` liest manifest.json bei JEDEM Aufruf neu
* (kein Cache) -- writeManifest() darf die Datei deshalb zwischen Tests
* ueberschreiben, ohne den laufenden HTTP-Server neu zu starten.
*/
const ORIGINAL_ENV = process.env.DESKTOP_DIST_DIR;
let tempDir: string;
let app: Awaited<ReturnType<typeof NestFactory.create>>;
let baseUrl: string;
const PACKAGE_NAME = 'test-package.bin';
let packageSize: number;
let packageSha256: string;
function writeManifest(files: Record<string, { name: string; size: number; sha256: string }>) {
fs.writeFileSync(
path.join(tempDir, 'manifest.json'),
JSON.stringify({
version: '1.1.0',
channel: 'dev',
commit: 'abc1234',
buildTime: '2026-09-16T00:00:00Z',
files,
}),
);
}
beforeAll(async () => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tessera-desktop-'));
const packageBytes = crypto.randomBytes(64 * 1024);
fs.writeFileSync(path.join(tempDir, PACKAGE_NAME), packageBytes);
packageSize = packageBytes.length;
packageSha256 = crypto.createHash('sha256').update(packageBytes).digest('hex');
writeManifest({ linux: { name: PACKAGE_NAME, size: packageSize, sha256: packageSha256 } });
process.env.DESKTOP_DIST_DIR = tempDir;
app = await NestFactory.create(DesktopModule, { logger: false });
await app.listen(0);
const address = app.getHttpServer().address();
const port = typeof address === 'object' && address ? address.port : 0;
baseUrl = `http://127.0.0.1:${port}`;
});
afterAll(async () => {
await app.close();
fs.rmSync(tempDir, { recursive: true, force: true });
if (ORIGINAL_ENV === undefined) {
delete process.env.DESKTOP_DIST_DIR;
} else {
process.env.DESKTOP_DIST_DIR = ORIGINAL_ENV;
}
});
afterEach(() => {
// Default-Manifest fuer den naechsten Test wiederherstellen (Tests 6/7
// ueberschreiben es bewusst mit einer anderen Form).
writeManifest({ linux: { name: PACKAGE_NAME, size: packageSize, sha256: packageSha256 } });
});
describe('DesktopService/DesktopController — HTTP-Durchstich (Phase 18)', () => {
it('Test 1 (latest, Manifest vorhanden): 200 mit Kopf-Feldern und relativer Download-URL', async () => {
const res = await fetch(`${baseUrl}/desktop/latest`);
expect(res.status).toBe(200);
const body = await res.json();
expect(body).toEqual({
version: '1.1.0',
channel: 'dev',
commit: 'abc1234',
buildTime: '2026-09-16T00:00:00Z',
files: {
linux: {
name: PACKAGE_NAME,
size: packageSize,
sha256: packageSha256,
url: '/desktop/download/linux',
},
},
});
});
it('Test 2 (getLatest ohne Manifest): eigene Instanz mit leerem Temp-Verzeichnis wirft NotFoundException', () => {
const emptyDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tessera-desktop-empty-'));
const previous = process.env.DESKTOP_DIST_DIR;
process.env.DESKTOP_DIST_DIR = emptyDir;
try {
const service = new DesktopService();
expect(() => service.getLatest()).toThrow(NotFoundException);
} finally {
process.env.DESKTOP_DIST_DIR = previous;
fs.rmSync(emptyDir, { recursive: true, force: true });
}
});
it('Test 3 (download/linux): 200, attachment-Header und Body-Hash stimmen mit dem Manifest ueberein', async () => {
const res = await fetch(`${baseUrl}/desktop/download/linux`);
expect(res.status).toBe(200);
expect(res.headers.get('content-disposition')).toBe(`attachment; filename="${PACKAGE_NAME}"`);
expect(res.headers.get('content-type')).toBe('application/octet-stream');
expect(res.headers.get('content-length')).toBe(String(packageSize));
const buffer = Buffer.from(await res.arrayBuffer());
const hash = crypto.createHash('sha256').update(buffer).digest('hex');
expect(hash).toBe(packageSha256);
});
it('Test 4 (Plattform-Whitelist + Traversal ueber HTTP): mac und ..%2F..%2Fetc%2Fpasswd enden mit 400', async () => {
const resMac = await fetch(`${baseUrl}/desktop/download/mac`);
expect(resMac.status).toBe(400);
const resTraversal = await fetch(`${baseUrl}/desktop/download/..%2F..%2Fetc%2Fpasswd`);
expect(resTraversal.status).toBe(400);
});
it('Test 5 (Whitelist vor Dateisystem): nicht existierendes Verzeichnis + mac wirft BadRequestException, nicht NotFoundException', () => {
const missingDir = path.join(os.tmpdir(), 'tessera-desktop-does-not-exist-' + Date.now());
const previous = process.env.DESKTOP_DIST_DIR;
process.env.DESKTOP_DIST_DIR = missingDir;
try {
const service = new DesktopService();
expect(() => service.getPackage('mac')).toThrow(BadRequestException);
} finally {
process.env.DESKTOP_DIST_DIR = previous;
}
});
it('Test 6 (Manifest nur mit windows): download/linux endet mit 404', async () => {
writeManifest({
windows: { name: 'Tessera-Setup-1.1.0.exe', size: 123, sha256: 'a'.repeat(64) },
});
const res = await fetch(`${baseUrl}/desktop/download/linux`);
expect(res.status).toBe(404);
});
it('Test 7 (manipulierter Name im Manifest): "../x.AppImage" endet mit 404', async () => {
writeManifest({
linux: { name: '../x.AppImage', size: 123, sha256: 'a'.repeat(64) },
});
const res = await fetch(`${baseUrl}/desktop/download/linux`);
expect(res.status).toBe(404);
});
it('Test 8 (bewusst oeffentlich): getLatest und download tragen @Public()', () => {
expect(Reflect.getMetadata(IS_PUBLIC_KEY, DesktopController.prototype.getLatest)).toBe(true);
expect(Reflect.getMetadata(IS_PUBLIC_KEY, DesktopController.prototype.download)).toBe(true);
});
});
+125
View File
@@ -0,0 +1,125 @@
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import type {
DesktopLatestResponse,
DesktopManifest,
DesktopManifestFile,
DesktopPlatform,
} from '@tessera/shared';
import * as fs from 'fs';
import * as path from 'path';
/**
* Wertevorrat der Plattformen (Phase 18, D-10). Geschlossen -- eine dritte
* Plattform waere eine bewusste Erweiterung hier UND am Typ `DesktopPlatform`
* in packages/shared/src/index.ts.
*/
const PLATFORMS = ['windows', 'linux'] as const;
@Injectable()
export class DesktopService {
private readonly logger = new Logger(DesktopService.name);
/** Resolved path to desktop-dist/ (monorepo root, or /app/desktop-dist im Abbild). */
private readonly desktopDistDir: string;
constructor() {
const envDir = process.env.DESKTOP_DIST_DIR?.trim();
this.desktopDistDir =
envDir && envDir.length > 0
? envDir
: // __dirname at runtime = apps/api/dist/desktop/ -- go up 4 levels to monorepo root
path.resolve(__dirname, '..', '..', '..', '..', 'desktop-dist');
}
/**
* Liest manifest.json. Gibt `null` zurueck (nie werfen) wenn die Datei
* fehlt, nicht parsebar ist, oder die Grundform nicht stimmt (version kein
* String, files kein Objekt) -- D-10: "fehlt das Verzeichnis/Manifest: 404
* mit klarer Meldung".
*/
getManifest(): DesktopManifest | null {
const manifestPath = path.join(this.desktopDistDir, 'manifest.json');
if (!fs.existsSync(manifestPath)) {
return null;
}
try {
const raw = fs.readFileSync(manifestPath, 'utf-8');
const parsed = JSON.parse(raw) as DesktopManifest;
if (typeof parsed.version !== 'string' || typeof parsed.files !== 'object' || parsed.files === null) {
this.logger.warn(`manifest.json unter ${manifestPath} hat unerwartete Form`);
return null;
}
return parsed;
} catch (error) {
this.logger.warn(`manifest.json unter ${manifestPath} konnte nicht gelesen werden: ${error}`);
return null;
}
}
/**
* `GET /desktop/latest` (D-10): Kopf-Felder aus dem Manifest, je
* vorhandener Plattform eine relative Download-URL ergaenzt (Client stellt
* die API-Basis davor, siehe Objective-Abschnitt "Vom Client aus ...").
*/
getLatest(): DesktopLatestResponse {
const manifest = this.getManifest();
if (!manifest) {
throw new NotFoundException('Desktop packages are not available on this server');
}
const files: DesktopLatestResponse['files'] = {};
for (const platform of PLATFORMS) {
const entry = manifest.files[platform];
if (entry) {
files[platform] = { ...entry, url: `/desktop/download/${platform}` };
}
}
return {
version: manifest.version,
channel: manifest.channel,
commit: manifest.commit,
buildTime: manifest.buildTime,
files,
};
}
/**
* `GET /desktop/download/:platform` (D-10, T-18-01): Reihenfolge ist die
* Sicherheitseigenschaft -- Whitelist VOR jedem Dateisystemzugriff, der
* Dateiname kommt ausschliesslich aus manifest.json, nie aus der Anfrage.
*/
getPackage(platform: string): { stream: fs.ReadStream; entry: DesktopManifestFile } {
// (1) Whitelist -- vor jedem Dateisystemzugriff.
if (!PLATFORMS.includes(platform as DesktopPlatform)) {
throw new BadRequestException('Unknown platform');
}
const knownPlatform = platform as DesktopPlatform;
// (2) Manifest holen.
const manifest = this.getManifest();
if (!manifest) {
throw new NotFoundException('Desktop packages are not available on this server');
}
// (3) Eintrag fuer diese Plattform muss existieren.
const entry = manifest.files[knownPlatform];
if (!entry) {
throw new NotFoundException(`No package for platform: ${knownPlatform}`);
}
// (4) Verteidigung in der Tiefe (T-18-02): auch ein manipuliertes
// Manifest darf nicht aus dem Ordner hinausfuehren.
if (!/^[A-Za-z0-9._-]+$/.test(entry.name)) {
throw new NotFoundException(`No package for platform: ${knownPlatform}`);
}
// (5) Datei muss existieren.
const filePath = path.join(this.desktopDistDir, entry.name);
if (!fs.existsSync(filePath)) {
throw new NotFoundException(`Package file missing: ${entry.name}`);
}
// (6) Stream zurueckgeben -- kein Puffern der ganzen Datei (Installer
// sind deutlich groesser als DKV-Exporte).
return { stream: fs.createReadStream(filePath), entry };
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@tessera/desktop", "name": "@tessera/desktop",
"version": "0.0.1", "version": "1.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"tauri": "tauri", "tauri": "tauri",
+54 -1
View File
@@ -1820,6 +1820,25 @@ version = "2.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
[[package]]
name = "is-docker"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3"
dependencies = [
"once_cell",
]
[[package]]
name = "is-wsl"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
dependencies = [
"is-docker",
"once_cell",
]
[[package]] [[package]]
name = "itoa" name = "itoa"
version = "1.0.18" version = "1.0.18"
@@ -2410,6 +2429,17 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "open"
version = "5.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa576c76302b7b808eecc68061e67336c47833ef9d22caa74dda10fa9675eebc"
dependencies = [
"dunce",
"is-wsl",
"libc",
]
[[package]] [[package]]
name = "openssl" name = "openssl"
version = "0.10.81" version = "0.10.81"
@@ -3818,6 +3848,28 @@ dependencies = [
"url", "url",
] ]
[[package]]
name = "tauri-plugin-opener"
version = "2.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60d60366174b745b4ef5824b8bbc1c457fd08f0ce101ff643c0a49181a9f4e91"
dependencies = [
"dunce",
"glob",
"objc2-app-kit",
"objc2-foundation",
"open",
"schemars 0.8.22",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"thiserror 2.0.18",
"url",
"windows",
"zbus",
]
[[package]] [[package]]
name = "tauri-plugin-store" name = "tauri-plugin-store"
version = "2.4.3" version = "2.4.3"
@@ -3986,7 +4038,7 @@ dependencies = [
[[package]] [[package]]
name = "tessera-desktop" name = "tessera-desktop"
version = "0.0.1" version = "1.1.0"
dependencies = [ dependencies = [
"reqwest 0.12.28", "reqwest 0.12.28",
"serde", "serde",
@@ -3995,6 +4047,7 @@ dependencies = [
"tauri-build", "tauri-build",
"tauri-plugin-autostart", "tauri-plugin-autostart",
"tauri-plugin-notification", "tauri-plugin-notification",
"tauri-plugin-opener",
"tauri-plugin-store", "tauri-plugin-store",
"tauri-plugin-window-state", "tauri-plugin-window-state",
] ]
+2 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "tessera-desktop" name = "tessera-desktop"
version = "0.0.1" version = "1.1.0"
edition = "2021" edition = "2021"
[lib] [lib]
@@ -19,3 +19,4 @@ tauri-plugin-window-state = "2"
reqwest = { version = "0.12", features = ["json"] } reqwest = { version = "0.12", features = ["json"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
tauri-plugin-opener = "2"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

@@ -13,6 +13,7 @@
"autostart:allow-enable", "autostart:allow-enable",
"autostart:allow-disable", "autostart:allow-disable",
"autostart:allow-is-enabled", "autostart:allow-is-enabled",
"window-state:default" "window-state:default",
{ "identifier": "opener:allow-open-url", "allow": [{ "url": "https://*" }, { "url": "http://*" }] }
] ]
} }
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
{"default":{"identifier":"default","description":"Tessera desktop capabilities","local":true,"windows":["main"],"permissions":["core:default","store:default","notification:default","notification:allow-is-permission-granted","notification:allow-request-permission","notification:allow-notify","autostart:allow-enable","autostart:allow-disable","autostart:allow-is-enabled","window-state:default"]}} {"default":{"identifier":"default","description":"Tessera desktop capabilities","local":true,"windows":["main"],"permissions":["core:default","store:default","notification:default","notification:allow-is-permission-granted","notification:allow-request-permission","notification:allow-notify","autostart:allow-enable","autostart:allow-disable","autostart:allow-is-enabled","window-state:default",{"identifier":"opener:allow-open-url","allow":[{"url":"https://*"},{"url":"http://*"}]}]}}
@@ -134,6 +134,174 @@
"description": "Reference a permission or permission set by identifier and extends its scope.", "description": "Reference a permission or permission set by identifier and extends its scope.",
"type": "object", "type": "object",
"allOf": [ "allOf": [
{
"if": {
"properties": {
"identifier": {
"anyOf": [
{
"description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`",
"type": "string",
"const": "opener:default",
"markdownDescription": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`"
},
{
"description": "This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.",
"type": "string",
"const": "opener:allow-default-urls",
"markdownDescription": "This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application."
},
{
"description": "Enables the open_path command without any pre-configured scope.",
"type": "string",
"const": "opener:allow-open-path",
"markdownDescription": "Enables the open_path command without any pre-configured scope."
},
{
"description": "Enables the open_url command without any pre-configured scope.",
"type": "string",
"const": "opener:allow-open-url",
"markdownDescription": "Enables the open_url command without any pre-configured scope."
},
{
"description": "Enables the reveal_item_in_dir command without any pre-configured scope.",
"type": "string",
"const": "opener:allow-reveal-item-in-dir",
"markdownDescription": "Enables the reveal_item_in_dir command without any pre-configured scope."
},
{
"description": "Denies the open_path command without any pre-configured scope.",
"type": "string",
"const": "opener:deny-open-path",
"markdownDescription": "Denies the open_path command without any pre-configured scope."
},
{
"description": "Denies the open_url command without any pre-configured scope.",
"type": "string",
"const": "opener:deny-open-url",
"markdownDescription": "Denies the open_url command without any pre-configured scope."
},
{
"description": "Denies the reveal_item_in_dir command without any pre-configured scope.",
"type": "string",
"const": "opener:deny-reveal-item-in-dir",
"markdownDescription": "Denies the reveal_item_in_dir command without any pre-configured scope."
}
]
}
}
},
"then": {
"properties": {
"allow": {
"items": {
"title": "OpenerScopeEntry",
"description": "Opener scope entry.",
"anyOf": [
{
"type": "object",
"required": [
"url"
],
"properties": {
"app": {
"description": "An application to open this url with, for example: firefox.",
"allOf": [
{
"$ref": "#/definitions/Application"
}
]
},
"url": {
"description": "A URL that can be opened by the webview when using the Opener APIs.\n\nWildcards can be used following the UNIX glob pattern.\n\nExamples:\n\n- \"https://*\" : allows all HTTPS origin\n\n- \"https://*.github.com/tauri-apps/tauri\": allows any subdomain of \"github.com\" with the \"tauri-apps/api\" path\n\n- \"https://myapi.service.com/users/*\": allows access to any URLs that begins with \"https://myapi.service.com/users/\"",
"type": "string"
}
}
},
{
"type": "object",
"required": [
"path"
],
"properties": {
"app": {
"description": "An application to open this path with, for example: xdg-open.",
"allOf": [
{
"$ref": "#/definitions/Application"
}
]
},
"path": {
"description": "A path that can be opened by the webview when using the Opener APIs.\n\nThe pattern can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.",
"type": "string"
}
}
}
]
}
},
"deny": {
"items": {
"title": "OpenerScopeEntry",
"description": "Opener scope entry.",
"anyOf": [
{
"type": "object",
"required": [
"url"
],
"properties": {
"app": {
"description": "An application to open this url with, for example: firefox.",
"allOf": [
{
"$ref": "#/definitions/Application"
}
]
},
"url": {
"description": "A URL that can be opened by the webview when using the Opener APIs.\n\nWildcards can be used following the UNIX glob pattern.\n\nExamples:\n\n- \"https://*\" : allows all HTTPS origin\n\n- \"https://*.github.com/tauri-apps/tauri\": allows any subdomain of \"github.com\" with the \"tauri-apps/api\" path\n\n- \"https://myapi.service.com/users/*\": allows access to any URLs that begins with \"https://myapi.service.com/users/\"",
"type": "string"
}
}
},
{
"type": "object",
"required": [
"path"
],
"properties": {
"app": {
"description": "An application to open this path with, for example: xdg-open.",
"allOf": [
{
"$ref": "#/definitions/Application"
}
]
},
"path": {
"description": "A path that can be opened by the webview when using the Opener APIs.\n\nThe pattern can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.",
"type": "string"
}
}
}
]
}
}
}
},
"properties": {
"identifier": {
"description": "Identifier of the permission or permission set.",
"allOf": [
{
"$ref": "#/definitions/Identifier"
}
]
}
}
},
{ {
"properties": { "properties": {
"identifier": { "identifier": {
@@ -2432,6 +2600,54 @@
"const": "notification:deny-show", "const": "notification:deny-show",
"markdownDescription": "Denies the show command without any pre-configured scope." "markdownDescription": "Denies the show command without any pre-configured scope."
}, },
{
"description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`",
"type": "string",
"const": "opener:default",
"markdownDescription": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`"
},
{
"description": "This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.",
"type": "string",
"const": "opener:allow-default-urls",
"markdownDescription": "This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application."
},
{
"description": "Enables the open_path command without any pre-configured scope.",
"type": "string",
"const": "opener:allow-open-path",
"markdownDescription": "Enables the open_path command without any pre-configured scope."
},
{
"description": "Enables the open_url command without any pre-configured scope.",
"type": "string",
"const": "opener:allow-open-url",
"markdownDescription": "Enables the open_url command without any pre-configured scope."
},
{
"description": "Enables the reveal_item_in_dir command without any pre-configured scope.",
"type": "string",
"const": "opener:allow-reveal-item-in-dir",
"markdownDescription": "Enables the reveal_item_in_dir command without any pre-configured scope."
},
{
"description": "Denies the open_path command without any pre-configured scope.",
"type": "string",
"const": "opener:deny-open-path",
"markdownDescription": "Denies the open_path command without any pre-configured scope."
},
{
"description": "Denies the open_url command without any pre-configured scope.",
"type": "string",
"const": "opener:deny-open-url",
"markdownDescription": "Denies the open_url command without any pre-configured scope."
},
{
"description": "Denies the reveal_item_in_dir command without any pre-configured scope.",
"type": "string",
"const": "opener:deny-reveal-item-in-dir",
"markdownDescription": "Denies the reveal_item_in_dir command without any pre-configured scope."
},
{ {
"description": "This permission set configures what kind of\noperations are available from the store plugin.\n\n#### Granted Permissions\n\nAll operations are enabled by default.\n\n\n#### This default permission set includes:\n\n- `allow-load`\n- `allow-get-store`\n- `allow-set`\n- `allow-get`\n- `allow-has`\n- `allow-delete`\n- `allow-clear`\n- `allow-reset`\n- `allow-keys`\n- `allow-values`\n- `allow-entries`\n- `allow-length`\n- `allow-reload`\n- `allow-save`", "description": "This permission set configures what kind of\noperations are available from the store plugin.\n\n#### Granted Permissions\n\nAll operations are enabled by default.\n\n\n#### This default permission set includes:\n\n- `allow-load`\n- `allow-get-store`\n- `allow-set`\n- `allow-get`\n- `allow-has`\n- `allow-delete`\n- `allow-clear`\n- `allow-reset`\n- `allow-keys`\n- `allow-values`\n- `allow-entries`\n- `allow-length`\n- `allow-reload`\n- `allow-save`",
"type": "string", "type": "string",
@@ -2743,6 +2959,23 @@
] ]
} }
] ]
},
"Application": {
"description": "Opener scope application.",
"anyOf": [
{
"description": "Open in default application.",
"type": "null"
},
{
"description": "If true, allow open with any application.",
"type": "boolean"
},
{
"description": "Allow specific application to open with.",
"type": "string"
}
]
} }
} }
} }
@@ -134,6 +134,174 @@
"description": "Reference a permission or permission set by identifier and extends its scope.", "description": "Reference a permission or permission set by identifier and extends its scope.",
"type": "object", "type": "object",
"allOf": [ "allOf": [
{
"if": {
"properties": {
"identifier": {
"anyOf": [
{
"description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`",
"type": "string",
"const": "opener:default",
"markdownDescription": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`"
},
{
"description": "This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.",
"type": "string",
"const": "opener:allow-default-urls",
"markdownDescription": "This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application."
},
{
"description": "Enables the open_path command without any pre-configured scope.",
"type": "string",
"const": "opener:allow-open-path",
"markdownDescription": "Enables the open_path command without any pre-configured scope."
},
{
"description": "Enables the open_url command without any pre-configured scope.",
"type": "string",
"const": "opener:allow-open-url",
"markdownDescription": "Enables the open_url command without any pre-configured scope."
},
{
"description": "Enables the reveal_item_in_dir command without any pre-configured scope.",
"type": "string",
"const": "opener:allow-reveal-item-in-dir",
"markdownDescription": "Enables the reveal_item_in_dir command without any pre-configured scope."
},
{
"description": "Denies the open_path command without any pre-configured scope.",
"type": "string",
"const": "opener:deny-open-path",
"markdownDescription": "Denies the open_path command without any pre-configured scope."
},
{
"description": "Denies the open_url command without any pre-configured scope.",
"type": "string",
"const": "opener:deny-open-url",
"markdownDescription": "Denies the open_url command without any pre-configured scope."
},
{
"description": "Denies the reveal_item_in_dir command without any pre-configured scope.",
"type": "string",
"const": "opener:deny-reveal-item-in-dir",
"markdownDescription": "Denies the reveal_item_in_dir command without any pre-configured scope."
}
]
}
}
},
"then": {
"properties": {
"allow": {
"items": {
"title": "OpenerScopeEntry",
"description": "Opener scope entry.",
"anyOf": [
{
"type": "object",
"required": [
"url"
],
"properties": {
"app": {
"description": "An application to open this url with, for example: firefox.",
"allOf": [
{
"$ref": "#/definitions/Application"
}
]
},
"url": {
"description": "A URL that can be opened by the webview when using the Opener APIs.\n\nWildcards can be used following the UNIX glob pattern.\n\nExamples:\n\n- \"https://*\" : allows all HTTPS origin\n\n- \"https://*.github.com/tauri-apps/tauri\": allows any subdomain of \"github.com\" with the \"tauri-apps/api\" path\n\n- \"https://myapi.service.com/users/*\": allows access to any URLs that begins with \"https://myapi.service.com/users/\"",
"type": "string"
}
}
},
{
"type": "object",
"required": [
"path"
],
"properties": {
"app": {
"description": "An application to open this path with, for example: xdg-open.",
"allOf": [
{
"$ref": "#/definitions/Application"
}
]
},
"path": {
"description": "A path that can be opened by the webview when using the Opener APIs.\n\nThe pattern can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.",
"type": "string"
}
}
}
]
}
},
"deny": {
"items": {
"title": "OpenerScopeEntry",
"description": "Opener scope entry.",
"anyOf": [
{
"type": "object",
"required": [
"url"
],
"properties": {
"app": {
"description": "An application to open this url with, for example: firefox.",
"allOf": [
{
"$ref": "#/definitions/Application"
}
]
},
"url": {
"description": "A URL that can be opened by the webview when using the Opener APIs.\n\nWildcards can be used following the UNIX glob pattern.\n\nExamples:\n\n- \"https://*\" : allows all HTTPS origin\n\n- \"https://*.github.com/tauri-apps/tauri\": allows any subdomain of \"github.com\" with the \"tauri-apps/api\" path\n\n- \"https://myapi.service.com/users/*\": allows access to any URLs that begins with \"https://myapi.service.com/users/\"",
"type": "string"
}
}
},
{
"type": "object",
"required": [
"path"
],
"properties": {
"app": {
"description": "An application to open this path with, for example: xdg-open.",
"allOf": [
{
"$ref": "#/definitions/Application"
}
]
},
"path": {
"description": "A path that can be opened by the webview when using the Opener APIs.\n\nThe pattern can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.",
"type": "string"
}
}
}
]
}
}
}
},
"properties": {
"identifier": {
"description": "Identifier of the permission or permission set.",
"allOf": [
{
"$ref": "#/definitions/Identifier"
}
]
}
}
},
{ {
"properties": { "properties": {
"identifier": { "identifier": {
@@ -2432,6 +2600,54 @@
"const": "notification:deny-show", "const": "notification:deny-show",
"markdownDescription": "Denies the show command without any pre-configured scope." "markdownDescription": "Denies the show command without any pre-configured scope."
}, },
{
"description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`",
"type": "string",
"const": "opener:default",
"markdownDescription": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`"
},
{
"description": "This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.",
"type": "string",
"const": "opener:allow-default-urls",
"markdownDescription": "This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application."
},
{
"description": "Enables the open_path command without any pre-configured scope.",
"type": "string",
"const": "opener:allow-open-path",
"markdownDescription": "Enables the open_path command without any pre-configured scope."
},
{
"description": "Enables the open_url command without any pre-configured scope.",
"type": "string",
"const": "opener:allow-open-url",
"markdownDescription": "Enables the open_url command without any pre-configured scope."
},
{
"description": "Enables the reveal_item_in_dir command without any pre-configured scope.",
"type": "string",
"const": "opener:allow-reveal-item-in-dir",
"markdownDescription": "Enables the reveal_item_in_dir command without any pre-configured scope."
},
{
"description": "Denies the open_path command without any pre-configured scope.",
"type": "string",
"const": "opener:deny-open-path",
"markdownDescription": "Denies the open_path command without any pre-configured scope."
},
{
"description": "Denies the open_url command without any pre-configured scope.",
"type": "string",
"const": "opener:deny-open-url",
"markdownDescription": "Denies the open_url command without any pre-configured scope."
},
{
"description": "Denies the reveal_item_in_dir command without any pre-configured scope.",
"type": "string",
"const": "opener:deny-reveal-item-in-dir",
"markdownDescription": "Denies the reveal_item_in_dir command without any pre-configured scope."
},
{ {
"description": "This permission set configures what kind of\noperations are available from the store plugin.\n\n#### Granted Permissions\n\nAll operations are enabled by default.\n\n\n#### This default permission set includes:\n\n- `allow-load`\n- `allow-get-store`\n- `allow-set`\n- `allow-get`\n- `allow-has`\n- `allow-delete`\n- `allow-clear`\n- `allow-reset`\n- `allow-keys`\n- `allow-values`\n- `allow-entries`\n- `allow-length`\n- `allow-reload`\n- `allow-save`", "description": "This permission set configures what kind of\noperations are available from the store plugin.\n\n#### Granted Permissions\n\nAll operations are enabled by default.\n\n\n#### This default permission set includes:\n\n- `allow-load`\n- `allow-get-store`\n- `allow-set`\n- `allow-get`\n- `allow-has`\n- `allow-delete`\n- `allow-clear`\n- `allow-reset`\n- `allow-keys`\n- `allow-values`\n- `allow-entries`\n- `allow-length`\n- `allow-reload`\n- `allow-save`",
"type": "string", "type": "string",
@@ -2743,6 +2959,23 @@
] ]
} }
] ]
},
"Application": {
"description": "Opener scope application.",
"anyOf": [
{
"description": "Open in default application.",
"type": "null"
},
{
"description": "If true, allow open with any application.",
"type": "boolean"
},
{
"description": "Allow specific application to open with.",
"type": "string"
}
]
} }
} }
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 361 B

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 B

After

Width:  |  Height:  |  Size: 33 KiB

+128 -11
View File
@@ -1,11 +1,12 @@
use std::time::Duration;
use tauri::{ use tauri::{
Manager, menu::{CheckMenuItemBuilder, MenuBuilder, MenuItemBuilder},
menu::{MenuBuilder, MenuItemBuilder},
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
RunEvent, WindowEvent, AppHandle, Manager, RunEvent, WindowEvent,
}; };
use tauri_plugin_autostart::MacosLauncher; use tauri_plugin_autostart::{MacosLauncher, ManagerExt};
use tauri_plugin_notification::NotificationExt; use tauri_plugin_notification::NotificationExt;
use tauri_plugin_opener::OpenerExt;
use tauri_plugin_store::StoreExt; use tauri_plugin_store::StoreExt;
#[derive(serde::Deserialize)] #[derive(serde::Deserialize)]
@@ -13,12 +14,77 @@ struct VersionResponse {
version: String, version: String,
} }
#[derive(serde::Deserialize)]
struct DesktopLatest {
version: String,
}
/// Baut die Adresse eines API-Pfads aus der gespeicherten Server-Adresse.
/// Die API ist vom Client nur ueber den Web-Ursprung erreichbar
/// (Next.js-Rewrite `/api-proxy/*`, siehe 18-01) -- niemals direkt unter dem
/// Web-Hostnamen. Dies ist die einzige Stelle, an der der Rewrite-Praefix
/// steht.
fn api_url(server: &str, path: &str) -> String {
format!("{}/api-proxy{}", server.trim_end_matches('/'), path)
}
#[tauri::command]
async fn check_server(url: String) -> Result<String, String> {
let parsed = tauri::Url::parse(&url).map_err(|_| "Diese Adresse ist ungültig.".to_string())?;
if parsed.scheme() != "http" && parsed.scheme() != "https" {
return Err("Es sind nur Adressen mit http oder https erlaubt.".to_string());
}
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(8))
.build()
.map_err(|e| e.to_string())?;
let resp = client
.get(api_url(&url, "/health/version"))
.send()
.await
.map_err(|_| "Unter dieser Adresse antwortet kein Tessera-Server.".to_string())?;
if !resp.status().is_success() {
return Err(format!(
"Der Server antwortete mit Status {}.",
resp.status().as_u16()
));
}
let info = resp
.json::<VersionResponse>()
.await
.map_err(|_| "Unter dieser Adresse antwortet kein Tessera-Server.".to_string())?;
Ok(info.version)
}
#[tauri::command]
fn save_server_url(app: AppHandle, url: String) -> Result<(), String> {
let parsed = tauri::Url::parse(&url).map_err(|_| "Diese Adresse ist ungültig.".to_string())?;
let normalized = parsed.as_str().to_string();
let store = app.store("config.json").map_err(|e| e.to_string())?;
store.set("server_url", serde_json::json!(normalized));
store.save().map_err(|e| e.to_string())?;
if let Some(window) = app.get_webview_window("main") {
let _ = window.navigate(parsed);
}
Ok(())
}
pub fn run() { pub fn run() {
let app = tauri::Builder::default() let app = tauri::Builder::default()
.plugin(tauri_plugin_store::Builder::new().build()) .plugin(tauri_plugin_store::Builder::new().build())
.plugin(tauri_plugin_notification::init()) .plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_window_state::Builder::default().build()) .plugin(tauri_plugin_window_state::Builder::default().build())
.plugin(tauri_plugin_autostart::init(MacosLauncher::LaunchAgent, None)) .plugin(tauri_plugin_autostart::init(MacosLauncher::LaunchAgent, None))
.plugin(tauri_plugin_opener::init())
.invoke_handler(tauri::generate_handler![check_server, save_server_url])
.setup(|app| { .setup(|app| {
let store = app.store("config.json")?; let store = app.store("config.json")?;
let server_url = store.get("server_url"); let server_url = store.get("server_url");
@@ -38,27 +104,71 @@ pub fn run() {
} }
} }
// Tray menu // Tray menu: Öffnen · Update herunterladen · — · Autostart-Haken · — · Beenden.
let open = MenuItemBuilder::with_id("open", "Oeffnen").build(app)?; // "update" bleibt gesperrt, bis die Versionspruefung eine neuere Version
// findet; "autostart" spiegelt den tatsaechlichen Systemzustand beim Start.
let open = MenuItemBuilder::with_id("open", "Öffnen").build(app)?;
let update = MenuItemBuilder::with_id("update", "Update herunterladen")
.enabled(false)
.build(app)?;
let autostart_label = if cfg!(target_os = "windows") {
"Mit Windows starten"
} else {
"Beim Anmelden starten"
};
let autostart = CheckMenuItemBuilder::with_id("autostart", autostart_label)
.checked(app.autolaunch().is_enabled().unwrap_or(false))
.build(app)?;
let quit = MenuItemBuilder::with_id("quit", "Beenden").build(app)?; let quit = MenuItemBuilder::with_id("quit", "Beenden").build(app)?;
let menu = MenuBuilder::new(app) let menu = MenuBuilder::new(app)
.item(&open) .item(&open)
.item(&update)
.separator()
.item(&autostart)
.separator() .separator()
.item(&quit) .item(&quit)
.build()?; .build()?;
let server_for_menu = url_for_check.clone();
let autostart_for_menu = autostart.clone();
let _tray = TrayIconBuilder::new() let _tray = TrayIconBuilder::new()
.icon(app.default_window_icon().unwrap().clone()) .icon(app.default_window_icon().unwrap().clone())
.menu(&menu) .menu(&menu)
.tooltip("Tessera") .tooltip("Tessera")
.show_menu_on_left_click(false) .show_menu_on_left_click(false)
.on_menu_event(|app, event| match event.id().as_ref() { .on_menu_event(move |app, event| match event.id().as_ref() {
"open" => { "open" => {
if let Some(w) = app.get_webview_window("main") { if let Some(w) = app.get_webview_window("main") {
let _ = w.show(); let _ = w.show();
let _ = w.set_focus(); let _ = w.set_focus();
} }
} }
"update" => {
if let Some(server) = &server_for_menu {
let target =
format!("{}/settings/general/desktop", server.trim_end_matches('/'));
let _ = app.opener().open_url(target, None::<&str>);
}
}
"autostart" => {
let mgr = app.autolaunch();
let currently_on = mgr.is_enabled().unwrap_or(false);
let toggled = if currently_on {
mgr.disable()
} else {
mgr.enable()
};
match toggled {
Ok(()) => {
let _ = autostart_for_menu.set_checked(!currently_on);
}
Err(_) => {
let _ = autostart_for_menu.set_checked(currently_on);
}
}
}
"quit" => { "quit" => {
app.exit(0); app.exit(0);
} }
@@ -83,17 +193,24 @@ pub fn run() {
if let Some(server_url) = url_for_check { if let Some(server_url) = url_for_check {
let app_handle = app.handle().clone(); let app_handle = app.handle().clone();
let app_version = env!("CARGO_PKG_VERSION").to_string(); let app_version = env!("CARGO_PKG_VERSION").to_string();
let update_item = update.clone();
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
let url = format!("{}/health/version", server_url.trim_end_matches('/')); let url = api_url(&server_url, "/desktop/latest");
if let Ok(resp) = reqwest::get(&url).await { if let Ok(resp) = reqwest::get(&url).await {
if let Ok(info) = resp.json::<VersionResponse>().await { if let Ok(info) = resp.json::<DesktopLatest>().await {
if info.version != app_version { if info.version != app_version {
let _ = app_handle let _ = app_handle
.notification() .notification()
.builder() .builder()
.title("Tessera Update") .title("Tessera-Update")
.body("Eine neue Version ist verfuegbar.") .body(format!(
"Neue Version {} verfügbar – Download über das Symbol im Infobereich.",
info.version
))
.show(); .show();
let _ = update_item
.set_text(format!("Version {} herunterladen", info.version));
let _ = update_item.set_enabled(true);
} }
} }
} }
+13 -4
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-config-schema/schema.json", "$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-config-schema/schema.json",
"productName": "Tessera", "productName": "Tessera",
"version": "0.0.1", "version": "1.1.0",
"identifier": "de.ctl.tessera.desktop", "identifier": "de.ctl.tessera.desktop",
"build": { "build": {
"frontendDist": "../src", "frontendDist": "../src",
@@ -21,12 +21,21 @@
} }
], ],
"security": { "security": {
"csp": "default-src 'self' 'unsafe-inline' 'unsafe-eval'; connect-src *; img-src * data:; font-src * data:; style-src 'self' 'unsafe-inline' *; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://unpkg.com" "csp": "default-src 'self' 'unsafe-inline' 'unsafe-eval'; connect-src *; img-src * data:; font-src * data:; style-src 'self' 'unsafe-inline' *; script-src 'self' 'unsafe-inline' 'unsafe-eval'"
} }
}, },
"bundle": { "bundle": {
"active": true, "active": true,
"targets": ["appimage", "nsis"], "targets": [
"icon": ["icons/icon.png", "icons/icon.ico"] "appimage",
"nsis"
],
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.png",
"icons/icon.ico"
]
} }
} }
+76 -22
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tessera - Setup</title> <title>Tessera – Desktop-App einrichten</title>
<style> <style>
* { * {
margin: 0; margin: 0;
@@ -28,16 +28,22 @@
max-width: 440px; max-width: 440px;
width: 100%; width: 100%;
padding: 48px 32px; padding: 48px 32px;
background: oklch(0.23 0.01 260); background: oklch(0.22 0.01 260);
border-radius: 12px; border-radius: 12px;
border: 1px solid oklch(0.30 0.01 260); border: 1px solid oklch(0.30 0.01 260);
} }
.brand-mark {
margin: 0 auto 16px;
width: 56px;
height: 56px;
}
h1 { h1 {
font-size: 2rem; font-size: 2rem;
font-weight: 700; font-weight: 700;
margin-bottom: 8px; margin-bottom: 8px;
color: oklch(0.91 0.19 102); color: #ffed00;
} }
.subtitle { .subtitle {
@@ -55,6 +61,13 @@
color: oklch(0.85 0 0); color: oklch(0.85 0 0);
} }
.hint {
text-align: left;
font-size: 0.8125rem;
color: oklch(0.60 0 0);
margin-bottom: 8px;
}
input[type="url"] { input[type="url"] {
width: 100%; width: 100%;
padding: 12px 16px; padding: 12px 16px;
@@ -69,7 +82,7 @@
} }
input[type="url"]:focus { input[type="url"]:focus {
border-color: oklch(0.91 0.19 102); border-color: #9c9440;
} }
input[type="url"]::placeholder { input[type="url"]::placeholder {
@@ -92,14 +105,22 @@
margin-top: 6px; margin-top: 6px;
} }
.info-message {
display: none;
text-align: left;
font-size: 0.8125rem;
color: oklch(0.70 0.15 150);
margin-top: 6px;
}
button { button {
margin-top: 24px; margin-top: 24px;
width: 100%; width: 100%;
padding: 12px 32px; padding: 12px 32px;
border-radius: 8px; border-radius: 8px;
border: none; border: none;
background: oklch(0.91 0.19 102); background: #ffed00;
color: oklch(0.20 0.02 90); color: #1a1a1a;
cursor: pointer; cursor: pointer;
font-size: 1rem; font-size: 1rem;
font-weight: 600; font-weight: 600;
@@ -123,31 +144,43 @@
</head> </head>
<body> <body>
<div class="setup-card"> <div class="setup-card">
<svg class="brand-mark" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 72 72" width="56" height="56">
<rect x="4" y="4" width="64" height="64" rx="14" fill="#1a1a1a"></rect>
<g>
<rect x="16" y="16" width="12" height="12" rx="2.5" fill="#9c9440"></rect>
<rect x="30" y="16" width="12" height="12" rx="2.5" fill="#9c9440"></rect>
<rect x="45" y="15" width="12" height="12" rx="2.5" transform="rotate(12 51 21)" fill="#ffed00"></rect>
<rect x="30" y="30" width="12" height="12" rx="2.5" fill="#9c9440"></rect>
<rect x="30" y="44" width="12" height="12" rx="2.5" fill="#9c9440"></rect>
</g>
</svg>
<h1>Tessera</h1> <h1>Tessera</h1>
<p class="subtitle">Desktop-Client einrichten</p> <p class="subtitle">Desktop-App einrichten</p>
<label for="server-url">Server-URL eingeben:</label> <label for="server-url">Adresse Ihres Tessera-Servers</label>
<p class="hint">Das ist die Adresse, unter der Sie Tessera auch im Browser öffnen.</p>
<input <input
id="server-url" id="server-url"
type="url" type="url"
placeholder="https://tessera.example.com" placeholder="https://tessera.example.com"
value="http://localhost:3000"
autocomplete="off" autocomplete="off"
spellcheck="false" spellcheck="false"
/> />
<p id="error-msg" class="error-message"></p> <p id="error-msg" class="error-message"></p>
<p id="warning-msg" class="warning-message"></p> <p id="warning-msg" class="warning-message"></p>
<p id="info-msg" class="info-message"></p>
<button id="connect-btn" type="button">Verbinden</button> <button id="connect-btn" type="button">Verbinden</button>
</div> </div>
<script type="module"> <script type="module">
import { load } from '@tauri-apps/plugin-store'; const { invoke } = window.__TAURI__.core;
const urlInput = document.getElementById('server-url'); const urlInput = document.getElementById('server-url');
const connectBtn = document.getElementById('connect-btn'); const connectBtn = document.getElementById('connect-btn');
const errorMsg = document.getElementById('error-msg'); const errorMsg = document.getElementById('error-msg');
const warningMsg = document.getElementById('warning-msg'); const warningMsg = document.getElementById('warning-msg');
const infoMsg = document.getElementById('info-msg');
/** /**
* Validate a server URL using the URL constructor. * Validate a server URL using the URL constructor.
@@ -157,19 +190,23 @@
const trimmed = input.trim(); const trimmed = input.trim();
if (!trimmed) { if (!trimmed) {
return { valid: false, warning: null, error: 'Bitte eine URL eingeben.' }; return { valid: false, warning: null, error: 'Bitte geben Sie die Adresse Ihres Tessera-Servers ein.' };
} }
let parsed; let parsed;
try { try {
parsed = new URL(trimmed); parsed = new URL(trimmed);
} catch { } catch {
return { valid: false, warning: null, error: 'Ungueltige URL. Bitte eine gueltige URL eingeben (z.B. https://tessera.example.com).' }; return {
valid: false,
warning: null,
error: 'Diese Adresse ist ungültig. Bitte geben Sie eine vollständige Adresse ein, z. B. https://tessera.example.com.',
};
} }
// Only allow http and https protocols // Only allow http and https protocols
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return { valid: false, warning: null, error: 'Nur HTTP und HTTPS URLs sind erlaubt.' }; return { valid: false, warning: null, error: 'Es sind nur Adressen mit http oder https erlaubt.' };
} }
// Warn on non-https for non-localhost addresses (T-06-01 mitigation) // Warn on non-https for non-localhost addresses (T-06-01 mitigation)
@@ -177,7 +214,7 @@
if (parsed.protocol === 'http:' && !isLocalhost) { if (parsed.protocol === 'http:' && !isLocalhost) {
return { return {
valid: true, valid: true,
warning: 'Warnung: Unverschluesselte Verbindung (HTTP). Fuer Produktivumgebungen wird HTTPS empfohlen.', warning: 'Hinweis: Diese Verbindung ist unverschlüsselt (http). Für den Produktivbetrieb empfehlen wir https.',
error: null, error: null,
}; };
} }
@@ -189,17 +226,26 @@
errorMsg.textContent = message; errorMsg.textContent = message;
errorMsg.style.display = 'block'; errorMsg.style.display = 'block';
warningMsg.style.display = 'none'; warningMsg.style.display = 'none';
infoMsg.style.display = 'none';
} }
function showWarning(message) { function showWarning(message) {
warningMsg.textContent = message; warningMsg.textContent = message;
warningMsg.style.display = 'block'; warningMsg.style.display = 'block';
errorMsg.style.display = 'none'; errorMsg.style.display = 'none';
infoMsg.style.display = 'none';
}
function showInfo(message) {
infoMsg.textContent = message;
infoMsg.style.display = 'block';
errorMsg.style.display = 'none';
} }
function clearMessages() { function clearMessages() {
errorMsg.style.display = 'none'; errorMsg.style.display = 'none';
warningMsg.style.display = 'none'; warningMsg.style.display = 'none';
infoMsg.style.display = 'none';
} }
async function connect() { async function connect() {
@@ -220,18 +266,26 @@
// Normalize the URL // Normalize the URL
const normalizedUrl = new URL(rawUrl.trim()).href; const normalizedUrl = new URL(rawUrl.trim()).href;
// Disable button during save // Disable button while the address is being checked
connectBtn.disabled = true; connectBtn.disabled = true;
connectBtn.textContent = 'Verbinde...'; connectBtn.textContent = 'Prüfe Verbindung …';
let version;
try {
version = await invoke('check_server', { url: normalizedUrl });
} catch (err) {
showError(String(err));
connectBtn.disabled = false;
connectBtn.textContent = 'Verbinden';
return;
}
showInfo('Tessera ' + version + ' gefunden – Verbindung wird hergestellt …');
try { try {
const store = await load('config.json', { autoSave: true }); await invoke('save_server_url', { url: normalizedUrl });
await store.set('server_url', normalizedUrl);
// Navigate the WebView to the configured server
window.location.href = normalizedUrl;
} catch (err) { } catch (err) {
showError('Fehler beim Speichern der Konfiguration: ' + (err.message || err)); showError('Die Adresse konnte nicht gespeichert werden: ' + String(err));
connectBtn.disabled = false; connectBtn.disabled = false;
connectBtn.textContent = 'Verbinden'; connectBtn.textContent = 'Verbinden';
} }
+4
View File
@@ -7,6 +7,7 @@ import Link from 'next/link';
import { login } from '@/lib/auth-actions'; import { login } from '@/lib/auth-actions';
import { BRAND_YELLOW } from '@/components/brand/brand'; import { BRAND_YELLOW } from '@/components/brand/brand';
import { TesseraLogo } from '@/components/brand/tessera-logo'; import { TesseraLogo } from '@/components/brand/tessera-logo';
import { DesktopDownloadLinks } from '@/components/desktop/desktop-download-links';
/** /**
* Split-screen login page (D-01). * Split-screen login page (D-01).
@@ -181,6 +182,9 @@ export default function LoginPage() {
)} )}
</button> </button>
</form> </form>
{/* Desktop-App-Link (D-12), blendet sich aus ohne API-Antwort */}
<DesktopDownloadLinks />
</div> </div>
</div> </div>
</div> </div>
@@ -0,0 +1,21 @@
'use client';
import { useTranslations } from 'next-intl';
import { DesktopAppSettings } from '@/components/settings/desktop-app-settings';
/**
* Desktop-App settings page — /settings/general/desktop (D-12).
* Shows version, download buttons, file size, and explanatory text.
*/
export default function DesktopSettingsPage() {
const t = useTranslations('settings');
return (
<div>
<h1 className="mb-6 text-lg font-semibold text-foreground">
{t('desktop.title')}
</h1>
<DesktopAppSettings />
</div>
);
}
@@ -0,0 +1,97 @@
import { cleanup, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { DesktopDownloadLinks } from './desktop-download-links';
/**
* desktop-download-links.test — Link-Block auf der Anmeldeseite (18-03).
* next-intl-Mock nach dem Muster in widget-settings-panel.test.tsx
* (de.json-gestuetzt), `@/lib/desktop` gemockt mit steuerbarem
* `loadDesktopLatest`; `desktopDownloadUrl` wird echt durchgereicht.
*/
vi.mock('next-intl', async () => {
const messages = (await import('@/messages/de.json')).default as Record<string, unknown>;
const lookup = (path: string): string | undefined =>
path.split('.').reduce<unknown>((o, k) => (o && typeof o === 'object' ? (o as any)[k] : undefined), messages) as
| string
| undefined;
return {
useTranslations:
(ns?: string) =>
(key: string, values?: Record<string, unknown>) => {
const raw = lookup(ns ? `${ns}.${key}` : key) ?? key;
return values ? raw.replace(/\{(\w+)\}/g, (_: string, n: string) => String(values[n] ?? '')) : raw;
},
useLocale: () => 'de',
};
});
const { loadDesktopLatest } = vi.hoisted(() => ({ loadDesktopLatest: vi.fn() }));
vi.mock('@/lib/desktop', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/desktop')>();
return {
...actual,
loadDesktopLatest,
};
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
const windowsFile = {
name: 'Tessera-Setup-1.1.0.exe',
size: 6123456,
sha256: 'x',
url: '/desktop/download/windows',
};
const linuxFile = {
name: 'Tessera-1.1.0.AppImage',
size: 106461688,
sha256: 'y',
url: '/desktop/download/linux',
};
describe('DesktopDownloadLinks (18-03)', () => {
it('Test 1 (keine Daten): rendert nichts, solange null zurueckkommt', async () => {
loadDesktopLatest.mockResolvedValue(null);
const { container } = render(<DesktopDownloadLinks />);
await vi.waitFor(() => expect(loadDesktopLatest).toHaveBeenCalled());
expect(container.firstChild).toBeNull();
});
it('Test 2 (beide Plattformen): Windows-Hauptlink, Linux-Kurzlink, Version', async () => {
loadDesktopLatest.mockResolvedValue({
version: '1.1.0',
channel: 'beta',
commit: 'abc1234',
buildTime: 'x',
files: { windows: windowsFile, linux: linuxFile },
});
render(<DesktopDownloadLinks />);
const windowsLink = await screen.findByText('Desktop-App herunterladen (Windows)');
expect(windowsLink.closest('a')).toHaveAttribute('href', 'http://localhost:3001/desktop/download/windows');
const linuxLink = screen.getByText('Linux-Version');
expect(linuxLink.closest('a')).toHaveAttribute('href', 'http://localhost:3001/desktop/download/linux');
expect(screen.getByText('Version 1.1.0')).toBeInTheDocument();
});
it('Test 3 (nur Linux): genau ein Link mit dem Linux-Text', async () => {
loadDesktopLatest.mockResolvedValue({
version: '1.1.0',
channel: 'beta',
commit: 'abc1234',
buildTime: 'x',
files: { linux: linuxFile },
});
render(<DesktopDownloadLinks />);
const linuxLink = await screen.findByText('Desktop-App herunterladen (Linux)');
expect(linuxLink.closest('a')).toHaveAttribute('href', 'http://localhost:3001/desktop/download/linux');
expect(screen.queryByText('Linux-Version')).not.toBeInTheDocument();
});
});
@@ -0,0 +1,66 @@
'use client';
import { useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { desktopDownloadUrl, type DesktopLatestInfo, loadDesktopLatest } from '@/lib/desktop';
/**
* Unauffaelliger Download-Link auf der Anmeldeseite (D-12): erscheint nur,
* wenn `/desktop/latest` antwortet — kein Fehlertext, kein Ladeanzeiger,
* nichts, solange nichts geladen ist. Windows fuehrt (Hauptlink), Linux
* folgt als kleiner zweiter Link, wenn beide Pakete vorliegen.
*/
export function DesktopDownloadLinks() {
const t = useTranslations('auth');
const [info, setInfo] = useState<DesktopLatestInfo | null>(null);
useEffect(() => {
let active = true;
loadDesktopLatest().then((data) => {
if (active) setInfo(data);
});
return () => {
active = false;
};
}, []);
const files = info?.files;
const windows = files?.windows;
const linux = files?.linux;
if (!info || (!windows && !linux)) {
return null;
}
const primary = windows ?? linux;
const primaryLabel = windows ? t('desktopDownload.windows') : t('desktopDownload.linux');
const showLinuxShort = Boolean(windows && linux);
return (
<div className="text-center text-sm text-muted-foreground">
<div>
{primary && (
<a
href={desktopDownloadUrl(primary)}
download
className="hover:text-foreground underline-offset-4 hover:underline"
>
{primaryLabel}
</a>
)}
{showLinuxShort && linux && (
<>
{' · '}
<a
href={desktopDownloadUrl(linux)}
download
className="hover:text-foreground underline-offset-4 hover:underline"
>
{t('desktopDownload.linuxShort')}
</a>
</>
)}
</div>
<div className="text-xs">{t('desktopDownload.version', { version: info.version })}</div>
</div>
);
}
@@ -0,0 +1,105 @@
import { cleanup, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { DesktopAppSettings } from './desktop-app-settings';
/**
* desktop-app-settings.test — Einstellungsseite "Desktop-App" (18-03).
* next-intl-Mock nach dem Muster in widget-settings-panel.test.tsx
* (de.json-gestuetzt), `@/lib/desktop` gemockt.
*/
vi.mock('next-intl', async () => {
const messages = (await import('@/messages/de.json')).default as Record<string, unknown>;
const lookup = (path: string): string | undefined =>
path.split('.').reduce<unknown>((o, k) => (o && typeof o === 'object' ? (o as any)[k] : undefined), messages) as
| string
| undefined;
return {
useTranslations:
(ns?: string) =>
(key: string, values?: Record<string, unknown>) => {
const raw = lookup(ns ? `${ns}.${key}` : key) ?? key;
return values ? raw.replace(/\{(\w+)\}/g, (_: string, n: string) => String(values[n] ?? '')) : raw;
},
useLocale: () => 'de',
};
});
const { loadDesktopLatest } = vi.hoisted(() => ({ loadDesktopLatest: vi.fn() }));
vi.mock('@/lib/desktop', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/desktop')>();
return {
...actual,
loadDesktopLatest,
};
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
const windowsFile = {
name: 'Tessera-Setup-1.1.0.exe',
size: 6123456,
sha256: 'x',
url: '/desktop/download/windows',
};
const linuxFile = {
name: 'Tessera-1.1.0.AppImage',
size: 106461688,
sha256: 'y',
url: '/desktop/download/linux',
};
describe('DesktopAppSettings (18-03)', () => {
it('Test 1 (beide Plattformen): Version, zwei Links, Dateiname und Groesse', async () => {
loadDesktopLatest.mockResolvedValue({
version: '1.1.0',
channel: 'live',
commit: 'abc1234',
buildTime: 'x',
files: { windows: windowsFile, linux: linuxFile },
});
render(<DesktopAppSettings />);
expect(await screen.findByText('Aktuelle Version: 1.1.0')).toBeInTheDocument();
const windowsLink = screen.getByTestId('desktop-download-windows');
expect(windowsLink).toHaveAttribute('href', 'http://localhost:3001/desktop/download/windows');
const linuxLink = screen.getByTestId('desktop-download-linux');
expect(linuxLink).toHaveAttribute('href', 'http://localhost:3001/desktop/download/linux');
expect(screen.getByText('Tessera-Setup-1.1.0.exe · 5,8 MB')).toBeInTheDocument();
expect(screen.getByText('Tessera-1.1.0.AppImage · 101,5 MB')).toBeInTheDocument();
});
it('Test 2 (Beta-Kanal): Hinweis mit Commit', async () => {
loadDesktopLatest.mockResolvedValue({
version: '1.1.0',
channel: 'beta',
commit: 'abc1234',
buildTime: 'x',
files: { windows: windowsFile, linux: linuxFile },
});
render(<DesktopAppSettings />);
expect(await screen.findByText('Beta-Ausgabe, Stand abc1234')).toBeInTheDocument();
});
it('Test 3 (keine Pakete): Hinweistext statt Knoepfe, Saetze bleiben', async () => {
loadDesktopLatest.mockResolvedValue(null);
render(<DesktopAppSettings />);
expect(
await screen.findByText('Auf diesem Server sind derzeit keine Desktop-Pakete hinterlegt.'),
).toBeInTheDocument();
expect(screen.queryByTestId('desktop-download-windows')).toBeNull();
expect(screen.queryByTestId('desktop-download-linux')).toBeNull();
expect(
screen.getByText(
'Die Desktop-App öffnet Tessera in einem eigenen Fenster – ohne Browser, mit Symbol im Infobereich der Taskleiste.',
),
).toBeInTheDocument();
});
});
@@ -0,0 +1,122 @@
'use client';
import { useEffect, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { desktopDownloadUrl, type DesktopFileInfo, type DesktopLatestInfo, formatFileSize, loadDesktopLatest } from '@/lib/desktop';
const BUTTON_CLASS =
'inline-flex items-center gap-2 rounded-md bg-primary px-4 py-2.5 text-sm font-medium text-primary-foreground hover:opacity-90';
function WindowsIcon() {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<rect x="1" y="1" width="6" height="6" rx="1" />
<rect x="9" y="1" width="6" height="6" rx="1" />
<rect x="1" y="9" width="6" height="6" rx="1" />
<rect x="9" y="9" width="6" height="6" rx="1" />
</svg>
);
}
function LinuxIcon() {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
<rect x="1" y="2" width="14" height="12" rx="1.5" />
<path d="M4 6l2.5 2L4 10" strokeLinecap="round" strokeLinejoin="round" />
<line x1="8" y1="10" x2="11" y2="10" strokeLinecap="round" />
</svg>
);
}
function DownloadButton({
file,
label,
icon,
testId,
locale,
fileInfoText,
}: {
file: DesktopFileInfo;
label: string;
icon: React.ReactNode;
testId: string;
locale: string;
fileInfoText: (values: { name: string; size: string }) => string;
}) {
return (
<div>
<a href={desktopDownloadUrl(file)} download data-testid={testId} className={BUTTON_CLASS}>
{icon}
{label}
</a>
<p className="mt-1 text-xs text-muted-foreground">
{fileInfoText({ name: file.name, size: formatFileSize(file.size, locale) })}
</p>
</div>
);
}
/**
* Inhalt der Einstellungsseite "Desktop-App" (D-12): vier erklaerende
* Saetze, dann Version/Downloads oder ein Hinweis, wenn der Server keine
* Pakete traegt (`loadDesktopLatest()` liefert `null`).
*/
export function DesktopAppSettings() {
const t = useTranslations('settings');
const locale = useLocale();
const [info, setInfo] = useState<DesktopLatestInfo | null | undefined>(undefined);
useEffect(() => {
let active = true;
loadDesktopLatest().then((data) => {
if (active) setInfo(data);
});
return () => {
active = false;
};
}, []);
const fileInfoText = (values: { name: string; size: string }) => t('desktop.fileInfo', values);
return (
<div>
<p className="text-sm text-muted-foreground">{t('desktop.intro')}</p>
<p className="text-sm text-muted-foreground">{t('desktop.firstStart')}</p>
<p className="text-sm text-muted-foreground">{t('desktop.tray')}</p>
<p className="text-sm text-muted-foreground">{t('desktop.update')}</p>
{info === null && <p className="mt-4 text-sm text-muted-foreground">{t('desktop.unavailable')}</p>}
{info && (
<>
<p className="mt-4 text-sm text-foreground">{t('desktop.versionLabel', { version: info.version })}</p>
{info.channel === 'beta' && (
<p className="text-sm text-muted-foreground">{t('desktop.channelBeta', { commit: info.commit })}</p>
)}
<div className="mt-4 flex flex-wrap gap-4">
{info.files.windows && (
<DownloadButton
file={info.files.windows}
label={t('desktop.downloadWindows')}
icon={<WindowsIcon />}
testId="desktop-download-windows"
locale={locale}
fileInfoText={fileInfoText}
/>
)}
{info.files.linux && (
<DownloadButton
file={info.files.linux}
label={t('desktop.downloadLinux')}
icon={<LinuxIcon />}
testId="desktop-download-linux"
locale={locale}
fileInfoText={fileInfoText}
/>
)}
</div>
</>
)}
</div>
);
}
@@ -58,6 +58,19 @@ export function SettingsSidebar() {
> >
{t('categoryAccount')} {t('categoryAccount')}
</Link> </Link>
<Link
href="/settings/general/desktop"
className={`flex items-center rounded-md px-2 py-1.5 text-sm transition-colors ${
isActive('/settings/general/desktop')
? 'bg-sidebar-accent text-sidebar-accent-foreground font-medium'
: 'text-sidebar-foreground hover:bg-muted'
}`}
aria-current={
isActive('/settings/general/desktop') ? 'page' : undefined
}
>
{t('categoryDesktopApp')}
</Link>
</nav> </nav>
{/* Dashboard category — existing items unchanged */} {/* Dashboard category — existing items unchanged */}
+72
View File
@@ -0,0 +1,72 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
/**
* desktop.test — Desktop-Paket-Info fuer die Web-Oberflaeche (Phase 18-03).
*
* `loadDesktopLatest()` memoisiert die Antwort von `GET /desktop/latest` —
* deshalb setzt jeder Test die Module zurueck und importiert dynamisch,
* nachdem `fetch` gestubbt ist (Muster: app-version.test.ts).
*/
afterEach(() => {
vi.unstubAllEnvs();
vi.unstubAllGlobals();
});
async function importFresh() {
vi.resetModules();
return import('./desktop');
}
describe('desktop (18-03)', () => {
it('Test 1 (Laden, memoisiert): zwei Aufrufe liefern dasselbe Objekt, fetch laeuft genau einmal ohne credentials', async () => {
const payload = {
version: '1.1.0',
channel: 'beta',
commit: 'abc1234',
buildTime: 'x',
files: {
windows: { name: 'Tessera-Setup-1.1.0.exe', size: 6123456, sha256: 'x', url: '/desktop/download/windows' },
},
};
const fetchMock = vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve(payload) }));
vi.stubGlobal('fetch', fetchMock);
const mod = await importFresh();
const first = await mod.loadDesktopLatest();
const second = await mod.loadDesktopLatest();
expect(first).toEqual(payload);
expect(second).toEqual(payload);
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, options] = fetchMock.mock.calls[0] as unknown as [string, RequestInit | undefined];
expect(url.endsWith('/desktop/latest')).toBe(true);
expect(options?.credentials).toBeUndefined();
});
it('Test 2 (still bei ok=false): eine Nicht-2xx-Antwort liefert null, nichts wird geworfen', async () => {
vi.stubGlobal('fetch', vi.fn(() => Promise.resolve({ ok: false, json: () => Promise.resolve({}) })));
const mod = await importFresh();
await expect(mod.loadDesktopLatest()).resolves.toBeNull();
});
it('Test 3 (still bei Netzfehler): ein abgelehntes fetch-Promise liefert null, nichts wird geworfen', async () => {
vi.stubGlobal('fetch', vi.fn(() => Promise.reject(new Error('netz'))));
const mod = await importFresh();
await expect(mod.loadDesktopLatest()).resolves.toBeNull();
});
it('Test 4 (desktopDownloadUrl): API_URL plus relatives url-Feld', async () => {
const mod = await importFresh();
expect(
mod.desktopDownloadUrl({ name: 'Tessera-Setup-1.1.0.exe', size: 1, sha256: 'x', url: '/desktop/download/windows' }),
).toBe('http://localhost:3001/desktop/download/windows');
});
it('Test 5 (formatFileSize): lokalisierte MB-Werte', async () => {
const mod = await importFresh();
expect(mod.formatFileSize(6123456, 'de')).toBe('5,8 MB');
expect(mod.formatFileSize(6123456, 'en')).toBe('5.8 MB');
expect(mod.formatFileSize(106461688, 'de')).toBe('101,5 MB');
});
});
+67
View File
@@ -0,0 +1,67 @@
/**
* Desktop-Paket-Info fuer die Web-Oberflaeche (Phase 18, D-10/D-12).
*
* Quelle fuer den unauffaelligen Download-Link auf der Anmeldeseite
* (`DesktopDownloadLinks`) UND fuer die Einstellungsseite
* "Desktop-App" (`DesktopAppSettings`): beide lesen `GET /desktop/latest`
* (oeffentlich, `@Public()` in `apps/api/src/desktop/desktop.controller.ts`)
* und blenden sich aus, wenn der Server keine Pakete traegt (404).
*
* Wichtig: Next.js ersetzt `process.env.NEXT_PUBLIC_*` nur dann zur Bauzeit
* im Browser-Bundle, wenn der Ausdruck woertlich mit vollem Namen im Code
* steht — kein Destructuring, kein `process.env[name]` (siehe `app-version.ts`).
* Im Betrieb laeuft die Anfrage ueber den Rewrite `/api-proxy` auf die API.
*/
export type DesktopPlatform = 'windows' | 'linux';
/**
* Spiegel von `DesktopLatestFile`/`DesktopLatestResponse` aus
* `packages/shared`: `apps/web` haengt nicht von `@tessera/shared` ab
* (gleiche Begruendung wie in `app-version.ts`). Die API-Wahrheit bleibt in
* `apps/api/src/desktop/desktop.service.ts`.
*/
export interface DesktopFileInfo {
name: string;
size: number;
sha256: string;
url: string;
}
export interface DesktopLatestInfo {
version: string;
channel: string;
commit: string;
buildTime: string;
files: Partial<Record<DesktopPlatform, DesktopFileInfo>>;
}
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
let desktopLatestPromise: Promise<DesktopLatestInfo | null> | null = null;
/**
* Laedt `GET /desktop/latest` genau einmal je Modulinstanz (memoisiert);
* jeder Fehler (Netz, Nicht-2xx — insbesondere 404 ohne Manifest) ist still
* und liefert `null`. Kein `credentials: 'include'`: die Anmeldeseite zeigt
* den Link, bevor ein Cookie existiert, und der Endpunkt ist oeffentlich.
*/
export function loadDesktopLatest(): Promise<DesktopLatestInfo | null> {
if (!desktopLatestPromise) {
desktopLatestPromise = fetch(`${API_URL}/desktop/latest`)
.then((res) => (res.ok ? (res.json() as Promise<DesktopLatestInfo>) : null))
.catch(() => null);
}
return desktopLatestPromise;
}
/** Baut die absolute Download-Adresse aus `API_URL` plus dem relativen `url`-Feld (T-18-07). */
export function desktopDownloadUrl(file: DesktopFileInfo): string {
return `${API_URL}${file.url}`;
}
/** Formatiert Dateigroessen in MB, lokalisiert (z. B. `5,8 MB` / `5.8 MB`). */
export function formatFileSize(bytes: number, locale: string): string {
const mb = bytes / 1048576;
return `${new Intl.NumberFormat(locale, { maximumFractionDigits: 1 }).format(mb)} MB`;
}
+20
View File
@@ -34,6 +34,12 @@
"branding": { "branding": {
"tagline": "Modulare Workflow-Plattform für Ihr Unternehmen" "tagline": "Modulare Workflow-Plattform für Ihr Unternehmen"
}, },
"desktopDownload": {
"windows": "Desktop-App herunterladen (Windows)",
"linux": "Desktop-App herunterladen (Linux)",
"linuxShort": "Linux-Version",
"version": "Version {version}"
},
"resetPassword": { "resetPassword": {
"title": "Passwort zurücksetzen", "title": "Passwort zurücksetzen",
"email": "E-Mail-Adresse", "email": "E-Mail-Adresse",
@@ -126,6 +132,7 @@
"categoryCalendar": "Kalender", "categoryCalendar": "Kalender",
"categoryGeneral": "Allgemein", "categoryGeneral": "Allgemein",
"categoryAccount": "Konto", "categoryAccount": "Konto",
"categoryDesktopApp": "Desktop-App",
"categorySmtp": "SMTP", "categorySmtp": "SMTP",
"account": { "account": {
"title": "Konto", "title": "Konto",
@@ -146,6 +153,19 @@
"deleteAvatarSuccess": "Profilbild erfolgreich gelöscht.", "deleteAvatarSuccess": "Profilbild erfolgreich gelöscht.",
"deleteAvatarError": "Löschen fehlgeschlagen." "deleteAvatarError": "Löschen fehlgeschlagen."
}, },
"desktop": {
"title": "Desktop-App",
"intro": "Die Desktop-App öffnet Tessera in einem eigenen Fenster – ohne Browser, mit Symbol im Infobereich der Taskleiste.",
"firstStart": "Beim ersten Start fragt die App nach der Adresse Ihres Tessera-Servers; das ist die Adresse, unter der Sie Tessera auch im Browser öffnen.",
"tray": "Schließen Sie das Fenster, läuft Tessera im Infobereich weiter; über das Symbol dort öffnen Sie das Fenster wieder, schalten den automatischen Start ein oder beenden die App.",
"update": "Erscheint eine neuere Version, weist die App Sie darauf hin und führt Sie auf diese Seite.",
"versionLabel": "Aktuelle Version: {version}",
"channelBeta": "Beta-Ausgabe, Stand {commit}",
"downloadWindows": "Für Windows herunterladen",
"downloadLinux": "Für Linux herunterladen",
"fileInfo": "{name} · {size}",
"unavailable": "Auf diesem Server sind derzeit keine Desktop-Pakete hinterlegt."
},
"sourceDeleteConfirm": "Möchten Sie diese Kalenderquelle wirklich löschen?", "sourceDeleteConfirm": "Möchten Sie diese Kalenderquelle wirklich löschen?",
"sourceDeleteCta": "Quelle löschen", "sourceDeleteCta": "Quelle löschen",
"providerDeleteConfirm": "Möchten Sie diesen Suchanbieter wirklich löschen?", "providerDeleteConfirm": "Möchten Sie diesen Suchanbieter wirklich löschen?",
+20
View File
@@ -34,6 +34,12 @@
"branding": { "branding": {
"tagline": "Modular workflow platform for your organization" "tagline": "Modular workflow platform for your organization"
}, },
"desktopDownload": {
"windows": "Download desktop app (Windows)",
"linux": "Download desktop app (Linux)",
"linuxShort": "Linux version",
"version": "Version {version}"
},
"resetPassword": { "resetPassword": {
"title": "Reset Password", "title": "Reset Password",
"email": "Email address", "email": "Email address",
@@ -126,6 +132,7 @@
"categoryCalendar": "Calendar", "categoryCalendar": "Calendar",
"categoryGeneral": "General", "categoryGeneral": "General",
"categoryAccount": "Account", "categoryAccount": "Account",
"categoryDesktopApp": "Desktop App",
"categorySmtp": "SMTP", "categorySmtp": "SMTP",
"account": { "account": {
"title": "Account", "title": "Account",
@@ -146,6 +153,19 @@
"deleteAvatarSuccess": "Profile picture deleted successfully.", "deleteAvatarSuccess": "Profile picture deleted successfully.",
"deleteAvatarError": "Failed to delete picture." "deleteAvatarError": "Failed to delete picture."
}, },
"desktop": {
"title": "Desktop App",
"intro": "The desktop app opens Tessera in its own window – no browser, with an icon in the notification area of the taskbar.",
"firstStart": "On first start the app asks for the address of your Tessera server; it is the address you also use to open Tessera in the browser.",
"tray": "If you close the window, Tessera keeps running in the notification area; use the icon there to reopen the window, enable automatic start, or quit the app.",
"update": "When a newer version is available the app notifies you and brings you to this page.",
"versionLabel": "Current version: {version}",
"channelBeta": "Beta build, commit {commit}",
"downloadWindows": "Download for Windows",
"downloadLinux": "Download for Linux",
"fileInfo": "{name} · {size}",
"unavailable": "No desktop packages are available on this server yet."
},
"sourceDeleteConfirm": "Are you sure you want to delete this calendar source?", "sourceDeleteConfirm": "Are you sure you want to delete this calendar source?",
"sourceDeleteCta": "Delete source", "sourceDeleteCta": "Delete source",
"providerDeleteConfirm": "Are you sure you want to delete this search provider?", "providerDeleteConfirm": "Are you sure you want to delete this search provider?",
@@ -114,6 +114,7 @@ export const UMLAUT_ALLOWLIST: readonly string[] = [
'Energiequellen', 'Energiequellen',
'neue', 'neue',
'neuen', 'neuen',
'neuere',
'Neue', 'Neue',
'Neues', 'Neues',
'aktuelle', 'aktuelle',
View File
+34
View File
@@ -17,3 +17,37 @@ export interface VersionResponse {
commit: string; commit: string;
buildTime: string; buildTime: string;
} }
/**
* Desktop-Pakete (Phase 18, D-08): Quelle ist ausschliesslich `manifest.json`,
* geschrieben nur von `.gitea/scripts/desktop-collect.sh` im CI. `platform` ist
* ein geschlossener Wertevorrat -- eine dritte Plattform waere eine bewusste
* Erweiterung hier und an der Konstante `PLATFORMS` im API-Dienst.
*/
export type DesktopPlatform = 'windows' | 'linux';
export interface DesktopManifestFile {
name: string;
size: number;
sha256: string;
}
export interface DesktopManifest {
version: string;
channel: string;
commit: string;
buildTime: string;
files: Partial<Record<DesktopPlatform, DesktopManifestFile>>;
}
export interface DesktopLatestFile extends DesktopManifestFile {
url: string;
}
export interface DesktopLatestResponse {
version: string;
channel: string;
commit: string;
buildTime: string;
files: Partial<Record<DesktopPlatform, DesktopLatestFile>>;
}