From 2eb3be8b74b61308a24e6ee4597cc228f5a13f37 Mon Sep 17 00:00:00 2001 From: Schalli Date: Wed, 16 Sep 2026 15:53:45 +0200 Subject: [PATCH] =?UTF-8?q?docs(18):=20Phase=2018=20geplant=20=E2=80=94=20?= =?UTF-8?q?sechs=20Plaene=20(Tracer=20Linux-Strecke,=20Pipeline,=20Web,=20?= =?UTF-8?q?Client,=20Windows-Cross-Bau=20mit=20Iterationsschleife,=20Absch?= =?UTF-8?q?luss),=20Validierungsplan,=20API-Coverage,=20Fahrplan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .planning/ROADMAP.md | 9 +- .../18-desktop-client-fertigstellen/.gitkeep | 1 + .../18-01-PLAN.md | 465 +++++++++++ .../18-02-PLAN.md | 290 +++++++ .../18-03-PLAN.md | 372 +++++++++ .../18-04-PLAN.md | 409 ++++++++++ .../18-05-PLAN.md | 308 +++++++ .../18-06-PLAN.md | 446 +++++++++++ .../18-COVERAGE.md | 27 + .../18-PATTERNS.md | 755 ++++++++++++++++++ .../18-VALIDATION.md | 56 +- 11 files changed, 3117 insertions(+), 21 deletions(-) create mode 100644 .planning/phases/18-desktop-client-fertigstellen/.gitkeep create mode 100644 .planning/phases/18-desktop-client-fertigstellen/18-01-PLAN.md create mode 100644 .planning/phases/18-desktop-client-fertigstellen/18-02-PLAN.md create mode 100644 .planning/phases/18-desktop-client-fertigstellen/18-03-PLAN.md create mode 100644 .planning/phases/18-desktop-client-fertigstellen/18-04-PLAN.md create mode 100644 .planning/phases/18-desktop-client-fertigstellen/18-05-PLAN.md create mode 100644 .planning/phases/18-desktop-client-fertigstellen/18-06-PLAN.md create mode 100644 .planning/phases/18-desktop-client-fertigstellen/18-COVERAGE.md create mode 100644 .planning/phases/18-desktop-client-fertigstellen/18-PATTERNS.md diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 48b1386..c0c3834 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -665,8 +665,13 @@ Plans (Wellenstruktur — streng nacheinander, alle drei fassen Schema, Controll 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:** 0 plans +**Plans:** 6 plans Plans: -- [ ] TBD (run /gsd-plan-phase 18 to break down) +- [ ] 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 +- [ ] 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 +- [ ] 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 +- [ ] 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 +- [ ] 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 +- [ ] 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 diff --git a/.planning/phases/18-desktop-client-fertigstellen/.gitkeep b/.planning/phases/18-desktop-client-fertigstellen/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/.planning/phases/18-desktop-client-fertigstellen/.gitkeep @@ -0,0 +1 @@ + diff --git a/.planning/phases/18-desktop-client-fertigstellen/18-01-PLAN.md b/.planning/phases/18-desktop-client-fertigstellen/18-01-PLAN.md new file mode 100644 index 0000000..1d7c89d --- /dev/null +++ b/.planning/phases/18-desktop-client-fertigstellen/18-01-PLAN.md @@ -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" +--- + + +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`. + + +## 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` + + +@$HOME/.claude/gsd-core/workflows/execute-plan.md +@$HOME/.claude/gsd-core/templates/summary.md + + + +@.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 + + + + + + Task 1: Ein Linux-Paket aus dem Bau bis zum Download aus der API — eine Strecke + 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). + 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. + + .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 + + + .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 + + + - `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. + + +**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> }`; +`DesktopLatestFile extends DesktopManifestFile { url: string }`; +`DesktopLatestResponse` mit denselben vier Kopf-Feldern und +`files: Partial>`. `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 ``; 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 ``; 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. + + + - `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. + + + cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/api exec vitest run src/desktop && pnpm --filter @tessera/api type-check + vitest meldet "failed" oder einen Exit-Code ungleich 0, oder tsc gibt Fehlerzeilen aus. + 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 + Das Skript endet mit Exit 1, `manifest.json` fehlt, oder die Zeile `MANIFEST-OK` erscheint nicht (Hash-Abweichung). + 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 + 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. + + +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. + + + + + Task 2: Die Version kommt aus dem Freigabe-Tag — Skript und Basislinie 1.1.0 + + .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 + + + .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 + + +**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`. + + + - `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`). + + + 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 + Eine der Pruefungen schlaegt fehl und `VERSION-OK` erscheint nicht — Skript, Basislinie oder Manifest tragen nicht `1.1.0`. + 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 + Das Skript akzeptiert `v1.2.3-beta` (Exit 0) oder hat trotz `--print` die Datei veraendert — `REJECT-OK` fehlt. + cd /home/vicolab/projects/tessera-ctl/apps/desktop/src-tauri && cargo check 2>&1 | tail -1 | grep -q 'Finished' + `cargo check` endet nicht mit einer `Finished`-Zeile (Kompilierfehler nach der Versionsaenderung). + + +Skript, Basislinie `1.1.0` in allen vier Dateien, `cargo check` gruen, und +`desktop-dist/` traegt `Tessera-1.1.0.AppImage` samt Manifest. + + + + + + +## 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). | + + + +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. + + + +- 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`. + + + +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. + diff --git a/.planning/phases/18-desktop-client-fertigstellen/18-02-PLAN.md b/.planning/phases/18-desktop-client-fertigstellen/18-02-PLAN.md new file mode 100644 index 0000000..d835cc5 --- /dev/null +++ b/.planning/phases/18-desktop-client-fertigstellen/18-02-PLAN.md @@ -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\\[\\]" +--- + + +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. + + +## 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. + + +@$HOME/.claude/gsd-core/workflows/execute-plan.md +@$HOME/.claude/gsd-core/templates/summary.md + + + +@.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 + + + + + + Task 1: Job desktop (Linux-AppImage) und Uebergabe an publish per actions/cache + Job-Aufbau und Cache-Schluessel lassen sich jederzeit aendern; kein Zustand ausserhalb des Runners. + + .gitea/workflows/ci.yml, + .gitea/scripts/publish-images.sh + + + .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") + + +**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 `` 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. + + + - `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. + + + 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 + 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. + 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 + Syntaxfehler, weniger als vier push-Zeilen im Probelauf, oder die Manifest-Pruefung fehlt im Skript — `IMAGES-OK` fehlt. + + +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. + + + + + Task 2: Release-Dateien idempotent an den Gitea-Release haengen + 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). + + .gitea/scripts/publish-release.sh + + + .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) + + +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. + + + - `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. + + + 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 + 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. + + +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. + + + + + + +## 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. | + + + +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). + + + +- 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. + + + +Create `.planning/phases/18-desktop-client-fertigstellen/18-02-SUMMARY.md` when done. + diff --git a/.planning/phases/18-desktop-client-fertigstellen/18-03-PLAN.md b/.planning/phases/18-desktop-client-fertigstellen/18-03-PLAN.md new file mode 100644 index 0000000..6f72282 --- /dev/null +++ b/.planning/phases/18-desktop-client-fertigstellen/18-03-PLAN.md @@ -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" +--- + + +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. + + +## 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. + + +@$HOME/.claude/gsd-core/workflows/execute-plan.md +@$HOME/.claude/gsd-core/templates/summary.md + + + +@.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 + + + + + + Task 1: Fetch-Helfer und der Download-Link auf der Anmeldeseite + + 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 + + + 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) + + + - `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. + + +**`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> }`. +`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 `` — 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 `
` +mit: Hauptlink (Windows, falls vorhanden, sonst Linux) als `` +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 `` innerhalb des `max-w-sm space-y-8`-Blocks +`` 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}". + + + - `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. + + + 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 + vitest meldet "failed" oder Exit-Code ungleich 0, oder tsc gibt Fehlerzeilen aus. + + +Acht Tests gruen, Typpruefung fehlerfrei, die Anmeldeseite baut den +Link-Block ein, de/en tragen den Block `auth.desktopDownload`. + + + + + Task 2: Einstellungsseite "Desktop-App" mit Knoepfen, Groesse und Erklaerung + + 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 + + + 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`) + + + - 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. + + +**Seite `app/(portal)/settings/general/desktop/page.tsx`**: exakt die +Huelle von `account/page.tsx` (`'use client'`, `useTranslations('settings')`, +`

` mit `t('desktop.title')`), Inhalt ``. 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 `

` je Satz, `text-sm text-muted-foreground`); +dann bei Daten: `

` mit `t('desktop.versionLabel', { version })` und, wenn +`channel === 'beta'`, `t('desktop.channelBeta', { commit })`; dann ein +`

` mit je Plattform (nur vorhandene, +Reihenfolge Windows, Linux) einem Block aus `` 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 `

` mit +`t('desktop.fileInfo', { name, size: formatFileSize(size, locale) })`. Bei +`null`: `

` 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 `