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