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