Files

31 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, user_setup, estimate, must_haves
phase plan type wave depends_on files_modified autonomous requirements user_setup estimate must_haves
18-desktop-client-fertigstellen 01 execute 1
.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
true
DESK-01
DESK-03
DESK-05
tokens raw_tokens tasks confidence
78000 78000 2 low
truths artifacts key_links
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).
path provides contains
.gitea/scripts/desktop-version.sh Version aus dem letzten Tag in tauri.conf.json und Cargo.toml schreiben (D-07) git describe --tags
path provides contains
.gitea/scripts/desktop-collect.sh Pakete unter kanonischen Namen einsammeln, Groesse und SHA-256 berechnen, manifest.json schreiben (D-08) manifest.json
path provides contains
apps/api/src/desktop/desktop.service.ts Manifest lesen, Plattform-Whitelist, Datei-Stream (D-10) PLATFORMS
path provides exports
apps/api/src/desktop/desktop.controller.ts GET /desktop/latest und GET /desktop/download/:platform, beide @Public()
DesktopController
path provides min_lines
apps/api/src/desktop/desktop.service.spec.ts HTTP-Durchstich ueber NestFactory: Manifest vorhanden/fehlt, Whitelist, Traversal, @Public() 80
path provides contains
apps/api/Dockerfile COPY desktop-dist nach /app/desktop-dist desktop-dist
from to via pattern
.gitea/scripts/desktop-collect.sh apps/api/src/desktop/desktop.service.ts manifest.json (version, channel, commit, buildTime, files.{windows,linux}.{name,size,sha256}) — die API liest ausschliesslich diese Datei manifest.json
from to via pattern
apps/api/Dockerfile apps/api/src/desktop/desktop.service.ts COPY desktop-dist ./desktop-dist — vier Ebenen ueber apps/api/dist/desktop/ liegt /app/desktop-dist 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

<execution_context> @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md </execution_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

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<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. <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> cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/api exec vitest run src/desktop && pnpm --filter @tessera/api type-check <fails_when>vitest meldet "failed" oder einen Exit-Code ungleich 0, oder tsc gibt Fehlerzeilen aus.</fails_when> 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 <fails_when>Das Skript endet mit Exit 1, manifest.json fehlt, oder die Zeile MANIFEST-OK erscheint nicht (Hash-Abweichung).</fails_when> 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 <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> 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. <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> 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 <fails_when>Eine der Pruefungen schlaegt fehl und VERSION-OK erscheint nicht — Skript, Basislinie oder Manifest tragen nicht 1.1.0.</fails_when> 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 <fails_when>Das Skript akzeptiert v1.2.3-beta (Exit 0) oder hat trotz --print die Datei veraendert — REJECT-OK fehlt.</fails_when> cd /home/vicolab/projects/tessera-ctl/apps/desktop/src-tauri && cargo check 2>&1 | tail -1 | grep -q 'Finished' <fails_when>cargo check endet nicht mit einer Finished-Zeile (Kompilierfehler nach der Versionsaenderung).</fails_when> Skript, Basislinie 1.1.0 in allen vier Dateien, cargo check gruen, und desktop-dist/ traegt Tessera-1.1.0.AppImage samt Manifest.

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

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