Compare commits
22 Commits
7110512d83
...
1c4247a7c9
| Author | SHA1 | Date | |
|---|---|---|---|
| 1c4247a7c9 | |||
| 233de7eae0 | |||
| 289604a28e | |||
| 2d55f07729 | |||
| 8b130fddbd | |||
| 82312ef691 | |||
| e96d460b8e | |||
| 026d9c39af | |||
| 2164cd537a | |||
| ab75911b72 | |||
| 75a8e40587 | |||
| a6ffe05150 | |||
| cd62de1d38 | |||
| c721464af2 | |||
| 614289a350 | |||
| ae8fecb538 | |||
| 0e4eb9bf9e | |||
| 2eb3be8b74 | |||
| e307a8e689 | |||
| 01ec4d3d4e | |||
| b1d7822f7e | |||
| 2d3c09f302 |
@@ -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)"
|
||||
@@ -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)"
|
||||
@@ -19,6 +19,12 @@
|
||||
# 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.
|
||||
#
|
||||
# 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
|
||||
|
||||
REGISTRY="${REGISTRY:-localhost:3002/schalli/tessera-ctl}"
|
||||
@@ -54,6 +60,11 @@ if [ "${1:-}" = "--print-plan" ]; then
|
||||
exit 0
|
||||
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
|
||||
docker build -t "$REGISTRY/$IMG:$APP_CHANNEL" \
|
||||
--build-arg APP_VERSION="$APP_VERSION" \
|
||||
|
||||
@@ -21,9 +21,21 @@
|
||||
# sonst http://localhost:3002/api/v1 (nur lokal erreichbar).
|
||||
# GITEA_REPO owner/repo; sonst GITHUB_REPOSITORY, sonst schalli/tessera-ctl.
|
||||
# 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
|
||||
# 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
|
||||
|
||||
usage() {
|
||||
@@ -101,11 +113,21 @@ UPDATE_JSON=$(jq -n --arg name "$NAME" --arg body "$BODY" '{name: $name, body: $
|
||||
RELEASES_URL="$API/repos/$REPO/releases"
|
||||
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
|
||||
echo "Probelauf (kein Netzaufruf):"
|
||||
echo " POST $RELEASES_URL"
|
||||
echo " PATCH $RELEASES_URL/<id> (falls GET $TAG_URL bereits 200 liefert)"
|
||||
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
|
||||
fi
|
||||
|
||||
@@ -118,9 +140,46 @@ umask 077
|
||||
TMPDIR_REL=$(mktemp -d)
|
||||
trap 'rm -rf "$TMPDIR_REL"' EXIT INT TERM
|
||||
HDR="$TMPDIR_REL/headers"
|
||||
HDR_AUTH="$TMPDIR_REL/headers-auth"
|
||||
RESP="$TMPDIR_REL/response.json"
|
||||
ASSETS_RESP="$TMPDIR_REL/assets.json"
|
||||
JSONFILE="$TMPDIR_REL/payload.json"
|
||||
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")
|
||||
case "$CODE" in
|
||||
@@ -140,7 +199,8 @@ case "$CODE" in
|
||||
printf '%s' "$CREATE_JSON" > "$JSONFILE"
|
||||
CODE=$(curl -sS --header @"$HDR" -X POST --data @"$JSONFILE" -o "$RESP" -w '%{http_code}' "$RELEASES_URL")
|
||||
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
|
||||
echo "POST $RELEASES_URL antwortete mit $CODE:" >&2
|
||||
cat "$RESP" >&2
|
||||
@@ -153,3 +213,16 @@ case "$CODE" in
|
||||
exit 1
|
||||
;;
|
||||
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
|
||||
|
||||
@@ -2,6 +2,13 @@
|
||||
# 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.
|
||||
# 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
|
||||
|
||||
on:
|
||||
@@ -52,16 +59,111 @@ jobs:
|
||||
- name: Run tests
|
||||
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:
|
||||
name: Build & Publish Images
|
||||
runs-on: ubuntu-latest
|
||||
needs: test
|
||||
needs: desktop
|
||||
steps:
|
||||
# Ohne volle Historie und Tags liefert `git describe` nichts -- Pflicht fuer den Stempel.
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
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
|
||||
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login localhost:3002 -u ${{ gitea.actor }} --password-stdin
|
||||
|
||||
|
||||
@@ -39,3 +39,7 @@ user-files/
|
||||
|
||||
# GSD runtime scratch (Dispatch-Sentinel, pro Sitzung neu geschrieben)
|
||||
.gsd/
|
||||
|
||||
# Desktop-Pakete aus dem Bau (Phase 18)
|
||||
desktop-dist/*
|
||||
!desktop-dist/.gitkeep
|
||||
|
||||
@@ -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-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.
|
||||
|
||||
### 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
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
---
|
||||
gsd_state_version: "1.0"
|
||||
milestone: v1.2
|
||||
current_phase: 17
|
||||
current_phase_name: eigene-ausschreibungs-quellen-je-nutzer
|
||||
current_phase: 18
|
||||
current_phase_name: desktop-client-fertigstellen
|
||||
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"
|
||||
last_updated: "2026-09-16T10:15:09.000Z"
|
||||
stopped_at: Completed 18-04-PLAN.md
|
||||
last_updated: "2026-09-16T14:43:25.735Z"
|
||||
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
|
||||
state_head: c5f4adeeed4bc858171323a5b4c3866ee991f560
|
||||
state_head: 289604a28e326f9909963922ca938c6a8fa7d661
|
||||
progress:
|
||||
total_phases: 17
|
||||
total_phases: 18
|
||||
completed_phases: 15
|
||||
total_plans: 83
|
||||
completed_plans: 82
|
||||
total_plans: 89
|
||||
completed_plans: 86
|
||||
milestone_name: Plattform-Berechtigungen
|
||||
---
|
||||
|
||||
@@ -28,12 +28,12 @@ See: .planning/PROJECT.md (updated 2026-07-17)
|
||||
|
||||
## Current Position
|
||||
|
||||
Phase: 17 (eigene-ausschreibungs-quellen-je-nutzer) — VERIFIED / passed
|
||||
Plan: 3 of 3
|
||||
Status: Phase abgeschlossen und im Browser gegengeprueft — bereit fuer /gsd-ship
|
||||
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
|
||||
Phase: 18 (desktop-client-fertigstellen) — IN PROGRESS
|
||||
Plan: 4 of 6 (18-02 abgeschlossen)
|
||||
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 - 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
|
||||
|
||||
@@ -123,11 +123,17 @@ Progress: [██████████] 100%
|
||||
| Phase quick-260911-nke P01 | 1 Sitzung | 3 tasks | 27 files |
|
||||
| Phase quick-260914-ebg P01 | 6min | 3 tasks | 4 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
|
||||
|
||||
### 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 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]: [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 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
|
||||
|
||||
@@ -457,8 +467,8 @@ sind. Kein Anlass, sie vorher erneut vorzulegen.
|
||||
|
||||
## 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.
|
||||
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`.
|
||||
Resume file: .planning/HANDOFF.json + .planning/.continue-here.md (2026-09-16T10:28Z)
|
||||
Stopped at: Completed 18-04-PLAN.md
|
||||
Resume file: None
|
||||
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
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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/packages/shared/src ./packages/shared/src
|
||||
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
|
||||
EXPOSE 3001
|
||||
CMD ["sh", "apps/api/scripts/migrate-and-start.sh"]
|
||||
|
||||
@@ -7,6 +7,7 @@ import { BugReportsModule } from './bug-reports/bug-reports.module';
|
||||
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from './auth/guards/roles.guard';
|
||||
import { ForcePasswordChangeInterceptor } from './auth/interceptors/force-password-change.interceptor';
|
||||
import { DesktopModule } from './desktop/desktop.module';
|
||||
import { HealthModule } from './health/health.module';
|
||||
import { LdapModule } from './ldap/ldap.module';
|
||||
import { MailModule } from './mail/mail.module';
|
||||
@@ -36,6 +37,7 @@ import { UserModule } from './user/user.module';
|
||||
UserModule,
|
||||
TenantModule,
|
||||
HealthModule,
|
||||
DesktopModule,
|
||||
MailModule,
|
||||
LdapModule,
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@tessera/desktop",
|
||||
"version": "0.0.1",
|
||||
"version": "1.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"tauri": "tauri",
|
||||
|
||||
@@ -1820,6 +1820,25 @@ version = "2.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
@@ -2410,6 +2429,17 @@ version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "openssl"
|
||||
version = "0.10.81"
|
||||
@@ -3818,6 +3848,28 @@ dependencies = [
|
||||
"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]]
|
||||
name = "tauri-plugin-store"
|
||||
version = "2.4.3"
|
||||
@@ -3986,7 +4038,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tessera-desktop"
|
||||
version = "0.0.1"
|
||||
version = "1.1.0"
|
||||
dependencies = [
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
@@ -3995,6 +4047,7 @@ dependencies = [
|
||||
"tauri-build",
|
||||
"tauri-plugin-autostart",
|
||||
"tauri-plugin-notification",
|
||||
"tauri-plugin-opener",
|
||||
"tauri-plugin-store",
|
||||
"tauri-plugin-window-state",
|
||||
]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "tessera-desktop"
|
||||
version = "0.0.1"
|
||||
version = "1.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
@@ -19,3 +19,4 @@ tauri-plugin-window-state = "2"
|
||||
reqwest = { version = "0.12", features = ["json"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tauri-plugin-opener = "2"
|
||||
|
||||
|
Before Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 8.8 KiB |
@@ -13,6 +13,7 @@
|
||||
"autostart:allow-enable",
|
||||
"autostart:allow-disable",
|
||||
"autostart:allow-is-enabled",
|
||||
"window-state:default"
|
||||
"window-state:default",
|
||||
{ "identifier": "opener:allow-open-url", "allow": [{ "url": "https://*" }, { "url": "http://*" }] }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
"type": "object",
|
||||
"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": {
|
||||
"identifier": {
|
||||
@@ -2432,6 +2600,54 @@
|
||||
"const": "notification:deny-show",
|
||||
"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`",
|
||||
"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.",
|
||||
"type": "object",
|
||||
"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": {
|
||||
"identifier": {
|
||||
@@ -2432,6 +2600,54 @@
|
||||
"const": "notification:deny-show",
|
||||
"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`",
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 361 B After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 105 B After Width: | Height: | Size: 33 KiB |
@@ -1,11 +1,12 @@
|
||||
use std::time::Duration;
|
||||
use tauri::{
|
||||
Manager,
|
||||
menu::{MenuBuilder, MenuItemBuilder},
|
||||
menu::{CheckMenuItemBuilder, MenuBuilder, MenuItemBuilder},
|
||||
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_opener::OpenerExt;
|
||||
use tauri_plugin_store::StoreExt;
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
@@ -13,12 +14,77 @@ struct VersionResponse {
|
||||
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() {
|
||||
let app = tauri::Builder::default()
|
||||
.plugin(tauri_plugin_store::Builder::new().build())
|
||||
.plugin(tauri_plugin_notification::init())
|
||||
.plugin(tauri_plugin_window_state::Builder::default().build())
|
||||
.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| {
|
||||
let store = app.store("config.json")?;
|
||||
let server_url = store.get("server_url");
|
||||
@@ -38,27 +104,71 @@ pub fn run() {
|
||||
}
|
||||
}
|
||||
|
||||
// Tray menu
|
||||
let open = MenuItemBuilder::with_id("open", "Oeffnen").build(app)?;
|
||||
// Tray menu: Öffnen · Update herunterladen · — · Autostart-Haken · — · Beenden.
|
||||
// "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 menu = MenuBuilder::new(app)
|
||||
.item(&open)
|
||||
.item(&update)
|
||||
.separator()
|
||||
.item(&autostart)
|
||||
.separator()
|
||||
.item(&quit)
|
||||
.build()?;
|
||||
|
||||
let server_for_menu = url_for_check.clone();
|
||||
let autostart_for_menu = autostart.clone();
|
||||
|
||||
let _tray = TrayIconBuilder::new()
|
||||
.icon(app.default_window_icon().unwrap().clone())
|
||||
.menu(&menu)
|
||||
.tooltip("Tessera")
|
||||
.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" => {
|
||||
if let Some(w) = app.get_webview_window("main") {
|
||||
let _ = w.show();
|
||||
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" => {
|
||||
app.exit(0);
|
||||
}
|
||||
@@ -83,17 +193,24 @@ pub fn run() {
|
||||
if let Some(server_url) = url_for_check {
|
||||
let app_handle = app.handle().clone();
|
||||
let app_version = env!("CARGO_PKG_VERSION").to_string();
|
||||
let update_item = update.clone();
|
||||
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(info) = resp.json::<VersionResponse>().await {
|
||||
if let Ok(info) = resp.json::<DesktopLatest>().await {
|
||||
if info.version != app_version {
|
||||
let _ = app_handle
|
||||
.notification()
|
||||
.builder()
|
||||
.title("Tessera Update")
|
||||
.body("Eine neue Version ist verfuegbar.")
|
||||
.title("Tessera-Update")
|
||||
.body(format!(
|
||||
"Neue Version {} verfügbar – Download über das Symbol im Infobereich.",
|
||||
info.version
|
||||
))
|
||||
.show();
|
||||
let _ = update_item
|
||||
.set_text(format!("Version {} herunterladen", info.version));
|
||||
let _ = update_item.set_enabled(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-config-schema/schema.json",
|
||||
"productName": "Tessera",
|
||||
"version": "0.0.1",
|
||||
"version": "1.1.0",
|
||||
"identifier": "de.ctl.tessera.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../src",
|
||||
@@ -21,12 +21,21 @@
|
||||
}
|
||||
],
|
||||
"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": {
|
||||
"active": true,
|
||||
"targets": ["appimage", "nsis"],
|
||||
"icon": ["icons/icon.png", "icons/icon.ico"]
|
||||
"targets": [
|
||||
"appimage",
|
||||
"nsis"
|
||||
],
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.png",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Tessera - Setup</title>
|
||||
<title>Tessera – Desktop-App einrichten</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
@@ -28,16 +28,22 @@
|
||||
max-width: 440px;
|
||||
width: 100%;
|
||||
padding: 48px 32px;
|
||||
background: oklch(0.23 0.01 260);
|
||||
background: oklch(0.22 0.01 260);
|
||||
border-radius: 12px;
|
||||
border: 1px solid oklch(0.30 0.01 260);
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
margin: 0 auto 16px;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 8px;
|
||||
color: oklch(0.91 0.19 102);
|
||||
color: #ffed00;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
@@ -55,6 +61,13 @@
|
||||
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"] {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
@@ -69,7 +82,7 @@
|
||||
}
|
||||
|
||||
input[type="url"]:focus {
|
||||
border-color: oklch(0.91 0.19 102);
|
||||
border-color: #9c9440;
|
||||
}
|
||||
|
||||
input[type="url"]::placeholder {
|
||||
@@ -92,14 +105,22 @@
|
||||
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 {
|
||||
margin-top: 24px;
|
||||
width: 100%;
|
||||
padding: 12px 32px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
background: oklch(0.91 0.19 102);
|
||||
color: oklch(0.20 0.02 90);
|
||||
background: #ffed00;
|
||||
color: #1a1a1a;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
@@ -123,31 +144,43 @@
|
||||
</head>
|
||||
<body>
|
||||
<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>
|
||||
<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
|
||||
id="server-url"
|
||||
type="url"
|
||||
placeholder="https://tessera.example.com"
|
||||
value="http://localhost:3000"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
<p id="error-msg" class="error-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>
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
import { load } from '@tauri-apps/plugin-store';
|
||||
const { invoke } = window.__TAURI__.core;
|
||||
|
||||
const urlInput = document.getElementById('server-url');
|
||||
const connectBtn = document.getElementById('connect-btn');
|
||||
const errorMsg = document.getElementById('error-msg');
|
||||
const warningMsg = document.getElementById('warning-msg');
|
||||
const infoMsg = document.getElementById('info-msg');
|
||||
|
||||
/**
|
||||
* Validate a server URL using the URL constructor.
|
||||
@@ -157,19 +190,23 @@
|
||||
const trimmed = input.trim();
|
||||
|
||||
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;
|
||||
try {
|
||||
parsed = new URL(trimmed);
|
||||
} 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
|
||||
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)
|
||||
@@ -177,7 +214,7 @@
|
||||
if (parsed.protocol === 'http:' && !isLocalhost) {
|
||||
return {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -189,17 +226,26 @@
|
||||
errorMsg.textContent = message;
|
||||
errorMsg.style.display = 'block';
|
||||
warningMsg.style.display = 'none';
|
||||
infoMsg.style.display = 'none';
|
||||
}
|
||||
|
||||
function showWarning(message) {
|
||||
warningMsg.textContent = message;
|
||||
warningMsg.style.display = 'block';
|
||||
errorMsg.style.display = 'none';
|
||||
infoMsg.style.display = 'none';
|
||||
}
|
||||
|
||||
function showInfo(message) {
|
||||
infoMsg.textContent = message;
|
||||
infoMsg.style.display = 'block';
|
||||
errorMsg.style.display = 'none';
|
||||
}
|
||||
|
||||
function clearMessages() {
|
||||
errorMsg.style.display = 'none';
|
||||
warningMsg.style.display = 'none';
|
||||
infoMsg.style.display = 'none';
|
||||
}
|
||||
|
||||
async function connect() {
|
||||
@@ -220,18 +266,26 @@
|
||||
// Normalize the URL
|
||||
const normalizedUrl = new URL(rawUrl.trim()).href;
|
||||
|
||||
// Disable button during save
|
||||
// Disable button while the address is being checked
|
||||
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 {
|
||||
const store = await load('config.json', { autoSave: true });
|
||||
await store.set('server_url', normalizedUrl);
|
||||
|
||||
// Navigate the WebView to the configured server
|
||||
window.location.href = normalizedUrl;
|
||||
await invoke('save_server_url', { url: normalizedUrl });
|
||||
} catch (err) {
|
||||
showError('Fehler beim Speichern der Konfiguration: ' + (err.message || err));
|
||||
showError('Die Adresse konnte nicht gespeichert werden: ' + String(err));
|
||||
connectBtn.disabled = false;
|
||||
connectBtn.textContent = 'Verbinden';
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import Link from 'next/link';
|
||||
import { login } from '@/lib/auth-actions';
|
||||
import { BRAND_YELLOW } from '@/components/brand/brand';
|
||||
import { TesseraLogo } from '@/components/brand/tessera-logo';
|
||||
import { DesktopDownloadLinks } from '@/components/desktop/desktop-download-links';
|
||||
|
||||
/**
|
||||
* Split-screen login page (D-01).
|
||||
@@ -181,6 +182,9 @@ export default function LoginPage() {
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Desktop-App-Link (D-12), blendet sich aus ohne API-Antwort */}
|
||||
<DesktopDownloadLinks />
|
||||
</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')}
|
||||
</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>
|
||||
|
||||
{/* Dashboard category — existing items unchanged */}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -34,6 +34,12 @@
|
||||
"branding": {
|
||||
"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": {
|
||||
"title": "Passwort zurücksetzen",
|
||||
"email": "E-Mail-Adresse",
|
||||
@@ -126,6 +132,7 @@
|
||||
"categoryCalendar": "Kalender",
|
||||
"categoryGeneral": "Allgemein",
|
||||
"categoryAccount": "Konto",
|
||||
"categoryDesktopApp": "Desktop-App",
|
||||
"categorySmtp": "SMTP",
|
||||
"account": {
|
||||
"title": "Konto",
|
||||
@@ -146,6 +153,19 @@
|
||||
"deleteAvatarSuccess": "Profilbild erfolgreich gelöscht.",
|
||||
"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?",
|
||||
"sourceDeleteCta": "Quelle löschen",
|
||||
"providerDeleteConfirm": "Möchten Sie diesen Suchanbieter wirklich löschen?",
|
||||
|
||||
@@ -34,6 +34,12 @@
|
||||
"branding": {
|
||||
"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": {
|
||||
"title": "Reset Password",
|
||||
"email": "Email address",
|
||||
@@ -126,6 +132,7 @@
|
||||
"categoryCalendar": "Calendar",
|
||||
"categoryGeneral": "General",
|
||||
"categoryAccount": "Account",
|
||||
"categoryDesktopApp": "Desktop App",
|
||||
"categorySmtp": "SMTP",
|
||||
"account": {
|
||||
"title": "Account",
|
||||
@@ -146,6 +153,19 @@
|
||||
"deleteAvatarSuccess": "Profile picture deleted successfully.",
|
||||
"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?",
|
||||
"sourceDeleteCta": "Delete source",
|
||||
"providerDeleteConfirm": "Are you sure you want to delete this search provider?",
|
||||
|
||||
@@ -114,6 +114,7 @@ export const UMLAUT_ALLOWLIST: readonly string[] = [
|
||||
'Energiequellen',
|
||||
'neue',
|
||||
'neuen',
|
||||
'neuere',
|
||||
'Neue',
|
||||
'Neues',
|
||||
'aktuelle',
|
||||
|
||||
@@ -17,3 +17,37 @@ export interface VersionResponse {
|
||||
commit: 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>>;
|
||||
}
|
||||
|
||||