466 lines
31 KiB
Markdown
466 lines
31 KiB
Markdown
---
|
|
phase: 18-desktop-client-fertigstellen
|
|
plan: 01
|
|
type: execute
|
|
wave: 1
|
|
depends_on: []
|
|
files_modified:
|
|
- .gitea/scripts/desktop-collect.sh
|
|
- .gitea/scripts/desktop-version.sh
|
|
- .gitignore
|
|
- desktop-dist/.gitkeep
|
|
- apps/api/Dockerfile
|
|
- apps/api/src/app.module.ts
|
|
- apps/api/src/desktop/desktop.controller.ts
|
|
- apps/api/src/desktop/desktop.module.ts
|
|
- apps/api/src/desktop/desktop.service.spec.ts
|
|
- apps/api/src/desktop/desktop.service.ts
|
|
- apps/desktop/package.json
|
|
- apps/desktop/src-tauri/Cargo.lock
|
|
- apps/desktop/src-tauri/Cargo.toml
|
|
- apps/desktop/src-tauri/tauri.conf.json
|
|
- packages/shared/src/index.ts
|
|
autonomous: true
|
|
requirements: [DESK-01, DESK-03, DESK-05]
|
|
user_setup: []
|
|
|
|
estimate:
|
|
tokens: 78000
|
|
raw_tokens: 78000
|
|
tasks: 2
|
|
confidence: low
|
|
|
|
must_haves:
|
|
truths:
|
|
- "GET /desktop/latest antwortet ohne Anmeldung mit Version, Kanal und Dateiliste aus manifest.json; fehlt das Manifest, antwortet die API mit 404 (D-10)."
|
|
- "GET /desktop/download/linux streamt die Datei mit Content-Disposition: attachment und dem Dateinamen aus dem Manifest; eine unbekannte Plattform endet mit 400, bevor das Dateisystem beruehrt wird (D-10)."
|
|
- "Das API-Abbild traegt /app/desktop-dist/ mit Paketen und manifest.json; ein lokal neu gebautes Abbild liefert das lokal gebaute AppImage ueber die API aus (D-08)."
|
|
- "desktop-version.sh schreibt die Version des letzten Freigabe-Tags als reines X.Y.Z in tauri.conf.json und Cargo.toml; Beta-Laeufe haengen den Commit-Stempel nur an den Dateinamen und ins Manifest (D-07)."
|
|
artifacts:
|
|
- path: ".gitea/scripts/desktop-version.sh"
|
|
provides: "Version aus dem letzten Tag in tauri.conf.json und Cargo.toml schreiben (D-07)"
|
|
contains: "git describe --tags"
|
|
- path: ".gitea/scripts/desktop-collect.sh"
|
|
provides: "Pakete unter kanonischen Namen einsammeln, Groesse und SHA-256 berechnen, manifest.json schreiben (D-08)"
|
|
contains: "manifest.json"
|
|
- path: "apps/api/src/desktop/desktop.service.ts"
|
|
provides: "Manifest lesen, Plattform-Whitelist, Datei-Stream (D-10)"
|
|
contains: "PLATFORMS"
|
|
- path: "apps/api/src/desktop/desktop.controller.ts"
|
|
provides: "GET /desktop/latest und GET /desktop/download/:platform, beide @Public()"
|
|
exports: ["DesktopController"]
|
|
- path: "apps/api/src/desktop/desktop.service.spec.ts"
|
|
provides: "HTTP-Durchstich ueber NestFactory: Manifest vorhanden/fehlt, Whitelist, Traversal, @Public()"
|
|
min_lines: 80
|
|
- path: "apps/api/Dockerfile"
|
|
provides: "COPY desktop-dist nach /app/desktop-dist"
|
|
contains: "desktop-dist"
|
|
key_links:
|
|
- from: ".gitea/scripts/desktop-collect.sh"
|
|
to: "apps/api/src/desktop/desktop.service.ts"
|
|
via: "manifest.json (version, channel, commit, buildTime, files.{windows,linux}.{name,size,sha256}) — die API liest ausschliesslich diese Datei"
|
|
pattern: "manifest\\.json"
|
|
- from: "apps/api/Dockerfile"
|
|
to: "apps/api/src/desktop/desktop.service.ts"
|
|
via: "COPY desktop-dist ./desktop-dist — vier Ebenen ueber apps/api/dist/desktop/ liegt /app/desktop-dist"
|
|
pattern: "desktop-dist"
|
|
---
|
|
|
|
<objective>
|
|
Die duenne, aber vollstaendige Bahn dieser Phase: Ein Linux-AppImage aus dem
|
|
Tauri-Bau bekommt die Freigabe-Version, wird unter kanonischem Namen samt
|
|
`manifest.json` eingesammelt, landet im API-Abbild unter `/app/desktop-dist/`
|
|
und wird von der API ueber `GET /desktop/latest` und
|
|
`GET /desktop/download/linux` ohne Anmeldung ausgeliefert — lokal bewiesen
|
|
mit dem echten Docker-Stack. Der CI-Job `desktop`, die Uebergabe an `publish`
|
|
und der Release-Upload folgen in 18-02; der Windows-Cross-Bau (18-05), die
|
|
Web-Oberflaeche (18-03) und der Client (18-04) bauen daneben auf dieser
|
|
bewiesenen Strecke auf.
|
|
|
|
Purpose: D-07, D-08 (Abbild-Seite) und D-10 aus 18-CONTEXT.md umsetzen und
|
|
die Architektur (Skript -> Abbild -> API) einmal durchgehend beweisen, bevor
|
|
die breiteren Plaene folgen.
|
|
Output: Zwei CI-Skripte, das API-Modul `apps/api/src/desktop/` mit
|
|
HTTP-Durchstich-Spec, geteilte Typen, Dockerfile-Erweiterung, Basislinie
|
|
`1.1.0`.
|
|
|
|
**Kein Datenbank-Schema betroffen:** Diese Phase aendert weder
|
|
`schema.prisma` noch Migrationen — kein Schema-Push noetig.
|
|
|
|
**Identitaetsfrage (Plattform):** `platform` ist ein geschlossener Wertevorrat
|
|
`'windows' | 'linux'` (Typ `DesktopPlatform` in `packages/shared`, Konstante
|
|
`PLATFORMS` im Dienst), kein freier String. Eine dritte Plattform waere eine
|
|
bewusste Erweiterung an genau diesen zwei Stellen.
|
|
|
|
**Externe Schnittstellen (Gitea REST, einzige in dieser Phase):** Bereits in
|
|
Gebrauch: `GET /repos/{owner}/{repo}/releases/tags/{tag}`, `POST .../releases`,
|
|
`PATCH .../releases/{id}`. Neu in diesem Plan:
|
|
`GET /repos/{owner}/{repo}/releases/{id}/assets`,
|
|
`DELETE /repos/{owner}/{repo}/releases/{id}/assets/{asset_id}`,
|
|
`POST /repos/{owner}/{repo}/releases/{id}/assets?name={name}` (multipart-Feld
|
|
`attachment`). Keine weitere Gitea-Faehigkeit ist im Umfang. Am 2026-09-16
|
|
gegen die laufende Instanz geprueft: Gitea 1.26.2; Release-Anhaenge sind
|
|
standardmaessig ohne Typ-Beschraenkung und bis 2048 MB erlaubt
|
|
(`[repository.release]`, Voreinstellung).
|
|
|
|
**Vom Client aus ist die API nur ueber den Web-Ursprung erreichbar:** Im
|
|
Betrieb steht die API nicht unter dem Web-Hostnamen, sondern hinter dem
|
|
Next.js-Rewrite `/api-proxy/*` (`apps/web/next.config.ts`; die Web-Oberflaeche
|
|
nutzt zur Bauzeit `NEXT_PUBLIC_API_URL=/api-proxy`). Deshalb sind alle
|
|
`url`-Felder der Antwort von `/desktop/latest` **relativ zur API-Basis**
|
|
(`/desktop/download/linux`); die Web-Oberflaeche stellt `API_URL` davor, der
|
|
Client (18-04) spricht `{server}/api-proxy/desktop/latest`.
|
|
</objective>
|
|
|
|
## Artifacts this phase produces
|
|
|
|
Phase 18 gesamt (dieser Plan erzeugt die mit * markierten):
|
|
|
|
- `.gitea/scripts/desktop-version.sh` * — Version aus dem letzten Tag setzen
|
|
- `.gitea/scripts/desktop-collect.sh` * — Pakete einsammeln, `manifest.json`
|
|
- 18-02: `.gitea/workflows/ci.yml` — Job `desktop`, `publish` mit Cache-Restore (18-05 ergaenzt Windows); `.gitea/scripts/publish-images.sh` — harte Pruefung auf `desktop-dist/manifest.json`; `.gitea/scripts/publish-release.sh` — Funktion `upload_asset`, Upload aller Manifest-Dateien
|
|
- `desktop-dist/.gitkeep` *, `.gitignore` * — Platzhalter-Verzeichnis fuer die Pakete
|
|
- `apps/api/Dockerfile` * — `COPY desktop-dist ./desktop-dist`
|
|
- `packages/shared/src/index.ts` * — `DesktopPlatform`, `DesktopManifestFile`, `DesktopManifest`, `DesktopLatestFile`, `DesktopLatestResponse`
|
|
- `apps/api/src/desktop/desktop.module.ts` *, `desktop.controller.ts` * (`DesktopController.getLatest`, `DesktopController.download`), `desktop.service.ts` * (`DesktopService.getManifest`, `getLatest`, `getPackage`, `PLATFORMS`), `desktop.service.spec.ts` *
|
|
- `apps/api/src/app.module.ts` * — `DesktopModule` registriert
|
|
- `apps/desktop/src-tauri/tauri.conf.json` *, `Cargo.toml` *, `Cargo.lock` *, `apps/desktop/package.json` * — Basislinie `1.1.0`
|
|
- 18-03: `apps/web/src/lib/desktop.ts` (`loadDesktopLatest`, `desktopDownloadUrl`, `formatFileSize`), `desktop.test.ts`, `components/desktop/desktop-download-links.tsx` (+Test), `app/(auth)/login/page.tsx`, `app/(portal)/settings/general/desktop/page.tsx`, `components/settings/desktop-app-settings.tsx` (+Test), `components/settings/settings-sidebar.tsx`, `messages/de.json`, `messages/en.json`
|
|
- 18-04: `apps/desktop/src-tauri/src/lib.rs` (Kommandos `check_server`, `save_server_url`; Tray `update`, `autostart`), `Cargo.toml` (`tauri-plugin-opener`), `capabilities/default.json`, `apps/desktop/src/setup.html`, `icons/*`
|
|
- 18-05: `ci.yml` (Windows-Cross-Bau), `desktop-collect.sh --require linux,windows`
|
|
- 18-06: `docs/anleitung-anwender.md`, `docs/anleitung-betrieb.md`, `docs/anleitung-entwicklung.md`, `docs/ci-cd-setup.md`, `CHANGELOG.md`, `.planning/REQUIREMENTS.md`
|
|
|
|
<execution_context>
|
|
@$HOME/.claude/gsd-core/workflows/execute-plan.md
|
|
@$HOME/.claude/gsd-core/templates/summary.md
|
|
</execution_context>
|
|
|
|
<context>
|
|
@.planning/PROJECT.md
|
|
@.planning/ROADMAP.md
|
|
@.planning/STATE.md
|
|
@.planning/phases/18-desktop-client-fertigstellen/18-CONTEXT.md
|
|
@.planning/phases/18-desktop-client-fertigstellen/18-RESEARCH.md
|
|
@.planning/phases/18-desktop-client-fertigstellen/18-PATTERNS.md
|
|
|
|
@.gitea/scripts/publish-images.sh
|
|
@apps/api/Dockerfile
|
|
@apps/api/src/health/health.controller.ts
|
|
@apps/api/src/health/health.controller.spec.ts
|
|
@apps/api/src/dkv/dkv.service.ts
|
|
@packages/shared/src/index.ts
|
|
</context>
|
|
|
|
<tasks>
|
|
|
|
<task type="tracer">
|
|
<name>Task 1: Ein Linux-Paket aus dem Bau bis zum Download aus der API — eine Strecke</name>
|
|
<precondition>Auf dem Entwicklungsrechner sind Rust/Cargo (1.96) und die Tauri-Linux-Abhaengigkeiten installiert (libwebkit2gtk-4.1-dev, libayatana-appindicator3-dev, librsvg2-dev, libgtk-3-dev — laut 18-RESEARCH.md "Environment Availability" vorhanden), und der lokale Docker-Stack aus `docker-compose.yml` laeuft (Container `tessera-ctl-api-1` auf Port 3001, `tessera-ctl-web-1` auf Port 3000).</precondition>
|
|
<reversibility rating="costly">Die Antwortform von `GET /desktop/latest` (Feld `version`, `files.{windows,linux}.{name,size,sha256,url}`) wird von installierten Clients gelesen; Aenderungen muessen abwaertskompatibel (nur additiv) bleiben, sonst verlieren alte Clients den Update-Hinweis.</reversibility>
|
|
<files>
|
|
.gitea/scripts/desktop-collect.sh,
|
|
.gitignore,
|
|
desktop-dist/.gitkeep,
|
|
packages/shared/src/index.ts,
|
|
apps/api/src/desktop/desktop.module.ts,
|
|
apps/api/src/desktop/desktop.controller.ts,
|
|
apps/api/src/desktop/desktop.service.ts,
|
|
apps/api/src/desktop/desktop.service.spec.ts,
|
|
apps/api/src/app.module.ts,
|
|
apps/api/Dockerfile
|
|
</files>
|
|
<read_first>
|
|
.planning/phases/18-desktop-client-fertigstellen/18-RESEARCH.md (Abschnitte "Pattern 2", "Code Examples 7", "Common Pitfalls 1 und 4"),
|
|
.planning/phases/18-desktop-client-fertigstellen/18-PATTERNS.md (Abschnitte desktop.module/controller/service/spec, Dockerfile, shared),
|
|
apps/api/src/health/health.controller.ts,
|
|
apps/api/src/health/health.controller.spec.ts,
|
|
apps/api/src/health/health.module.ts,
|
|
apps/api/src/dkv/dkv.service.ts (Zeilen 85-110 und 700-732),
|
|
apps/api/src/auth/decorators/public.decorator.ts,
|
|
apps/api/Dockerfile,
|
|
.gitea/scripts/publish-images.sh (Kopfkommentar und case-Block als Stilvorlage),
|
|
packages/shared/src/index.ts,
|
|
.dockerignore
|
|
</read_first>
|
|
<behavior>
|
|
- `GET /desktop/latest` liefert bei vorhandenem Manifest 200 mit `{ version, channel, commit, buildTime, files: { linux: { name, size, sha256, url: "/desktop/download/linux" } } }`; ohne Manifest 404.
|
|
- `GET /desktop/download/linux` liefert 200, `Content-Disposition: attachment; filename="{name aus Manifest}"`, `Content-Type: application/octet-stream`, `Content-Length` = `size`, und der Inhalt hat exakt den SHA-256 aus dem Manifest.
|
|
- `GET /desktop/download/mac` und `GET /desktop/download/..%2F..%2Fetc%2Fpasswd` enden mit 400 — auch dann, wenn das Verzeichnis gar nicht existiert (Whitelist greift vor jedem Dateisystemzugriff).
|
|
- Listet das Manifest die angefragte Plattform nicht, kommt 404; traegt ein Manifest-Eintrag einen Namen mit Pfadzeichen, kommt ebenfalls 404 (Verteidigung in der Tiefe, T-18-02).
|
|
- Beide Handler tragen `@Public()` (Reflect-Metadaten `isPublic === true`).
|
|
- `desktop-collect.sh` findet das AppImage im Tauri-Bundle-Verzeichnis, kopiert es nach `desktop-dist/Tessera-{version}.AppImage` und schreibt `desktop-dist/manifest.json` mit korrekter Groesse und korrektem SHA-256.
|
|
</behavior>
|
|
<action>
|
|
**Platzhalter-Verzeichnis.** `desktop-dist/.gitkeep` (leer) anlegen und in
|
|
`.gitignore` unter einer neuen Ueberschrift "Desktop-Pakete aus dem Bau
|
|
(Phase 18)" die zwei Zeilen `desktop-dist/*` und `!desktop-dist/.gitkeep`
|
|
ergaenzen. Grund: Das Dockerfile kopiert `desktop-dist/` immer; ohne
|
|
versionierten Platzhalter scheitert jeder lokale `docker build`, und die API
|
|
soll bei leerem Verzeichnis sauber 404 liefern (D-10). `.dockerignore` braucht
|
|
keine Aenderung — die Zeile `dist` trifft nur das Wurzelverzeichnis `dist`,
|
|
nicht `desktop-dist` (per D-08 muss der Ordner in den Bau-Kontext).
|
|
|
|
**Geteilte Typen (`packages/shared/src/index.ts`).** Direkt unter
|
|
`VersionResponse` im selben flachen Stil ergaenzen, mit deutschem
|
|
Kopfkommentar (Quelle: `manifest.json`, geschrieben nur von
|
|
`desktop-collect.sh` im CI, D-08): `export type DesktopPlatform = 'windows' | 'linux'`;
|
|
`DesktopManifestFile { name: string; size: number; sha256: string }`;
|
|
`DesktopManifest { version: string; channel: string; commit: string; buildTime: string; files: Partial<Record<DesktopPlatform, DesktopManifestFile>> }`;
|
|
`DesktopLatestFile extends DesktopManifestFile { url: string }`;
|
|
`DesktopLatestResponse` mit denselben vier Kopf-Feldern und
|
|
`files: Partial<Record<DesktopPlatform, DesktopLatestFile>>`. `files` ist
|
|
bewusst `Partial`, weil dieser Plan nur Linux liefert und Windows erst mit
|
|
18-05 dazukommt.
|
|
|
|
**Sammel-Skript `.gitea/scripts/desktop-collect.sh`** (POSIX `sh`, `set -eu`,
|
|
deutscher Kopfkommentar im Stil von `publish-images.sh`, kennt kein Secret).
|
|
Aufruf `sh .gitea/scripts/desktop-collect.sh --require linux` (Kommaliste,
|
|
spaeter `linux,windows`). Umgebung: `GITHUB_REF` (Kanalentscheidung exakt wie
|
|
in `publish-images.sh`: `refs/tags/v*` -> Kanal `live`, kein Suffix;
|
|
`refs/heads/main` -> Kanal `beta`, Suffix `-beta.{7-stelliger SHA}`; alles
|
|
andere -> Kanal `dev`, kein Suffix, damit lokale Proben die Freigabe-Namen
|
|
tragen), `DESKTOP_DIST` (Vorgabe `desktop-dist`), `TAURI_DIR` (Vorgabe
|
|
`apps/desktop/src-tauri`). Ablauf: Version per `jq -r .version` aus
|
|
`$TAURI_DIR/tauri.conf.json` lesen und gegen `^[0-9]+\.[0-9]+\.[0-9]+$`
|
|
pruefen (sonst Exit 1 — Pitfall 2, NSIS nimmt nur numerische Versionen);
|
|
`git rev-parse --short=7 HEAD`; alte `*.AppImage`, `*.exe`, `manifest.json`
|
|
im Zielordner entfernen (Platzhalter bleibt); Linux: genau eine Datei
|
|
`$TAURI_DIR/target/release/bundle/appimage/*.AppImage` per `find`/`ls`
|
|
ermitteln — bei null oder mehr als einer Datei und geforderter Plattform Exit 1
|
|
mit klarer Meldung (Pitfall 4: niemals den Tauri-Vorgabenamen annehmen);
|
|
kopieren nach `Tessera-${VERSION}${SUFFIX}.AppImage`; Windows analog aus
|
|
`$TAURI_DIR/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe` nach
|
|
`Tessera-Setup-${VERSION}${SUFFIX}.exe` (in diesem Plan noch nicht gefordert,
|
|
Zweig aber schon anlegen); je Datei `size` ueber `stat -c %s` und `sha256`
|
|
ueber `sha256sum | cut -d' ' -f1`; `manifest.json` ausschliesslich mit `jq -n`
|
|
und `--arg`/`--argjson` bauen (Felder `version`, `channel`, `commit`,
|
|
`buildTime` als UTC-ISO-Zeit, `files` nur mit tatsaechlich vorhandenen
|
|
Plattformen); zum Schluss je Datei eine Zeile `linux: {Name} ({Bytes} Bytes,
|
|
sha256 {Hash})` ausgeben. Datei ausfuehrbar machen (`chmod +x`) wie die
|
|
Nachbarskripte.
|
|
|
|
**Lokales AppImage als Testobjekt.** Liegt unter
|
|
`apps/desktop/src-tauri/target/release/bundle/appimage/` noch das AppImage aus
|
|
Phase 6, reicht es fuer diesen Durchstich; sonst zuerst
|
|
`pnpm --filter @tessera/desktop exec tauri build --bundles appimage`
|
|
laufen lassen (dauert einige Minuten). Danach das Sammel-Skript aufrufen; es
|
|
muss `desktop-dist/Tessera-0.0.1.AppImage` und `desktop-dist/manifest.json`
|
|
erzeugen (die Basislinie `1.1.0` kommt erst in Task 2).
|
|
|
|
**API-Modul `apps/api/src/desktop/`.** `desktop.module.ts` nach dem Vorbild
|
|
`health.module.ts` mit `controllers: [DesktopController]` und
|
|
`providers: [DesktopService]`; in `app.module.ts` importieren und hinter
|
|
`HealthModule` in die `imports`-Liste aufnehmen.
|
|
|
|
`desktop.service.ts` (`@Injectable()`, Imports `fs`/`path` wie
|
|
`dkv.service.ts`): Konstante `PLATFORMS = ['windows', 'linux'] as const`
|
|
(Wertevorrat = `DesktopPlatform`). Verzeichnis im Konstruktor bestimmen:
|
|
`process.env.DESKTOP_DIST_DIR` (getrimmt, nicht leer) hat Vorrang, sonst
|
|
`path.resolve(__dirname, '..', '..', '..', '..', 'desktop-dist')` — gleiche
|
|
Vier-Ebenen-Aufloesung wie `userFilesDir` in `dkv.service.ts`, ergibt im
|
|
Abbild `/app/desktop-dist` und lokal die Monorepo-Wurzel. Methoden:
|
|
`getManifest(): DesktopManifest | null` (liest `manifest.json`, `null` wenn
|
|
Datei fehlt oder `JSON.parse` scheitert oder `version` kein String bzw. `files`
|
|
kein Objekt ist — mit `Logger.warn`, nie werfen);
|
|
`getLatest(): DesktopLatestResponse` (wirft `NotFoundException('Desktop packages are not available on this server')`
|
|
ohne Manifest; sonst Kopf-Felder uebernehmen und je vorhandener Plattform
|
|
`url: '/desktop/download/' + platform` ergaenzen);
|
|
`getPackage(platform: string): { stream: fs.ReadStream; entry: DesktopManifestFile }`
|
|
in genau dieser Reihenfolge: (1) `PLATFORMS.includes(platform)` sonst
|
|
`BadRequestException('Unknown platform')` — vor jedem Dateisystemzugriff;
|
|
(2) Manifest holen, sonst 404; (3) `manifest.files[platform]` fehlt -> 404;
|
|
(4) `entry.name` muss `^[A-Za-z0-9._-]+$` erfuellen, sonst 404 (kein Name aus
|
|
der Anfrage, aber auch ein manipuliertes Manifest darf nicht aus dem Ordner
|
|
hinausfuehren); (5) `path.join(dir, entry.name)` muss existieren, sonst 404;
|
|
(6) `fs.createReadStream` zurueckgeben.
|
|
|
|
`desktop.controller.ts` (`@Controller('desktop')`, Konstruktor mit
|
|
`DesktopService`): `@Public() @Get('latest') getLatest()` mit einem
|
|
Kommentar, warum oeffentlich (D-10: die Anmeldeseite zeigt den Link vor jeder
|
|
Anmeldung; gleicher Grund wie `HealthController.getVersion`, T-KU1-03);
|
|
`@Public() @Get('download/:platform') download(@Param('platform') platform: string): StreamableFile`
|
|
— `new StreamableFile(stream, { type: 'application/octet-stream', disposition: 'attachment; filename="' + entry.name + '"', length: entry.size })`
|
|
(Optionen-Objekt von `StreamableFile` aus `@nestjs/common`; kein `@Res`, kein
|
|
Puffern der ganzen Datei — Installer sind zwei Groessenordnungen groesser als
|
|
die DKV-Exporte, deshalb bewusst anders als `dkv.controller.ts`). Kein
|
|
`@Roles()` an beiden Handlern.
|
|
|
|
`desktop.service.spec.ts` (Kopfkommentar und nummerierte `it('Test N (…)')`
|
|
im Stil von `health.controller.spec.ts`, `import 'reflect-metadata'` zuerst).
|
|
Keine `fs`-Mocks — stattdessen ein echtes Temp-Verzeichnis
|
|
(`fs.mkdtempSync(path.join(os.tmpdir(), 'tessera-desktop-'))`) mit einer
|
|
kleinen Zufallsdatei (z. B. 64 KiB aus `crypto.randomBytes`) und einem von
|
|
Hand geschriebenen `manifest.json`, dessen `sha256` im Test unabhaengig ueber
|
|
`crypto.createHash('sha256')` berechnet wird. Fuer den HTTP-Durchstich
|
|
`process.env.DESKTOP_DIST_DIR` auf das Temp-Verzeichnis setzen, dann
|
|
`NestFactory.create(DesktopModule, { logger: false })`, `await app.listen(0)`,
|
|
Port aus `app.getHttpServer().address().port`, Aufrufe mit dem globalen
|
|
`fetch`; im `afterAll` `app.close()` und Temp-Verzeichnis entfernen. Faelle:
|
|
Test 1 latest -> 200 und Form wie in `<behavior>`; Test 2 Dienst ohne
|
|
Manifest (zweites, leeres Temp-Verzeichnis, eigene `DesktopService`-Instanz
|
|
nach Umsetzen der Umgebungsvariable) -> `NotFoundException`; Test 3
|
|
download/linux -> Header und Body-Hash wie in `<behavior>`; Test 4 `mac` und
|
|
`..%2F..%2Fetc%2Fpasswd` -> 400; Test 5 Dienst mit nicht existierendem
|
|
Verzeichnis und Plattform `mac` -> `BadRequestException` (nicht
|
|
`NotFoundException`) — beweist die Reihenfolge Whitelist vor Dateisystem;
|
|
Test 6 Manifest nur mit `windows` -> download/linux 404; Test 7 Manifest mit
|
|
Namen `../x.AppImage` -> 404; Test 8 `@Public()` auf `getLatest` und
|
|
`download` per `Reflect.getMetadata(IS_PUBLIC_KEY, DesktopController.prototype.getLatest)`.
|
|
Erwartungswerte von Hand hinschreiben, nicht ueber den Pruefling erzeugen.
|
|
|
|
**Dockerfile (`apps/api/Dockerfile`).** In der `runner`-Stufe unmittelbar vor
|
|
`USER nestjs` die Zeile `COPY desktop-dist ./desktop-dist` mit deutschem
|
|
Kommentar (Phase 18, D-08: Pakete werden vom CI in den Bau-Kontext gelegt,
|
|
lokal nur der Platzhalter; nur lesend, keine `chown` noetig).
|
|
|
|
**Durchstich im laufenden Stack.** Nach den Tests das API-Abbild lokal neu
|
|
bauen und den Container ersetzen (`docker compose build api` und danach
|
|
`docker compose up -d --force-recreate api` — `up` allein baut nicht neu,
|
|
Projektwissen "Deploy-Fallstricke"); dann `curl http://localhost:3001/desktop/latest`
|
|
und die Kopfzeilen von `/desktop/download/linux` pruefen, zusaetzlich ueber
|
|
den Web-Rewrite `http://localhost:3000/api-proxy/desktop/latest`. Danach bleibt
|
|
der lokale Stack in diesem Zustand (mit Paketen) stehen.
|
|
</action>
|
|
<acceptance_criteria>
|
|
- `git ls-files --error-unmatch desktop-dist/.gitkeep` endet mit 0 nach dem Commit; `grep -c '^!desktop-dist/.gitkeep$' .gitignore` ergibt 1.
|
|
- `grep -c 'export type DesktopPlatform' packages/shared/src/index.ts` ergibt 1; `grep -c 'export interface DesktopLatestResponse' packages/shared/src/index.ts` ergibt 1.
|
|
- `grep -c "DesktopModule" apps/api/src/app.module.ts` ergibt mindestens 2 (Import und imports-Eintrag).
|
|
- `grep -c '^COPY desktop-dist ./desktop-dist' apps/api/Dockerfile` ergibt 1.
|
|
- `grep -v '^\s*//' apps/api/src/desktop/desktop.controller.ts | grep -c '@Public()'` ergibt 2.
|
|
- `grep -v '^\s*//' apps/api/src/desktop/desktop.service.ts | grep -c "PLATFORMS = \['windows', 'linux'\] as const"` ergibt 1.
|
|
- `pnpm --filter @tessera/api exec vitest run src/desktop` meldet 8 Tests bestanden, 0 fehlgeschlagen.
|
|
- `sh .gitea/scripts/desktop-collect.sh --require linux` erzeugt `desktop-dist/manifest.json`; `jq -r .files.linux.name desktop-dist/manifest.json` ergibt `Tessera-0.0.1.AppImage` (bzw. die aktuelle Version aus tauri.conf.json) und der SHA-256 im Manifest ist gleich `sha256sum` der Datei.
|
|
- `curl -s http://localhost:3001/desktop/latest | jq -r .files.linux.url` ergibt `/desktop/download/linux`; `curl -sI http://localhost:3001/desktop/download/linux` enthaelt `content-disposition: attachment; filename="Tessera-` und den Status 200.
|
|
</acceptance_criteria>
|
|
<verify>
|
|
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/api exec vitest run src/desktop && pnpm --filter @tessera/api type-check</automated>
|
|
<fails_when>vitest meldet "failed" oder einen Exit-Code ungleich 0, oder tsc gibt Fehlerzeilen aus.</fails_when>
|
|
<automated>cd /home/vicolab/projects/tessera-ctl && sh .gitea/scripts/desktop-collect.sh --require linux && test "$(sha256sum "desktop-dist/$(jq -r .files.linux.name desktop-dist/manifest.json)" | cut -d' ' -f1)" = "$(jq -r .files.linux.sha256 desktop-dist/manifest.json)" && echo MANIFEST-OK</automated>
|
|
<fails_when>Das Skript endet mit Exit 1, `manifest.json` fehlt, oder die Zeile `MANIFEST-OK` erscheint nicht (Hash-Abweichung).</fails_when>
|
|
<automated>cd /home/vicolab/projects/tessera-ctl && curl -sf http://localhost:3001/desktop/latest | jq -e '.files.linux.url == "/desktop/download/linux"' && curl -sI http://localhost:3001/desktop/download/linux | grep -i 'content-disposition: attachment; filename="Tessera-' && curl -sf http://localhost:3000/api-proxy/desktop/latest | jq -e .version</automated>
|
|
<fails_when>curl liefert einen Nicht-2xx-Status (Exit 22), `jq -e` findet das Feld nicht, oder die Kopfzeile `content-disposition: attachment; filename="Tessera-` fehlt — dann liefert das neu gebaute Abbild die Pakete nicht aus.</fails_when>
|
|
</verify>
|
|
<done>
|
|
Acht Spec-Tests gruen, Typpruefung fehlerfrei. Das lokal eingesammelte
|
|
AppImage liegt mit passendem Manifest in `desktop-dist/`, das neu gebaute
|
|
API-Abbild liefert es unter `/desktop/download/linux` mit `attachment`-Header
|
|
aus, und `/desktop/latest` ist auch ueber `/api-proxy/` des Web-Containers
|
|
erreichbar.
|
|
</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 2: Die Version kommt aus dem Freigabe-Tag — Skript und Basislinie 1.1.0</name>
|
|
<files>
|
|
.gitea/scripts/desktop-version.sh,
|
|
apps/desktop/src-tauri/tauri.conf.json,
|
|
apps/desktop/src-tauri/Cargo.toml,
|
|
apps/desktop/src-tauri/Cargo.lock,
|
|
apps/desktop/package.json
|
|
</files>
|
|
<read_first>
|
|
.planning/phases/18-desktop-client-fertigstellen/18-RESEARCH.md (Abschnitte "Code Examples 1" und "Common Pitfalls 2"),
|
|
.gitea/scripts/publish-images.sh,
|
|
apps/desktop/src-tauri/tauri.conf.json,
|
|
apps/desktop/src-tauri/Cargo.toml (Zeile `version = "0.0.1"` unter `[package]`; die Zeilen `tauri = { version = "2", … }` stehen nicht am Zeilenanfang),
|
|
apps/desktop/package.json
|
|
</read_first>
|
|
<action>
|
|
**Skript `.gitea/scripts/desktop-version.sh`** (POSIX `sh`, `set -eu`,
|
|
deutscher Kopfkommentar; Muster aus RESEARCH Code Example 1, D-07).
|
|
Versionsquelle: `TAG="${DESKTOP_TAG:-$(git describe --tags --abbrev=0 --match 'v[0-9]*')}"`
|
|
(die Umgebungsvariable `DESKTOP_TAG` dient nur der lokalen Probe); scheitert
|
|
`git describe` (kein Tag erreichbar), Exit 1 mit Meldung — im CI ist das ein
|
|
Fehler, weil `fetch-depth: 0` Pflicht ist. `VERSION="${TAG#v}"` muss
|
|
`^[0-9]+\.[0-9]+\.[0-9]+$` erfuellen, sonst Exit 1: es wird **nie** eine
|
|
Vorab- oder Metadaten-Form geschrieben (Pitfall 2, Windows-Ressourcen sind
|
|
rein numerisch). Schreiben: `tauri.conf.json` per `jq --arg v "$VERSION" '.version = $v'`
|
|
ueber eine Temp-Datei; `Cargo.toml` per `sed -i` nur auf der Zeile, die mit
|
|
`version = "` **am Zeilenanfang** beginnt (trifft ausschliesslich den
|
|
`[package]`-Eintrag). `apps/desktop/package.json` bleibt vom Skript
|
|
unberuehrt (kein Bau-Eingang). Option `--print`: nur die ermittelte Version
|
|
ausgeben, nichts schreiben. Abschlusszeile `Desktop-Version gesetzt: X.Y.Z (aus Tag vX.Y.Z)`.
|
|
Ausfuehrbar machen.
|
|
|
|
**Basislinie einchecken.** Das Skript einmal lokal ausfuehren (aktueller
|
|
letzter Tag ist `v1.1.0`), danach `cargo check` im Verzeichnis
|
|
`apps/desktop/src-tauri` laufen lassen, damit `Cargo.lock` den Eintrag des
|
|
eigenen Pakets auf `1.1.0` zieht; `apps/desktop/package.json` von Hand auf
|
|
`"version": "1.1.0"` setzen. Alle vier Dateien werden mit dem Skript
|
|
committet — die eingecheckten Werte sind nur die Basislinie fuer lokale Baue,
|
|
die Wahrheit im CI ist der Tag (Kopfkommentar des Skripts sagt genau das).
|
|
|
|
**Frisches AppImage mit der Basislinie.** `pnpm --filter @tessera/desktop exec tauri build --bundles appimage`
|
|
erneut laufen lassen (bei warmem `target/` wenige Minuten), vorher das alte
|
|
Bundle-Verzeichnis `apps/desktop/src-tauri/target/release/bundle` entfernen,
|
|
damit `desktop-collect.sh` genau eine Datei findet. Danach
|
|
`sh .gitea/scripts/desktop-collect.sh --require linux` — das Manifest traegt
|
|
jetzt `1.1.0` und den Namen `Tessera-1.1.0.AppImage`.
|
|
</action>
|
|
<acceptance_criteria>
|
|
- `sh .gitea/scripts/desktop-version.sh --print` gibt genau `1.1.0` aus (bei Tag-Stand v1.1.0).
|
|
- `jq -r .version apps/desktop/src-tauri/tauri.conf.json` ergibt `1.1.0`; `grep -c '^version = "1.1.0"' apps/desktop/src-tauri/Cargo.toml` ergibt 1; `grep -c '"version": "1.1.0"' apps/desktop/package.json` ergibt 1.
|
|
- `grep -A1 'name = "tessera-desktop"' apps/desktop/src-tauri/Cargo.lock | grep -c 'version = "1.1.0"'` ergibt 1.
|
|
- `jq -r .files.linux.name desktop-dist/manifest.json` ergibt `Tessera-1.1.0.AppImage`.
|
|
- Negativprobe: `DESKTOP_TAG=v1.2.3-beta sh .gitea/scripts/desktop-version.sh --print` endet mit Exit 1 und schreibt nichts; `DESKTOP_TAG=v2.0.0 sh .gitea/scripts/desktop-version.sh --print` gibt `2.0.0` aus und schreibt ebenfalls nichts (Dateien bleiben bei `1.1.0`).
|
|
</acceptance_criteria>
|
|
<verify>
|
|
<automated>cd /home/vicolab/projects/tessera-ctl && test "$(sh .gitea/scripts/desktop-version.sh --print)" = "1.1.0" && test "$(jq -r .version apps/desktop/src-tauri/tauri.conf.json)" = "1.1.0" && grep -q '^version = "1.1.0"' apps/desktop/src-tauri/Cargo.toml && test "$(jq -r .files.linux.name desktop-dist/manifest.json)" = "Tessera-1.1.0.AppImage" && echo VERSION-OK</automated>
|
|
<fails_when>Eine der Pruefungen schlaegt fehl und `VERSION-OK` erscheint nicht — Skript, Basislinie oder Manifest tragen nicht `1.1.0`.</fails_when>
|
|
<automated>cd /home/vicolab/projects/tessera-ctl && if DESKTOP_TAG=v1.2.3-beta sh .gitea/scripts/desktop-version.sh --print >/dev/null 2>&1; then echo "Vorabversion wurde akzeptiert"; exit 1; fi && test "$(jq -r .version apps/desktop/src-tauri/tauri.conf.json)" = "1.1.0" && echo REJECT-OK</automated>
|
|
<fails_when>Das Skript akzeptiert `v1.2.3-beta` (Exit 0) oder hat trotz `--print` die Datei veraendert — `REJECT-OK` fehlt.</fails_when>
|
|
<automated>cd /home/vicolab/projects/tessera-ctl/apps/desktop/src-tauri && cargo check 2>&1 | tail -1 | grep -q 'Finished'</automated>
|
|
<fails_when>`cargo check` endet nicht mit einer `Finished`-Zeile (Kompilierfehler nach der Versionsaenderung).</fails_when>
|
|
</verify>
|
|
<done>
|
|
Skript, Basislinie `1.1.0` in allen vier Dateien, `cargo check` gruen, und
|
|
`desktop-dist/` traegt `Tessera-1.1.0.AppImage` samt Manifest.
|
|
</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<threat_model>
|
|
## Trust Boundaries
|
|
|
|
| Boundary | Description |
|
|
|----------|-------------|
|
|
| Internet -> API (`/desktop/latest`, `/desktop/download/:platform`) | Oeffentliche, unauthentifizierte Endpunkte; der Pfadparameter ist Angreifereingabe. |
|
|
| CI-Runner -> API-Abbild (`desktop-dist/`) | Das Manifest und die Pakete entstehen im Runner und werden unveraendert ins Abbild kopiert. |
|
|
|
|
## STRIDE Threat Register
|
|
|
|
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
|
|-----------|----------|-----------|----------|-------------|-----------------|
|
|
| T-18-01 | Tampering / Information Disclosure | `DesktopService.getPackage` (Pfad-Traversal ueber `:platform`) | high | mitigate | Whitelist `PLATFORMS` vor jedem Dateisystemzugriff; Dateiname kommt ausschliesslich aus `manifest.json`; zusaetzlich Namensmuster `^[A-Za-z0-9._-]+$`. Spec-Tests 4, 5 und 7 pinnen das. |
|
|
| T-18-02 | Tampering | `manifest.json` (veraltet oder manipuliert) | medium | mitigate | Nur `desktop-collect.sh` im CI schreibt die Datei; sie liegt im unveraenderlichen Abbild, kein Laufzeitpfad schreibt nach `/app/desktop-dist/`; Namensmuster-Pruefung als Verteidigung in der Tiefe. SHA-256 ist Integritaets-Metadatum, keine Signatur (D-09). |
|
|
| T-18-04 | Information Disclosure | `GET /desktop/latest` (Version, Kanal, Commit oeffentlich) | low | accept | Gleiche Abwaegung wie `GET /health/version` (T-KU1-03): keine Komponentenversionen, privates Repository; die Anmeldeseite braucht die Daten vor der Anmeldung (D-10). |
|
|
| T-18-05 | Denial of Service | `GET /desktop/download/:platform` (grosse Datei, oeffentlich) | low | accept | Streaming statt Puffern; Ratenbegrenzung ist Aufgabe des vorgeschalteten Nginx Proxy Managers (ASVS L1). |
|
|
| T-18-SC | Tampering | Paketinstallationen | low | accept | Dieser Plan installiert kein neues Paket (Legitimitaetstabelle in RESEARCH: `cargo-xwin`, `tauri-plugin-opener` beide `OK`, kommen in 18-04/18-05). |
|
|
</threat_model>
|
|
|
|
<verification>
|
|
1. `pnpm --filter @tessera/api exec vitest run src/desktop` — 8 Tests gruen.
|
|
2. `pnpm --filter @tessera/api type-check` — fehlerfrei.
|
|
3. `desktop-dist/manifest.json` traegt `1.1.0` und den Namen `Tessera-1.1.0.AppImage`, SHA-256 stimmt mit der Datei ueberein.
|
|
4. Lokal neu gebautes API-Abbild liefert `/desktop/latest` (200) und `/desktop/download/linux` (200, `attachment`) aus; ueber `http://localhost:3000/api-proxy/desktop/latest` ebenfalls 200.
|
|
5. Beide neuen Skripte bestehen `sh -n`; `desktop-version.sh` weist eine Vorabversion ab.
|
|
</verification>
|
|
|
|
<success_criteria>
|
|
- Ein lokal gebautes AppImage wird nach dem Einsammeln vom neu gebauten
|
|
API-Abbild ohne Anmeldung ausgeliefert (Strecke Skript -> Abbild -> API
|
|
bewiesen).
|
|
- Unbekannte Plattformen und Traversal-Versuche enden mit 400, fehlende
|
|
Pakete mit 404 — gepinnt durch die Spec.
|
|
- Die Versionsquelle ist der Freigabe-Tag; die Basislinie im Repository ist
|
|
`1.1.0`.
|
|
</success_criteria>
|
|
|
|
<output>
|
|
Create `.planning/phases/18-desktop-client-fertigstellen/18-01-SUMMARY.md` when done.
|
|
Im SUMMARY festhalten: Groesse und SHA-256 des lokal eingesammelten AppImage,
|
|
die Dauer des lokalen `tauri build`, und ob das Phase-6-AppImage oder ein
|
|
frischer Bau als Testobjekt diente.
|
|
</output>
|