docs(18): Phase 18 geplant — sechs Plaene (Tracer Linux-Strecke, Pipeline, Web, Client, Windows-Cross-Bau mit Iterationsschleife, Abschluss), Validierungsplan, API-Coverage, Fahrplan
This commit is contained in:
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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"
|
||||
---
|
||||
|
||||
<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>
|
||||
@@ -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\\[\\]"
|
||||
---
|
||||
|
||||
<objective>
|
||||
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.
|
||||
</objective>
|
||||
|
||||
## 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.
|
||||
|
||||
<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-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
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Job desktop (Linux-AppImage) und Uebergabe an publish per actions/cache</name>
|
||||
<reversibility rating="reversible">Job-Aufbau und Cache-Schluessel lassen sich jederzeit aendern; kein Zustand ausserhalb des Runners.</reversibility>
|
||||
<files>
|
||||
.gitea/workflows/ci.yml,
|
||||
.gitea/scripts/publish-images.sh
|
||||
</files>
|
||||
<read_first>
|
||||
.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")
|
||||
</read_first>
|
||||
<action>
|
||||
**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 `<verify>` 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.
|
||||
</action>
|
||||
<acceptance_criteria>
|
||||
- `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.
|
||||
</acceptance_criteria>
|
||||
<verify>
|
||||
<automated>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</automated>
|
||||
<fails_when>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.</fails_when>
|
||||
<automated>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</automated>
|
||||
<fails_when>Syntaxfehler, weniger als vier push-Zeilen im Probelauf, oder die Manifest-Pruefung fehlt im Skript — `IMAGES-OK` fehlt.</fails_when>
|
||||
</verify>
|
||||
<done>
|
||||
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.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Release-Dateien idempotent an den Gitea-Release haengen</name>
|
||||
<precondition>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).</precondition>
|
||||
<files>
|
||||
.gitea/scripts/publish-release.sh
|
||||
</files>
|
||||
<read_first>
|
||||
.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)
|
||||
</read_first>
|
||||
<action>
|
||||
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.
|
||||
</action>
|
||||
<acceptance_criteria>
|
||||
- `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.
|
||||
</acceptance_criteria>
|
||||
<verify>
|
||||
<automated>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</automated>
|
||||
<fails_when>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.</fails_when>
|
||||
</verify>
|
||||
<done>
|
||||
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.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## 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. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
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).
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- 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.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/18-desktop-client-fertigstellen/18-02-SUMMARY.md` when done.
|
||||
</output>
|
||||
@@ -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"
|
||||
---
|
||||
|
||||
<objective>
|
||||
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.
|
||||
</objective>
|
||||
|
||||
## 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.
|
||||
|
||||
<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-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
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: Fetch-Helfer und der Download-Link auf der Anmeldeseite</name>
|
||||
<files>
|
||||
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
|
||||
</files>
|
||||
<read_first>
|
||||
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)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- `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.
|
||||
</behavior>
|
||||
<action>
|
||||
**`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<Record<DesktopPlatform, DesktopFileInfo>> }`.
|
||||
`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 `<behavior>` — 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 `<div className="text-center text-sm text-muted-foreground">`
|
||||
mit: Hauptlink (Windows, falls vorhanden, sonst Linux) als `<a href={desktopDownloadUrl(file)} download className="hover:text-foreground underline-offset-4 hover:underline">`
|
||||
mit Text `t('desktopDownload.windows')` bzw. `t('desktopDownload.linux')`;
|
||||
ist Windows vorhanden **und** Linux vorhanden, dahinter ` · ` und ein
|
||||
zweiter Link `t('desktopDownload.linuxShort')`; darunter in `text-xs`
|
||||
`t('desktopDownload.version', { version })`. Keine Fehlermeldung, kein
|
||||
Spinner — der Block ist unauffaellig (D-12).
|
||||
|
||||
**`desktop-download-links.test.tsx`**: next-intl-Mock nach dem Muster in
|
||||
`widget-settings-panel.test.tsx` (de.json-gestuetzt, zusaetzlich
|
||||
`useLocale: () => 'de'`), `vi.mock('@/lib/desktop', …)` mit steuerbarem
|
||||
`loadDesktopLatest` (echte `desktopDownloadUrl`/`formatFileSize` per
|
||||
`importOriginal` durchreichen). Faelle: (1) `null` -> Container leer
|
||||
(`container.firstChild` ist `null`); (2) beide Plattformen -> zwei Links mit
|
||||
den deutschen Texten aus de.json und hrefs `…/desktop/download/windows` bzw.
|
||||
`…/desktop/download/linux`, Text `Version 1.1.0`; (3) nur Linux -> genau ein
|
||||
Link mit dem Linux-Text. `findBy…` fuer die asynchrone Aufloesung.
|
||||
|
||||
**Anmeldeseite (`(auth)/login/page.tsx`)**: Import der Komponente; direkt
|
||||
nach dem schliessenden `</form>` innerhalb des `max-w-sm space-y-8`-Blocks
|
||||
`<DesktopDownloadLinks />` einfuegen. Sonst nichts aendern.
|
||||
|
||||
**Uebersetzungen** in `de.json` unter `auth` neuer Block `desktopDownload`:
|
||||
`windows` = "Desktop-App herunterladen (Windows)", `linux` = "Desktop-App
|
||||
herunterladen (Linux)", `linuxShort` = "Linux-Version", `version` =
|
||||
"Version {version}". In `en.json` 1:1: "Download desktop app (Windows)",
|
||||
"Download desktop app (Linux)", "Linux version", "Version {version}".
|
||||
</action>
|
||||
<acceptance_criteria>
|
||||
- `pnpm --filter @tessera/web exec vitest run src/lib/desktop.test.ts src/components/desktop` meldet 8 Tests bestanden, 0 fehlgeschlagen.
|
||||
- `grep -v '^\s*//' apps/web/src/lib/desktop.ts | grep -c 'process.env.NEXT_PUBLIC_API_URL'` ergibt 1.
|
||||
- `grep -c 'DesktopDownloadLinks' "apps/web/src/app/(auth)/login/page.tsx"` ergibt 2 (Import und Einbau).
|
||||
- `node -e "const de=require('./apps/web/src/messages/de.json');if(de.auth.desktopDownload.windows!=='Desktop-App herunterladen (Windows)')process.exit(1)"` endet mit 0.
|
||||
- `pnpm --filter @tessera/web type-check` fehlerfrei.
|
||||
</acceptance_criteria>
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web exec vitest run src/lib/desktop.test.ts src/components/desktop && pnpm --filter @tessera/web type-check</automated>
|
||||
<fails_when>vitest meldet "failed" oder Exit-Code ungleich 0, oder tsc gibt Fehlerzeilen aus.</fails_when>
|
||||
</verify>
|
||||
<done>
|
||||
Acht Tests gruen, Typpruefung fehlerfrei, die Anmeldeseite baut den
|
||||
Link-Block ein, de/en tragen den Block `auth.desktopDownload`.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: Einstellungsseite "Desktop-App" mit Knoepfen, Groesse und Erklaerung</name>
|
||||
<files>
|
||||
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
|
||||
</files>
|
||||
<read_first>
|
||||
apps/web/src/app/(portal)/settings/general/account/page.tsx (Seitenhuelle),
|
||||
apps/web/src/components/settings/settings-sidebar.tsx (Eintrag "Konto" unter "Allgemein"),
|
||||
apps/web/src/components/settings/widget-settings-panel.test.tsx (Zeilen 1-30),
|
||||
apps/web/src/app/(auth)/login/page.tsx (Klassen des Primaerknopfs: `rounded-md bg-primary px-4 py-2.5 text-sm font-medium text-primary-foreground hover:opacity-90`),
|
||||
apps/web/src/lib/desktop.ts (aus Task 1),
|
||||
apps/web/src/messages/de.json (Namensraum `settings`, Block `account`)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- Route `/settings/general/desktop` rendert die Ueberschrift "Desktop-App" und die Komponente `DesktopAppSettings`.
|
||||
- Mit Daten fuer beide Plattformen zeigt die Seite "Aktuelle Version: 1.1.0", zwei Knoepfe "Für Windows herunterladen" und "Für Linux herunterladen" (Primaerfarbe, jeweils mit Plattform-Symbol als inline-SVG, `href` aus `desktopDownloadUrl`, Attribut `download`) und darunter je Knopf die Zeile "{Dateiname} · {Groesse}", z. B. "Tessera-Setup-1.1.0.exe · 5,8 MB".
|
||||
- Auf dem Beta-Kanal steht zusaetzlich "Beta-Ausgabe, Stand {commit}".
|
||||
- Vier Saetze in Sie-Form erklaeren, was die App ist, den Erststart mit Server-Adresse, das Verhalten im Infobereich und den Update-Hinweis.
|
||||
- Antwortet die API mit null, erscheinen statt der Knoepfe der Satz "Auf diesem Server sind derzeit keine Desktop-Pakete hinterlegt." und die vier Saetze bleiben stehen.
|
||||
- Die Seitenleiste zeigt unter "Allgemein" den Eintrag "Desktop-App" mit `aria-current="page"` auf der Route.
|
||||
</behavior>
|
||||
<action>
|
||||
**Seite `app/(portal)/settings/general/desktop/page.tsx`**: exakt die
|
||||
Huelle von `account/page.tsx` (`'use client'`, `useTranslations('settings')`,
|
||||
`<h1>` mit `t('desktop.title')`), Inhalt `<DesktopAppSettings />`. Kein
|
||||
Anlegen weiterer Layout-Dateien — die Route liegt unter dem bestehenden
|
||||
`settings`-Layout mit Seitenleiste.
|
||||
|
||||
**Komponente `components/settings/desktop-app-settings.tsx`**
|
||||
(`'use client'`, `useTranslations('settings')`, `useLocale()`): laedt
|
||||
`loadDesktopLatest()` wie in Task 1 (State `undefined` = laedt, `null` =
|
||||
nicht verfuegbar, Objekt = Daten). Aufbau: Absatz mit den vier Saetzen
|
||||
`t('desktop.intro')`, `t('desktop.firstStart')`, `t('desktop.tray')`,
|
||||
`t('desktop.update')` (ein `<p>` je Satz, `text-sm text-muted-foreground`);
|
||||
dann bei Daten: `<p>` mit `t('desktop.versionLabel', { version })` und, wenn
|
||||
`channel === 'beta'`, `t('desktop.channelBeta', { commit })`; dann ein
|
||||
`<div className="flex flex-wrap gap-4">` mit je Plattform (nur vorhandene,
|
||||
Reihenfolge Windows, Linux) einem Block aus `<a href download>` im
|
||||
Primaerknopf-Stil der Anmeldeseite (`inline-flex items-center gap-2 rounded-md bg-primary px-4 py-2.5 text-sm font-medium text-primary-foreground hover:opacity-90`)
|
||||
mit inline-SVG-Symbol (Windows: vier abgerundete Felder im 2x2-Raster;
|
||||
Linux: Terminalfenster mit `>_`-Prompt — beide 16x16, `aria-hidden`) und
|
||||
Text `t('desktop.downloadWindows')` bzw. `t('desktop.downloadLinux')`,
|
||||
darunter `<p className="mt-1 text-xs text-muted-foreground">` mit
|
||||
`t('desktop.fileInfo', { name, size: formatFileSize(size, locale) })`. Bei
|
||||
`null`: `<p>` mit `t('desktop.unavailable')` statt Knoepfen. Waehrend des
|
||||
Ladens nichts unterhalb der Saetze. `data-testid="desktop-download-windows"`
|
||||
und `desktop-download-linux` an den Links.
|
||||
|
||||
**Seitenleiste `settings-sidebar.tsx`**: im `<nav>` unter "Allgemein" hinter
|
||||
dem Konto-Link einen zweiten `<Link href="/settings/general/desktop">` mit
|
||||
identischem Klassen-/`aria-current`-Muster und `t('categoryDesktopApp')`;
|
||||
`isActive` bleibt unveraendert (`startsWith` deckt die Route ab).
|
||||
|
||||
**Uebersetzungen** `de.json` `settings`: `categoryDesktopApp` = "Desktop-App";
|
||||
Block `desktop`: `title` = "Desktop-App", `intro` = "Die Desktop-App öffnet
|
||||
Tessera in einem eigenen Fenster – ohne Browser, mit Symbol im Infobereich der
|
||||
Taskleiste.", `firstStart` = "Beim ersten Start fragt die App nach der Adresse
|
||||
Ihres Tessera-Servers; das ist die Adresse, unter der Sie Tessera auch im
|
||||
Browser öffnen.", `tray` = "Schließen Sie das Fenster, läuft Tessera im
|
||||
Infobereich weiter; über das Symbol dort öffnen Sie das Fenster wieder,
|
||||
schalten den automatischen Start ein oder beenden die App.", `update` =
|
||||
"Erscheint eine neuere Version, weist die App Sie darauf hin und führt Sie auf
|
||||
diese Seite.", `versionLabel` = "Aktuelle Version: {version}", `channelBeta`
|
||||
= "Beta-Ausgabe, Stand {commit}", `downloadWindows` = "Für Windows
|
||||
herunterladen", `downloadLinux` = "Für Linux herunterladen", `fileInfo` =
|
||||
"{name} · {size}", `unavailable` = "Auf diesem Server sind derzeit keine
|
||||
Desktop-Pakete hinterlegt.". `en.json` 1:1 sinngemaess ("Desktop app",
|
||||
"The desktop app opens Tessera in its own window – no browser, with an icon
|
||||
in the notification area of the taskbar.", "On first start the app asks for
|
||||
the address of your Tessera server; it is the address you also use to open
|
||||
Tessera in the browser.", "If you close the window, Tessera keeps running in
|
||||
the notification area; use the icon there to reopen the window, enable
|
||||
automatic start, or quit the app.", "When a newer version is available the
|
||||
app notifies you and brings you to this page.", "Current version: {version}",
|
||||
"Beta build, commit {commit}", "Download for Windows", "Download for Linux",
|
||||
"{name} · {size}", "No desktop packages are available on this server yet.").
|
||||
|
||||
**Test `desktop-app-settings.test.tsx`**: next-intl-Mock wie in Task 1
|
||||
(Namensraum `settings`, `useLocale: () => 'de'`), `@/lib/desktop` gemockt.
|
||||
Faelle: (1) beide Plattformen -> Text "Aktuelle Version: 1.1.0", zwei Links
|
||||
mit den Testids, hrefs `…/desktop/download/windows` und `…/desktop/download/linux`,
|
||||
Zeile "Tessera-Setup-1.1.0.exe · 5,8 MB" (Groesse 6123456) und
|
||||
"Tessera-1.1.0.AppImage · 101,5 MB" (Groesse 106461688); (2) Kanal `beta`,
|
||||
Commit `abc1234` -> "Beta-Ausgabe, Stand abc1234"; (3) `null` -> Hinweistext
|
||||
sichtbar, keine Links (`queryByTestId` beide `null`), die vier Saetze
|
||||
weiterhin da (mindestens `intro` per Text geprueft).
|
||||
</action>
|
||||
<acceptance_criteria>
|
||||
- `pnpm --filter @tessera/web exec vitest run src/components/settings/desktop-app-settings.test.tsx` meldet 3 Tests bestanden, 0 fehlgeschlagen.
|
||||
- `test -f "apps/web/src/app/(portal)/settings/general/desktop/page.tsx"` endet mit 0; `grep -c 'DesktopAppSettings' "apps/web/src/app/(portal)/settings/general/desktop/page.tsx"` ergibt 2.
|
||||
- `grep -c 'href="/settings/general/desktop"' apps/web/src/components/settings/settings-sidebar.tsx` ergibt 1.
|
||||
- Parität und Umlaute: das node-Skript aus `<verify>` gibt `i18n OK` aus.
|
||||
- `pnpm --filter @tessera/web exec vitest run` — gesamte Web-Suite gruen (Basis am 2026-09-16: 52 Dateien / 354 Tests plus die neuen).
|
||||
</acceptance_criteria>
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web exec vitest run src/components/settings/desktop-app-settings.test.tsx src/components/desktop src/lib/desktop.test.ts && pnpm --filter @tessera/web type-check</automated>
|
||||
<fails_when>vitest meldet "failed" oder Exit-Code ungleich 0, oder tsc gibt Fehlerzeilen aus.</fails_when>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && node -e "const de=require('./apps/web/src/messages/de.json'),en=require('./apps/web/src/messages/en.json');const walk=(o,p='')=>Object.entries(o).flatMap(([k,v])=>typeof v==='object'&&v?walk(v,p+k+'.'):[p+k]);for(const ns of ['auth','settings']){const d=walk(de[ns]),e=walk(en[ns]);const miss=d.filter(k=>!e.includes(k)).concat(e.filter(k=>!d.includes(k)));if(miss.length){console.error('Fehlende Uebersetzungen in '+ns+':',miss);process.exit(1)}}const vals=o=>Object.values(o).flatMap(v=>typeof v==='object'&&v?vals(v):[String(v)]);const bad=vals({a:de.auth.desktopDownload,b:de.settings.desktop,c:{k:de.settings.categoryDesktopApp}}).filter(s=>/\b(fuer|ueber|koennen|Groesse|verfuegbar|oeffnen|schliessen|Oeffnen|Schliessen|laeuft|fuehrt)\b/i.test(s));if(bad.length){console.error('ASCII-Umschrift statt Umlaut:',bad);process.exit(1)}console.log('i18n OK')"</automated>
|
||||
<fails_when>Ausgabe `Fehlende Uebersetzungen` (Schluessel nur in einer Sprache) oder `ASCII-Umschrift statt Umlaut` (deutscher Text mit ae/oe/ue-Umschrift) und Exit 1; `i18n OK` fehlt.</fails_when>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web exec vitest run</automated>
|
||||
<fails_when>Irgendeine Datei der Web-Suite meldet "failed" — dann hat die Aenderung an de.json/en.json oder an der Seitenleiste bestehende Tests gebrochen.</fails_when>
|
||||
</verify>
|
||||
<done>
|
||||
Die Einstellungsseite existiert mit Knoepfen, Groesse, Saetzen und
|
||||
Hinweisfall, der Seitenleisteneintrag zeigt darauf, drei neue Tests gruen,
|
||||
die gesamte Web-Suite gruen, de/en vollstaendig und mit Umlauten.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Browser -> API (`/desktop/latest`, `/desktop/download/:platform`) | Oeffentliche Endpunkte; die Web-Oberflaeche rendert nur, was die API liefert. |
|
||||
| API-Antwort -> DOM (`href`, Dateiname, Groesse) | Werte aus dem Manifest landen als Linkziel und Text in der Seite. |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-18-07 | Tampering | `desktopDownloadUrl` (Linkziel aus API-Daten) | low | mitigate | Das Linkziel wird aus `API_URL` plus dem relativen `url`-Feld gebaut; die Komponenten uebernehmen nie eine absolute Adresse aus der Antwort, ein manipuliertes Manifest kann den Download also nicht auf einen fremden Host lenken. |
|
||||
| T-18-08 | Spoofing | Dateiname/Version als Text | low | accept | React rendert Text escaped; die Werte stammen aus dem vom CI geschriebenen Manifest (T-18-03 in 18-01). |
|
||||
| T-18-09 | Information Disclosure | Anmeldeseite zeigt Version vor der Anmeldung | low | accept | Beabsichtigt (D-12); gleiche Abwaegung wie T-18-05. |
|
||||
| T-18-SC | Tampering | Paketinstallationen | low | accept | Dieser Plan installiert kein neues Paket. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
1. `pnpm --filter @tessera/web exec vitest run` — gesamte Web-Suite gruen.
|
||||
2. `pnpm --filter @tessera/web type-check` — fehlerfrei.
|
||||
3. i18n-Paritaets- und Umlautpruefung gibt `i18n OK` aus.
|
||||
4. Browser-Gegenprobe am Phasenende (18-06): Link auf der Anmeldeseite, Seite
|
||||
unter Einstellungen -> Allgemein -> Desktop-App, Download startet.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Anmeldeseite: Link "Desktop-App herunterladen (Windows)" plus Linux-Link
|
||||
und Version, nur wenn die API antwortet.
|
||||
- Einstellungen -> Allgemein -> Desktop-App: Version, zwei Primaerknoepfe mit
|
||||
Symbol, Dateiname und Groesse, vier erklaerende Saetze, Hinweis bei fehlenden
|
||||
Paketen.
|
||||
- Alle Downloads laufen ueber die Tessera-API.
|
||||
- de/en vollstaendig, deutsche Texte mit Umlauten und in Sie-Form.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/18-desktop-client-fertigstellen/18-03-SUMMARY.md` when done.
|
||||
</output>
|
||||
@@ -0,0 +1,409 @@
|
||||
---
|
||||
phase: 18-desktop-client-fertigstellen
|
||||
plan: 04
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: ["18-01"]
|
||||
files_modified:
|
||||
- apps/desktop/src-tauri/src/lib.rs
|
||||
- apps/desktop/src-tauri/Cargo.toml
|
||||
- apps/desktop/src-tauri/Cargo.lock
|
||||
- apps/desktop/src-tauri/capabilities/default.json
|
||||
- apps/desktop/src-tauri/tauri.conf.json
|
||||
- apps/desktop/src/setup.html
|
||||
- apps/desktop/src-tauri/icons/icon.png
|
||||
- apps/desktop/src-tauri/icons/icon.ico
|
||||
- apps/desktop/src-tauri/icons/128x128.png
|
||||
- apps/desktop/src-tauri/icons/128x128@2x.png
|
||||
- apps/desktop/src-tauri/icons/32x32.png
|
||||
files_deleted:
|
||||
- apps/desktop/src-tauri/apps/desktop/src-tauri/icons/128x128.png
|
||||
- apps/desktop/src-tauri/apps/desktop/src-tauri/icons/32x32.png
|
||||
- apps/desktop/src-tauri/apps/desktop/src-tauri/icons/icon.ico
|
||||
- apps/desktop/src-tauri/apps/desktop/src-tauri/icons/icon.png
|
||||
autonomous: true
|
||||
requirements: [DESK-01, DESK-02, DESK-05]
|
||||
user_setup: []
|
||||
|
||||
estimate:
|
||||
tokens: 90000
|
||||
raw_tokens: 90000
|
||||
tasks: 2
|
||||
confidence: low
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Der Client vergleicht beim Start seine Version mit `{server}/api-proxy/desktop/latest`; weicht sie ab, zeigt er die Benachrichtigung 'Neue Version X.Y.Z verfügbar' und schaltet den Tray-Eintrag 'Update herunterladen' frei, der `{server}/settings/general/desktop` im Systembrowser öffnet (D-11, D-13)."
|
||||
- "Die Erststart-Seite fragt die Server-Adresse ab, prueft sie ueber `/api-proxy/health/version` (Rust-Kommando, kein CORS), speichert sie und laedt die Tessera-Anmeldung; Texte in Sie-Form, Tessera-Farben und -Logo (D-02, D-13)."
|
||||
- "Das Tray-Menue traegt 'Öffnen', 'Update herunterladen', den Haken 'Mit Windows starten' (auf Linux 'Beim Anmelden starten') und 'Beenden' — mit echten Umlauten; Schliessen-ins-Tray, Fensterzustand und Autostart-Plugin bleiben wie in Phase 6 (D-13, D-14)."
|
||||
- "Der Client traegt das Tessera-Zeichen als App- und Fenster-Icon (kein flaches gelbes Quadrat), und `cargo check`, `cargo clippy` sowie ein lokaler AppImage-Bau laufen durch (D-16)."
|
||||
artifacts:
|
||||
- path: "apps/desktop/src-tauri/src/lib.rs"
|
||||
provides: "Kommandos check_server und save_server_url, Versionspruefung gegen /desktop/latest, Tray-Eintraege update und autostart, Opener"
|
||||
contains: "check_server"
|
||||
- path: "apps/desktop/src-tauri/capabilities/default.json"
|
||||
provides: "opener:allow-open-url mit http/https-Scope"
|
||||
contains: "opener:allow-open-url"
|
||||
- path: "apps/desktop/src/setup.html"
|
||||
provides: "Erststart-Seite ohne Bundler-Import, ueber window.__TAURI__.core.invoke"
|
||||
contains: "__TAURI__"
|
||||
- path: "apps/desktop/src-tauri/icons/icon.ico"
|
||||
provides: "Mehrgroessen-ICO (16 bis 256) aus dem Tessera-Zeichen"
|
||||
key_links:
|
||||
- from: "apps/desktop/src-tauri/src/lib.rs"
|
||||
to: "apps/api/src/desktop/desktop.controller.ts"
|
||||
via: "GET {server}/api-proxy/desktop/latest — Feld version"
|
||||
pattern: "api-proxy/desktop/latest"
|
||||
- from: "apps/desktop/src/setup.html"
|
||||
to: "apps/desktop/src-tauri/src/lib.rs"
|
||||
via: "window.__TAURI__.core.invoke('check_server' | 'save_server_url')"
|
||||
pattern: "invoke\\('check_server'"
|
||||
- from: "apps/desktop/src-tauri/src/lib.rs (Tray 'update')"
|
||||
to: "apps/web/src/app/(portal)/settings/general/desktop/page.tsx"
|
||||
via: "opener().open_url(`{server}/settings/general/desktop`)"
|
||||
pattern: "settings/general/desktop"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Der Tauri-Client aus Phase 6 wird zum fertigen Produkt: Versionspruefung
|
||||
gegen `/desktop/latest` mit Update-Hinweis und Download-Link im Tray,
|
||||
Autostart-Haken im Tray, Umlaute in allen Tray-Texten, eine Erststart-Seite
|
||||
in Sie-Form mit Tessera-Gestalt, die die Adresse wirklich prueft, und ein
|
||||
echtes App-Icon. Der Windows-Bau wird in 18-05 in der Pipeline bewiesen; hier
|
||||
werden `cargo check`, `cargo clippy` und ein lokaler AppImage-Bau als Beweis
|
||||
vor dem Push verlangt (D-16).
|
||||
|
||||
Purpose: D-11, D-13 und D-14 aus 18-CONTEXT.md sowie Erfolgskriterium 3.
|
||||
Output: Geaenderte `lib.rs`, neues Plugin `tauri-plugin-opener`, erweiterte
|
||||
Capabilities, ueberarbeitete `setup.html`, Icon-Satz, lokal gebautes AppImage.
|
||||
|
||||
**Zwei Befunde aus der Planung, die dieser Plan behebt:**
|
||||
1. `setup.html` importiert das Store-Plugin als nacktes ES-Modul; ohne
|
||||
Bundler und ohne Importmap scheitert dieser Import im gebauten Client mit
|
||||
"Failed to resolve module specifier", der Knopf "Verbinden" tut dann
|
||||
nichts. Die Seite spricht kuenftig ausschliesslich ueber
|
||||
`window.__TAURI__.core.invoke` mit zwei Rust-Kommandos (`withGlobalTauri`
|
||||
ist bereits aktiv).
|
||||
2. Die API ist vom Client nur ueber den Web-Ursprung erreichbar
|
||||
(Next.js-Rewrite `/api-proxy/*`, siehe 18-01). Die bisherige Pruefung
|
||||
gegen `{server}/health/version` lief im Betrieb ins Leere; alle Aufrufe
|
||||
gehen jetzt ueber `{server}/api-proxy/...`.
|
||||
|
||||
**Discretion (Icon-Pruefung, Tray-Reihenfolge):** Die heutigen Icons sind
|
||||
flache gelbe Quadrate (32x32, 105 Bytes). Sie werden aus dem Web-Zeichen
|
||||
`apps/web/src/app/icon.svg` neu erzeugt. Tray-Reihenfolge: Öffnen ·
|
||||
Update herunterladen · — · Autostart-Haken · — · Beenden.
|
||||
</objective>
|
||||
|
||||
## Artifacts this phase produces
|
||||
|
||||
Dieser Plan: `apps/desktop/src-tauri/src/lib.rs` (Funktionen `api_url`,
|
||||
`check_server`, `save_server_url`, Struktur `DesktopLatest`, Tray-IDs
|
||||
`open`/`update`/`autostart`/`quit`), `Cargo.toml` (+`tauri-plugin-opener`),
|
||||
`Cargo.lock`, `capabilities/default.json` (`opener:allow-open-url`),
|
||||
`tauri.conf.json` (Icon-Liste, CSP ohne Fremdhost), `apps/desktop/src/setup.html`,
|
||||
`icons/icon.png` (512), `icons/128x128.png`, `icons/128x128@2x.png`,
|
||||
`icons/32x32.png`, `icons/icon.ico` (16-256). Entfernt: das versehentlich
|
||||
verschachtelte Verzeichnis `apps/desktop/src-tauri/apps/`. Gesamtliste der
|
||||
Phase: siehe 18-01-PLAN.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/06-desktop-client-ci-cd/06-02-SUMMARY.md
|
||||
|
||||
@apps/desktop/src-tauri/src/lib.rs
|
||||
@apps/desktop/src-tauri/Cargo.toml
|
||||
@apps/desktop/src-tauri/capabilities/default.json
|
||||
@apps/desktop/src-tauri/tauri.conf.json
|
||||
@apps/desktop/src/setup.html
|
||||
@apps/web/src/app/icon.svg
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: lib.rs — Kommandos fuer die Erststart-Seite, Versionspruefung gegen /desktop/latest, Tray mit Update und Autostart</name>
|
||||
<precondition>Rust/Cargo 1.96 mit Clippy ist installiert (`cargo clippy --version` antwortet), `cargo check` in `apps/desktop/src-tauri` ist am Stand von 18-01 gruen, und die Basislinie `1.1.0` aus 18-01 Task 2 ist eingecheckt.</precondition>
|
||||
<reversibility rating="reversible">Plugin-Einbindung und Tray-Aufbau sind lokal in einer Datei; ein Rueckbau ist ein Commit.</reversibility>
|
||||
<files>
|
||||
apps/desktop/src-tauri/src/lib.rs,
|
||||
apps/desktop/src-tauri/Cargo.toml,
|
||||
apps/desktop/src-tauri/Cargo.lock,
|
||||
apps/desktop/src-tauri/capabilities/default.json
|
||||
</files>
|
||||
<read_first>
|
||||
apps/desktop/src-tauri/src/lib.rs (gesamt, 121 Zeilen),
|
||||
apps/desktop/src-tauri/Cargo.toml,
|
||||
apps/desktop/src-tauri/capabilities/default.json,
|
||||
.planning/phases/18-desktop-client-fertigstellen/18-RESEARCH.md (Code Examples 4 und 5, "Package Legitimacy Audit"),
|
||||
.planning/phases/18-desktop-client-fertigstellen/18-PATTERNS.md (Abschnitte lib.rs, capabilities, Cargo.toml)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- `check_server(url)` (async Tauri-Kommando) prueft Schema http/https, ruft `{url}/api-proxy/health/version` mit 8 Sekunden Zeitlimit ab und liefert `Ok(version)`; jeder Fehler liefert `Err({deutsche Meldung in Sie-Form})`.
|
||||
- `save_server_url(url)` normalisiert die Adresse, schreibt `server_url` in `config.json` des Store-Plugins, speichert den Store und navigiert das Fenster `main` auf die Adresse.
|
||||
- Beim Start mit gespeicherter Adresse laeuft die Versionspruefung gegen `{server}/api-proxy/desktop/latest`; bei `version != CARGO_PKG_VERSION` erscheint die Benachrichtigung (Titel "Tessera-Update", Text "Neue Version X.Y.Z verfügbar – Download über das Symbol im Infobereich."), und der Tray-Eintrag `update` wird aktiviert und in "Version X.Y.Z herunterladen" umbenannt.
|
||||
- Tray-Eintrag `update` oeffnet `{server}/settings/general/desktop` im Systembrowser ueber `tauri-plugin-opener`.
|
||||
- Tray-Haken `autostart` spiegelt beim Start `autolaunch().is_enabled()`; ein Klick schaltet um und setzt den Haken auf den neuen Zustand.
|
||||
- Tray-Texte: "Öffnen", "Update herunterladen", "Mit Windows starten" (unter `cfg!(target_os = "windows")`, sonst "Beim Anmelden starten"), "Beenden".
|
||||
</behavior>
|
||||
<action>
|
||||
**Abhaengigkeit.** Im Verzeichnis `apps/desktop/src-tauri`
|
||||
`cargo add tauri-plugin-opener@2` ausfuehren (Legitimitaetspruefung in
|
||||
RESEARCH: `OK`, offizielles Plugin aus `tauri-apps/plugins-workspace`;
|
||||
gleiche unpinnte Major-Schreibweise wie die anderen `tauri-plugin-*`-Zeilen).
|
||||
`Cargo.lock` wird dabei aktualisiert und mit committet.
|
||||
|
||||
**Capabilities (`capabilities/default.json`).** An das `permissions`-Array
|
||||
das Objekt `{ "identifier": "opener:allow-open-url", "allow": [ { "url": "https://*" }, { "url": "http://*" } ] }`
|
||||
anhaengen (`http://*` wegen D-02: interne Server ohne TLS sind erlaubt,
|
||||
gleiche Begruendung wie die HTTP-Warnung der Erststart-Seite). Die
|
||||
Autostart-Rechte sind bereits vorhanden.
|
||||
|
||||
**`lib.rs` — Imports und Plugins.** Zusaetzlich `use tauri_plugin_opener::OpenerExt;`,
|
||||
`use tauri_plugin_autostart::ManagerExt;` (neben `MacosLauncher`),
|
||||
`tauri::menu::CheckMenuItemBuilder`, `tauri::AppHandle`, `std::time::Duration`.
|
||||
Plugin `.plugin(tauri_plugin_opener::init())` registrieren und
|
||||
`.invoke_handler(tauri::generate_handler![check_server, save_server_url])`
|
||||
vor `.setup(...)` einhaengen.
|
||||
|
||||
**Hilfsfunktion `fn api_url(server: &str, path: &str) -> String`**: liefert
|
||||
`format!("{}/api-proxy{}", server.trim_end_matches('/'), path)` — die einzige
|
||||
Stelle, an der der Rewrite-Praefix steht; Kommentar erklaert, warum
|
||||
(Next.js-Rewrite, API nicht unter dem Web-Hostnamen).
|
||||
|
||||
**Kommando `check_server`** (`#[tauri::command] async fn check_server(url: String) -> Result<String, String>`):
|
||||
`tauri::Url::parse` (Fehler: "Diese Adresse ist ungültig."), Schema
|
||||
`http`/`https` (sonst "Es sind nur Adressen mit http oder https erlaubt."),
|
||||
`reqwest::Client::builder().timeout(Duration::from_secs(8)).build()`,
|
||||
GET `api_url(&url, "/health/version")`; Netzfehler -> "Unter dieser Adresse
|
||||
antwortet kein Tessera-Server."; Nicht-2xx -> "Der Server antwortete mit
|
||||
Status {code}."; JSON in die bestehende Struktur `VersionResponse` (Feld
|
||||
`version`) -> `Ok(version)`. Meldungen sind Sie-Form-tauglich (keine
|
||||
Anrede), Umlaute als UTF-8.
|
||||
|
||||
**Kommando `save_server_url`** (`#[tauri::command] fn save_server_url(app: AppHandle, url: String) -> Result<(), String>`):
|
||||
Adresse parsen und als `String` normalisieren (`Url::as_str`), Store
|
||||
`config.json` ueber `app.store(...)`, `store.set("server_url", serde_json::json!(normalized))`,
|
||||
`store.save()` (Fehler als `String`), danach `get_webview_window("main")`
|
||||
und `navigate(parsed_url)`.
|
||||
|
||||
**Versionspruefung umbauen.** Struktur `DesktopLatest { version: String }`
|
||||
(`serde::Deserialize`). Im bestehenden `async_runtime::spawn`-Block die
|
||||
Adresse durch `api_url(&server_url, "/desktop/latest")` ersetzen, Antwort
|
||||
als `DesktopLatest` lesen; bei Abweichung Benachrichtigung mit Titel
|
||||
"Tessera-Update" und Text "Neue Version {v} verfügbar – Download über das
|
||||
Symbol im Infobereich." **und** am geklonten Handle des Tray-Eintrags
|
||||
`update` `set_text(format!("Version {v} herunterladen"))` und
|
||||
`set_enabled(true)` aufrufen (Rueckgaben mit `let _ =` ignorieren, wie
|
||||
bisher).
|
||||
|
||||
**Tray-Menue.** Eintraege in dieser Reihenfolge: `open` "Öffnen";
|
||||
`update` "Update herunterladen" mit `.enabled(false)` beim Bau (wird erst
|
||||
nach der Pruefung freigeschaltet); Trenner; `autostart` als
|
||||
`CheckMenuItemBuilder::with_id("autostart", label)` mit `.checked(app.autolaunch().is_enabled().unwrap_or(false))`,
|
||||
Label `if cfg!(target_os = "windows") { "Mit Windows starten" } else { "Beim Anmelden starten" }`;
|
||||
Trenner; `quit` "Beenden". Fuer `on_menu_event` vorher
|
||||
`let server_for_menu = url_for_check.clone();` und
|
||||
`let autostart_for_menu = autostart.clone();` anlegen (die Handles sind
|
||||
`Clone + Send + Sync`). Neue `match`-Arme: `"update"` -> wenn eine Adresse
|
||||
gespeichert ist, `app.opener().open_url(format!("{}/settings/general/desktop", server.trim_end_matches('/')), None::<&str>)`;
|
||||
`"autostart"` -> `let mgr = app.autolaunch(); let on = mgr.is_enabled().unwrap_or(false);`
|
||||
dann `mgr.disable()` bzw. `mgr.enable()`, bei Erfolg `set_checked(!on)`,
|
||||
bei Fehler `set_checked(on)` (Haken bleibt bei der Wahrheit). Die
|
||||
bestehenden Arme `open`/`quit`, `on_tray_icon_event`, `on_window_event`
|
||||
(Schliessen-ins-Tray) und `RunEvent::ExitRequested` bleiben unveraendert
|
||||
(D-14). Bestehender Kommentar "Tray menu" um zwei Saetze zu den neuen
|
||||
Eintraegen ergaenzen.
|
||||
|
||||
Nach dem Umbau `cargo check` und `cargo clippy` ausfuehren; Clippy-Warnungen
|
||||
in den **geaenderten** Zeilen beheben (bestehende Warnungen andernorts nur
|
||||
beheben, wenn trivial).
|
||||
</action>
|
||||
<acceptance_criteria>
|
||||
- `grep -c '^tauri-plugin-opener = "2"' apps/desktop/src-tauri/Cargo.toml` ergibt 1; `grep -c 'name = "tauri-plugin-opener"' apps/desktop/src-tauri/Cargo.lock` ergibt 1.
|
||||
- `grep -c '"opener:allow-open-url"' apps/desktop/src-tauri/capabilities/default.json` ergibt 1.
|
||||
- `grep -v '^\s*//' apps/desktop/src-tauri/src/lib.rs | grep -c 'fn check_server'` ergibt 1; ebenso `fn save_server_url` 1, `fn api_url` 1, `api-proxy` mindestens 1, `tauri_plugin_opener::init()` 1, `generate_handler!\[check_server, save_server_url\]` 1.
|
||||
- `grep -c '"Öffnen"' apps/desktop/src-tauri/src/lib.rs` ergibt 1; `grep -c '"Mit Windows starten"' apps/desktop/src-tauri/src/lib.rs` ergibt 1; `grep -c 'CheckMenuItemBuilder::with_id("autostart"' apps/desktop/src-tauri/src/lib.rs` ergibt 1; `grep -c 'settings/general/desktop' apps/desktop/src-tauri/src/lib.rs` ergibt 1.
|
||||
- `grep -c '/desktop/latest' apps/desktop/src-tauri/src/lib.rs` ergibt 1.
|
||||
- `cargo check` und `cargo clippy` in `apps/desktop/src-tauri` enden mit Exit 0.
|
||||
</acceptance_criteria>
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl/apps/desktop/src-tauri && cargo check 2>&1 | tail -1 | grep -q Finished && cargo clippy 2>&1 | tail -1 | grep -q Finished && echo RUST-OK</automated>
|
||||
<fails_when>`cargo check` oder `cargo clippy` endet nicht mit einer `Finished`-Zeile (Kompilier- oder Clippy-Fehler) — `RUST-OK` fehlt.</fails_when>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && grep -v '^\s*//' apps/desktop/src-tauri/src/lib.rs | grep -q 'fn check_server' && grep -q 'fn save_server_url' apps/desktop/src-tauri/src/lib.rs && grep -q 'api-proxy' apps/desktop/src-tauri/src/lib.rs && grep -q '"Öffnen"' apps/desktop/src-tauri/src/lib.rs && grep -q 'CheckMenuItemBuilder::with_id("autostart"' apps/desktop/src-tauri/src/lib.rs && grep -q 'settings/general/desktop' apps/desktop/src-tauri/src/lib.rs && grep -q '"opener:allow-open-url"' apps/desktop/src-tauri/capabilities/default.json && grep -q '^tauri-plugin-opener = "2"' apps/desktop/src-tauri/Cargo.toml && echo WIRING-OK</automated>
|
||||
<fails_when>Eines der Kennzeichen (Kommandos, Rewrite-Praefix, Umlaut-Label, Autostart-Haken, Update-Link, Capability, Abhaengigkeit) fehlt — `WIRING-OK` fehlt.</fails_when>
|
||||
</verify>
|
||||
<done>
|
||||
`lib.rs` kompiliert mit Opener-Plugin, beiden Kommandos, Versionspruefung
|
||||
gegen `/api-proxy/desktop/latest`, Tray mit Update-Eintrag und
|
||||
Autostart-Haken und Umlaut-Texten; Capabilities und Cargo-Dateien sind
|
||||
nachgezogen; `cargo check`/`cargo clippy` gruen.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Erststart-Seite in Tessera-Gestalt und Sie-Form, echtes App-Icon, lokaler AppImage-Beweis</name>
|
||||
<precondition>ImageMagick 7 (`magick`) ist auf dem Entwicklungsrechner vorhanden (am 2026-09-16 geprueft: 7.1.1, mit SVG- und ICO-Unterstuetzung); die Tauri-Linux-Abhaengigkeiten aus 18-01 Task 1 sind installiert.</precondition>
|
||||
<files>
|
||||
apps/desktop/src/setup.html,
|
||||
apps/desktop/src-tauri/tauri.conf.json,
|
||||
apps/desktop/src-tauri/icons/icon.png,
|
||||
apps/desktop/src-tauri/icons/icon.ico,
|
||||
apps/desktop/src-tauri/icons/128x128.png,
|
||||
apps/desktop/src-tauri/icons/128x128@2x.png,
|
||||
apps/desktop/src-tauri/icons/32x32.png
|
||||
</files>
|
||||
<read_first>
|
||||
apps/desktop/src/setup.html (gesamt, 254 Zeilen),
|
||||
apps/desktop/src-tauri/tauri.conf.json,
|
||||
apps/web/src/app/icon.svg (Tessera-Zeichen, 72x72),
|
||||
apps/web/src/components/brand/brand.ts (BRAND_YELLOW #ffed00, BRAND_OLIVE #9c9440, BRAND_PLATE #1a1a1a),
|
||||
.gitea/scripts/desktop-collect.sh (aus 18-01)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- Die Seite laedt keinen Modulcode von aussen und importiert kein npm-Paket; sie nutzt `window.__TAURI__.core.invoke`.
|
||||
- Klick auf "Verbinden" (oder Enter): Adresse pruefen (leer, ungueltig, falsches Schema -> Fehlertext in Sie-Form), dann `invoke('check_server', { url })`; bei Fehler erscheint die Meldung des Kommandos, der Knopf ist wieder bedienbar; bei Erfolg erscheint kurz "Tessera {version} gefunden – Verbindung wird hergestellt …" und `invoke('save_server_url', { url })` fuehrt zur Tessera-Anmeldung.
|
||||
- Bei http ausserhalb von localhost bleibt die Warnung (Sie-Form) sichtbar, die Verbindung ist erlaubt (D-02).
|
||||
- Die Seite zeigt das Tessera-Zeichen (inline-SVG aus `icon.svg`) und den Schriftzug "Tessera" in Markengelb auf dunklem Grund; keine vorbelegte Server-Adresse, nur der Platzhalter `https://tessera.example.com`.
|
||||
- Das App-Icon ist das Tessera-Zeichen in 512x512 (PNG) und als ICO mit den Groessen 16, 32, 48, 64, 128, 256.
|
||||
- `pnpm --filter @tessera/desktop exec tauri build --bundles appimage` laeuft lokal durch und `desktop-collect.sh` sammelt `Tessera-1.1.0.AppImage` ein.
|
||||
</behavior>
|
||||
<action>
|
||||
**`setup.html` — Skript.** Den `<script type="module">`-Block umschreiben:
|
||||
kein `import`-Statement mehr; am Anfang `const { invoke } = window.__TAURI__.core;`.
|
||||
`validateUrl` behalten (Logik unveraendert), Meldungen ersetzen:
|
||||
leer -> "Bitte geben Sie die Adresse Ihres Tessera-Servers ein.";
|
||||
ungueltig -> "Diese Adresse ist ungültig. Bitte geben Sie eine vollständige
|
||||
Adresse ein, z. B. https://tessera.example.com."; Schema -> "Es sind nur
|
||||
Adressen mit http oder https erlaubt."; Warnung -> "Hinweis: Diese Verbindung
|
||||
ist unverschlüsselt (http). Für den Produktivbetrieb empfehlen wir https.".
|
||||
`connect()`: nach der Pruefung Knopf sperren, Text "Prüfe Verbindung …",
|
||||
`const version = await invoke('check_server', { url: normalizedUrl })` im
|
||||
`try`; im `catch` `showError(String(err))` und Knopf freigeben ("Verbinden");
|
||||
bei Erfolg `showInfo('Tessera ' + version + ' gefunden – Verbindung wird
|
||||
hergestellt …')` (neue Hilfsfunktion und ein `<p id="info-msg">` im gleichen
|
||||
Stil wie die Warnung, gruenliche Farbe) und `await invoke('save_server_url', { url: normalizedUrl })`;
|
||||
schlaegt das Speichern fehl: "Die Adresse konnte nicht gespeichert werden: …".
|
||||
Enter-Taste und Eingabe-Reset bleiben.
|
||||
|
||||
**`setup.html` — Markup und Gestalt (Sie-Form, Tessera-Farben).** Ueber der
|
||||
Ueberschrift das Tessera-Zeichen als inline-SVG (Inhalt von
|
||||
`apps/web/src/app/icon.svg`, Breite 56px), `<h1>` "Tessera" in `#ffed00`,
|
||||
Untertitel "Desktop-App einrichten", Label "Adresse Ihres Tessera-Servers",
|
||||
darunter ein Hilfstext `<p class="hint">` "Das ist die Adresse, unter der
|
||||
Sie Tessera auch im Browser öffnen." Das `value`-Attribut des Eingabefelds
|
||||
entfernen (keine vorbelegte Adresse — ein Paket fuer alle Umgebungen, D-02),
|
||||
Platzhalter `https://tessera.example.com` bleibt. Knopf "Verbinden" in
|
||||
Markengelb mit dunkler Schrift (`#1a1a1a`), Karte dunkel
|
||||
(`oklch(0.22 0.01 260)`), Rahmenfarbe in Olive (`#9c9440`) fuer Fokus.
|
||||
Alle Texte mit echten Umlauten (`<meta charset="UTF-8">` steht bereits).
|
||||
`<title>` "Tessera – Desktop-App einrichten".
|
||||
|
||||
**CSP (`tauri.conf.json`).** In `app.security.csp` bei `script-src` den
|
||||
Fremdhost-Eintrag entfernen, sodass dort nur noch `'self' 'unsafe-inline' 'unsafe-eval'`
|
||||
steht (die Seite laedt nichts mehr von aussen; T-18-11). `connect-src *`
|
||||
bleibt (D-02).
|
||||
|
||||
**Icons.** Aus `apps/web/src/app/icon.svg` erzeugen (im Verzeichnis
|
||||
`apps/desktop/src-tauri/icons`): `magick -background none -density 512 ../../../web/src/app/icon.svg -resize 512x512 icon.png`;
|
||||
daraus `128x128.png` (128), `128x128@2x.png` (256), `32x32.png` (32) per
|
||||
`-resize`; `icon.ico` mit `magick icon.png -define icon:auto-resize=256,128,64,48,32,16 icon.ico`.
|
||||
In `tauri.conf.json` `bundle.icon` auf
|
||||
`["icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.png", "icons/icon.ico"]`
|
||||
setzen. Das versehentlich verschachtelte, versionierte Verzeichnis
|
||||
`apps/desktop/src-tauri/apps/` mit `git rm -r` entfernen (Rest aus Phase 6).
|
||||
|
||||
**Lokaler Beweis (D-16).** `rm -rf apps/desktop/src-tauri/target/release/bundle`,
|
||||
dann `pnpm --filter @tessera/desktop exec tauri build --bundles appimage`
|
||||
(Version bleibt `1.1.0` aus der Basislinie), danach
|
||||
`sh .gitea/scripts/desktop-collect.sh --require linux`. Im SUMMARY die
|
||||
Baudauer und die Groesse des AppImage festhalten. Optional, wenn eine
|
||||
grafische Sitzung vorhanden ist: das AppImage starten, Erststart-Seite
|
||||
ansehen, `http://localhost:3000` eingeben, Anmeldung sehen; Ergebnis im
|
||||
SUMMARY notieren (kein Pflichtschritt — die Windows-Probe macht der Nutzer
|
||||
am Phasenende).
|
||||
</action>
|
||||
<acceptance_criteria>
|
||||
- `grep -c "window.__TAURI__.core" apps/desktop/src/setup.html` ergibt 1; `grep -c "invoke('check_server'" apps/desktop/src/setup.html` ergibt 1; `grep -c "invoke('save_server_url'" apps/desktop/src/setup.html` ergibt 1.
|
||||
- `grep -c '^\s*import ' apps/desktop/src/setup.html` ergibt 0 (kein Modul-Import mehr).
|
||||
- `grep -c 'unpkg.com' apps/desktop/src-tauri/tauri.conf.json` ergibt 0.
|
||||
- `grep -c 'value="http://localhost:3000"' apps/desktop/src/setup.html` ergibt 0; `grep -c 'Adresse Ihres Tessera-Servers' apps/desktop/src/setup.html` ergibt mindestens 1.
|
||||
- `magick identify -format '%wx%h\n' apps/desktop/src-tauri/icons/icon.png` ergibt `512x512`; `magick identify apps/desktop/src-tauri/icons/icon.ico | wc -l` ergibt 6.
|
||||
- `test ! -e apps/desktop/src-tauri/apps` endet mit 0 (verschachteltes Verzeichnis per `git rm -r` entfernt).
|
||||
- `jq -r '.bundle.icon | length' apps/desktop/src-tauri/tauri.conf.json` ergibt 5.
|
||||
- `desktop-dist/manifest.json` traegt `Tessera-1.1.0.AppImage` aus dem frischen Bau (Zeitstempel des AppImage neuer als der von Task 1 geaenderten lib.rs).
|
||||
</acceptance_criteria>
|
||||
<!-- planner-discipline-allow: unpkg.com -->
|
||||
<!-- planner-discipline-allow: value="http://localhost:3000" -->
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && grep -q "window.__TAURI__.core" apps/desktop/src/setup.html && grep -q "invoke('check_server'" apps/desktop/src/setup.html && grep -q "invoke('save_server_url'" apps/desktop/src/setup.html && test "$(grep -c '^\s*import ' apps/desktop/src/setup.html)" = "0" && test "$(grep -c 'unpkg.com' apps/desktop/src-tauri/tauri.conf.json)" = "0" && test "$(magick identify -format '%wx%h' apps/desktop/src-tauri/icons/icon.png)" = "512x512" && test "$(magick identify apps/desktop/src-tauri/icons/icon.ico | wc -l)" = "6" && test ! -e apps/desktop/src-tauri/apps && echo SETUP-OK</automated>
|
||||
<fails_when>Ein Kennzeichen fehlt, ein Modul-Import ist noch da, der Fremdhost steht noch in der CSP, ein Icon hat die falsche Groesse/Anzahl, oder das verschachtelte Verzeichnis ist noch versioniert — `SETUP-OK` fehlt.</fails_when>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && test -n "$(find apps/desktop/src-tauri/target/release/bundle/appimage -name '*.AppImage' -newer apps/desktop/src-tauri/src/lib.rs)" && sh .gitea/scripts/desktop-collect.sh --require linux && test "$(jq -r .files.linux.name desktop-dist/manifest.json)" = "Tessera-1.1.0.AppImage" && echo APPIMAGE-OK</automated>
|
||||
<fails_when>Kein AppImage, das neuer als die geaenderte `lib.rs` ist (der lokale Bau lief nicht oder scheiterte), das Sammel-Skript bricht ab, oder das Manifest nennt nicht `Tessera-1.1.0.AppImage` — `APPIMAGE-OK` fehlt.</fails_when>
|
||||
</verify>
|
||||
<done>
|
||||
Die Erststart-Seite spricht nur noch ueber `invoke`, prueft die Adresse
|
||||
serverseitig, ist in Sie-Form und Tessera-Gestalt; die CSP laedt nichts von
|
||||
aussen; der Icon-Satz zeigt das Tessera-Zeichen; ein frischer lokaler
|
||||
AppImage-Bau mit dem neuen Client liegt eingesammelt in `desktop-dist/`.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Erststart-Seite -> Rust-Kommandos (`invoke`) | Die vom Anwender eingegebene Adresse wird an `check_server`/`save_server_url` uebergeben. |
|
||||
| Client -> Server (`/api-proxy/health/version`, `/api-proxy/desktop/latest`) | Ausgehende HTTP-Aufrufe an die gespeicherte Adresse. |
|
||||
| Tray -> Systembrowser (`opener`) | Der Client oeffnet eine Adresse ausserhalb der App. |
|
||||
| WebView -> entfernte Web-App | Nach dem Erststart laeuft die Tessera-Web-App im WebView (unveraendert seit Phase 6). |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-18-10 | Spoofing / Tampering | `check_server`/`save_server_url` (beliebige Adresse) | low | mitigate | Nur `http`/`https`, Adresse wird geparst und normalisiert; der Aufruf geht nur an die vom Anwender selbst eingegebene Adresse, ausschliesslich auf dessen Rechner (kein Server-seitiges SSRF). Zeitlimit 8 s. |
|
||||
| T-18-11 | Tampering | CSP der lokalen Seite (Fremdhost in `script-src`) | low | mitigate | Fremdhost entfernt; die Seite laedt keinen externen Code mehr. |
|
||||
| T-18-12 | Elevation of Privilege | Tray `update` (Opener) | low | mitigate | Adresse wird aus der gespeicherten `server_url` gebaut, nie aus Serverdaten; Capability auf `http://*`/`https://*` beschraenkt (kein `file:`/Schema-Missbrauch). |
|
||||
| T-18-13 | Spoofing | Update-Hinweis aus `/desktop/latest` (falsche Version vorgetaeuscht) | low | accept | Der Hinweis fuehrt nur auf die Tessera-Seite; kein Auto-Update, kein Download ohne Nutzeraktion (D-03). |
|
||||
| T-18-14 | Information Disclosure | Unsignierte Binaries / SmartScreen | low | accept | Keine Code-Signierung in dieser Phase (D-09); Erklaerung im Anwenderhandbuch (18-06). |
|
||||
| T-18-SC | Tampering | `cargo add tauri-plugin-opener@2` | low | mitigate | Legitimitaetspruefung in RESEARCH: `OK` (tauri-apps/plugins-workspace, 374k Downloads/Woche); Lockfile committet; kein `[ASSUMED]`/`[SUS]`-Paket, daher keine Sperr-Freigabe noetig. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
1. `cargo check` und `cargo clippy` in `apps/desktop/src-tauri` — gruen.
|
||||
2. Kennzeichen-Greps fuer Kommandos, Rewrite-Praefix, Umlaute, Capability,
|
||||
Abhaengigkeit, Icon-Groessen — alle erfuellt.
|
||||
3. Lokaler AppImage-Bau nach dem Umbau erfolgreich, `desktop-collect.sh`
|
||||
liefert `Tessera-1.1.0.AppImage`.
|
||||
4. Der Windows-Bau desselben Stands wird in 18-05 in der Pipeline bewiesen;
|
||||
die Bedienprobe (Erststart, Tray, Anmeldung) macht der Nutzer am
|
||||
Phasenende (18-06).
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Erststart-Seite prueft die Adresse wirklich, speichert sie und fuehrt zur
|
||||
Anmeldung; Sie-Form, Tessera-Gestalt, kein Fremdcode.
|
||||
- Tray: Öffnen · Update herunterladen · Autostart-Haken · Beenden, mit
|
||||
Umlauten; Update-Eintrag oeffnet die Download-Seite im Browser.
|
||||
- Versionspruefung gegen `/api-proxy/desktop/latest` mit Benachrichtigung.
|
||||
- Echtes App-Icon; `cargo check`/`clippy` und lokaler AppImage-Bau gruen.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/18-desktop-client-fertigstellen/18-04-SUMMARY.md` when done.
|
||||
Im SUMMARY festhalten: Baudauer und Groesse des AppImage, ob eine grafische
|
||||
Probe moeglich war, und alle Clippy-Warnungen, die bewusst stehen blieben.
|
||||
</output>
|
||||
@@ -0,0 +1,308 @@
|
||||
---
|
||||
phase: 18-desktop-client-fertigstellen
|
||||
plan: 05
|
||||
type: execute
|
||||
wave: 3
|
||||
depends_on: ["18-02", "18-04"]
|
||||
files_modified:
|
||||
- .gitea/workflows/ci.yml
|
||||
- .gitea/scripts/desktop-collect.sh
|
||||
- .gitea/scripts/publish-release.sh
|
||||
- apps/desktop/src-tauri/Cargo.toml
|
||||
- apps/desktop/src-tauri/Cargo.lock
|
||||
autonomous: false
|
||||
requirements: [DESK-01, DESK-04, DESK-05]
|
||||
user_setup: []
|
||||
|
||||
estimate:
|
||||
tokens: 70000
|
||||
raw_tokens: 70000
|
||||
tasks: 3
|
||||
confidence: low
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Der CI-Job desktop baut auf dem Linux-Runner zusaetzlich den Windows-Installer per Cross-Bau (cargo-xwin, NSIS) und sammelt `Tessera-Setup-X.Y.Z.exe` neben `Tessera-X.Y.Z.AppImage` ein; das Manifest traegt beide Plattformen (D-04, D-05)."
|
||||
- "Ein Push auf main endet mit einem gruenen Lauf: Job desktop mit beiden Dateien, Job publish mit Abbildern, die die Pakete tragen (D-06, D-08)."
|
||||
- "Jeder Fehlschlag der Pipeline wird gelesen, der Job angepasst, erneut gepusht — hoechstens drei Runden, jede als normaler Commit (D-16)."
|
||||
artifacts:
|
||||
- path: ".gitea/workflows/ci.yml"
|
||||
provides: "Windows-Cross-Bau-Schritte im Job desktop, Einsammeln mit --require linux,windows"
|
||||
contains: "cargo-xwin"
|
||||
key_links:
|
||||
- from: ".gitea/workflows/ci.yml (Schritt Windows NSIS Cross-Bau)"
|
||||
to: ".gitea/scripts/desktop-collect.sh"
|
||||
via: "Bundle-Verzeichnis target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe -> Tessera-Setup-X.Y.Z.exe"
|
||||
pattern: "x86_64-pc-windows-msvc"
|
||||
- from: ".gitea/workflows/ci.yml (desktop)"
|
||||
to: ".gitea/workflows/ci.yml (publish)"
|
||||
via: "actions/cache Schluessel desktop-dist-${{ gitea.sha }} (aus 18-02)"
|
||||
pattern: "desktop-dist-"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Der Windows-Installer entsteht im selben CI-Job wie das AppImage — als
|
||||
Cross-Bau auf dem Linux-Runner (`cargo tauri build --runner cargo-xwin --target x86_64-pc-windows-msvc --bundles nsis`,
|
||||
NSIS aus dem Ubuntu-Paket). Weil dieser Bau nur in der Pipeline beweisbar ist
|
||||
(kein Windows-Werkzeug auf dem Entwicklungsrechner, siehe RESEARCH), enthaelt
|
||||
der Plan die in D-16 vorgesehene Iterationsschleife: pushen, Protokoll lesen,
|
||||
Job anpassen, erneut pushen — hoechstens drei Runden.
|
||||
|
||||
Purpose: D-04, D-05, D-06 und D-16 aus 18-CONTEXT.md; Erfolgskriterium 1
|
||||
(bis auf den Release-Anhang, der erst beim naechsten Freigabe-Tag sichtbar
|
||||
wird — der Upload-Pfad selbst ist in 18-02 gebaut und per Probelauf geprueft).
|
||||
Output: Erweiterter Job `desktop`, gruener Pipeline-Lauf mit beiden Dateien,
|
||||
Beta-Abbilder mit Paketen.
|
||||
|
||||
**Rollen:** Der Executor pusht nie. Der Orchestrator pusht (`git push`; die
|
||||
Push-Adresse zeigt auf `localhost:3002`), beobachtet den Lauf in Gitea und
|
||||
meldet Status und Protokollauszug zurueck. Der Executor liest, behebt,
|
||||
committet.
|
||||
</objective>
|
||||
|
||||
## Artifacts this phase produces
|
||||
|
||||
Dieser Plan: `.gitea/workflows/ci.yml` (Schritte "Windows-Werkzeuge",
|
||||
"Windows-Installer bauen (Cross-Bau)", erweiterte Cache-Pfade, Einsammeln
|
||||
mit `--require linux,windows`); bei Bedarf Korrekturen an
|
||||
`.gitea/scripts/desktop-collect.sh`, `.gitea/scripts/publish-release.sh`
|
||||
(`GITEA_API`-Umgehung) und `apps/desktop/src-tauri/Cargo.toml`
|
||||
(`rustls-tls`-Ausweichlösung). Gesamtliste der Phase: siehe 18-01-PLAN.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-01-SUMMARY.md
|
||||
@.planning/phases/18-desktop-client-fertigstellen/18-02-SUMMARY.md
|
||||
@.planning/phases/18-desktop-client-fertigstellen/18-04-SUMMARY.md
|
||||
|
||||
@.gitea/workflows/ci.yml
|
||||
@.gitea/scripts/desktop-collect.sh
|
||||
@.gitea/scripts/publish-release.sh
|
||||
@apps/desktop/src-tauri/Cargo.toml
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Windows-Cross-Bau in den Job desktop einbauen</name>
|
||||
<reversibility rating="reversible">Reine Workflow-Schritte; Rueckbau ist ein Commit, kein Zustand ausserhalb des Runners ausser dem Cache.</reversibility>
|
||||
<files>
|
||||
.gitea/workflows/ci.yml
|
||||
</files>
|
||||
<read_first>
|
||||
.gitea/workflows/ci.yml (Job desktop aus 18-02),
|
||||
.gitea/scripts/desktop-collect.sh (Windows-Zweig: Bundle-Pfad und Zielname),
|
||||
.planning/phases/18-desktop-client-fertigstellen/18-RESEARCH.md (Abschnitte "Pattern 1", "Standard Stack: Installation", "Common Pitfalls 2-5", "Open Questions 2-3"),
|
||||
.planning/phases/18-desktop-client-fertigstellen/18-CONTEXT.md (Abschnitt "Specific Ideas": Runner 8 Kerne/15 GB, nacheinander im selben Job)
|
||||
</read_first>
|
||||
<action>
|
||||
Im Job `desktop` (Datei `.gitea/workflows/ci.yml`) folgende Aenderungen,
|
||||
Schrittnamen deutsch:
|
||||
|
||||
1. Schritt "Systemabhaengigkeiten": die apt-Liste um `lld llvm clang nsis`
|
||||
erweitern (alle vier am 2026-09-16 im Runner-Abbild per `apt-cache policy`
|
||||
bestaetigt: lld/llvm/clang 18, nsis 3.09).
|
||||
2. Neuer Schritt "Windows-Werkzeuge" nach "Rust-Toolchain":
|
||||
`rustup target add x86_64-pc-windows-msvc` und
|
||||
`command -v cargo-xwin >/dev/null 2>&1 || cargo install --locked cargo-xwin`
|
||||
(Legitimitaetspruefung in RESEARCH: `OK`, rust-cross/cargo-xwin, 0.23.1).
|
||||
3. Schritt "Cargo-Zwischenspeicher": `path` um `~/.cargo/bin/cargo-xwin`,
|
||||
`~/.cache/cargo-xwin` (Windows-SDK-Ablage von cargo-xwin, mehrere hundert
|
||||
MB, soll nur einmal geladen werden) und `~/.local/share/tauri` (NSIS-Plugins,
|
||||
die der Tauri-Bundler beim ersten Windows-Bau laedt) erweitern.
|
||||
4. Schritt "Alte Bundles entfernen": zusaetzlich
|
||||
`rm -rf apps/desktop/src-tauri/target/x86_64-pc-windows-msvc/release/bundle`.
|
||||
5. Neuer Schritt "Windows-Installer bauen (Cross-Bau)" **nach** dem
|
||||
AppImage-Schritt (nacheinander, ein Job, ein Cache — CONTEXT "Specific
|
||||
Ideas"): `pnpm --filter @tessera/desktop exec tauri build --runner cargo-xwin --target x86_64-pc-windows-msvc --bundles nsis`.
|
||||
6. Schritt "Pakete einsammeln": `--require linux,windows`.
|
||||
7. Kopfkommentar des Jobs: zwei Saetze zum Cross-Bau und zum Grund, warum
|
||||
die Version rein numerisch bleibt (Pitfall 2).
|
||||
|
||||
Keine `-j`-Begrenzung und keine `CARGO_BUILD_JOBS`-Vorgabe im ersten Anlauf;
|
||||
beides ist eine Ausweichlösung der Schleife (Task 3), falls der Runner den
|
||||
Speicher ausschoepft. `desktop-collect.sh` braucht keine Aenderung, wenn der
|
||||
Windows-Zweig aus 18-01 (desktop-collect.sh) den Pfad
|
||||
`target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe` bereits kennt —
|
||||
pruefen, sonst nachziehen.
|
||||
</action>
|
||||
<acceptance_criteria>
|
||||
- `grep -c 'cargo-xwin' .gitea/workflows/ci.yml` ergibt mindestens 3 (Install, Cache-Pfad, Bauschritt).
|
||||
- `grep -c -- '--target x86_64-pc-windows-msvc --bundles nsis' .gitea/workflows/ci.yml` ergibt 1.
|
||||
- `grep -c 'desktop-collect.sh --require linux,windows' .gitea/workflows/ci.yml` ergibt 1; `grep -c 'desktop-collect.sh --require linux$' .gitea/workflows/ci.yml` ergibt 0.
|
||||
- Die apt-Zeile enthaelt `nsis`, `lld`, `llvm` und `clang` (`grep -E 'lld llvm clang nsis|nsis' .gitea/workflows/ci.yml`).
|
||||
- `grep -c 'x86_64-pc-windows-msvc/release/bundle/nsis' .gitea/scripts/desktop-collect.sh` ergibt mindestens 1.
|
||||
- `sh -n .gitea/scripts/desktop-collect.sh` endet mit 0.
|
||||
</acceptance_criteria>
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && test "$(grep -c 'cargo-xwin' .gitea/workflows/ci.yml)" -ge 3 && grep -q -- '--target x86_64-pc-windows-msvc --bundles nsis' .gitea/workflows/ci.yml && grep -q 'desktop-collect.sh --require linux,windows' .gitea/workflows/ci.yml && grep -q 'nsis' .gitea/workflows/ci.yml && grep -q 'x86_64-pc-windows-msvc/release/bundle/nsis' .gitea/scripts/desktop-collect.sh && sh -n .gitea/scripts/desktop-collect.sh && node -e "const y=require('fs').readFileSync('.gitea/workflows/ci.yml','utf8');const d=y.indexOf('\n desktop:'),p=y.indexOf('\n publish:');if(d===-1||p===-1||d>p)process.exit(1);const job=y.slice(d,p);if(job.indexOf('--bundles appimage')>job.indexOf('--bundles nsis'))process.exit(2)" && echo WINDOWS-STEPS-OK</automated>
|
||||
<fails_when>Ein Kennzeichen fehlt, das Einsammeln fordert Windows nicht, das Sammel-Skript kennt den NSIS-Pfad nicht, oder der Windows-Schritt steht vor dem AppImage-Schritt (Exit 2) — `WINDOWS-STEPS-OK` fehlt.</fails_when>
|
||||
</verify>
|
||||
<done>
|
||||
Der Job `desktop` installiert Windows-Werkzeuge, baut nach dem AppImage den
|
||||
NSIS-Installer per Cross-Bau, cached SDK und NSIS-Plugins und sammelt beide
|
||||
Dateien ein. Commit liegt bereit fuer den Push.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:human-action" gate="blocking">
|
||||
<name>Task 2: Push und CI-Lauf beobachten — gruen mit beiden Dateien?</name>
|
||||
<precondition>Der Commit aus Task 1 (bzw. aus der letzten Runde von Task 3) liegt lokal auf `main`; der Gitea-Runner `gitea-runner` laeuft (Container aktiv), das Secret `REGISTRY_TOKEN` ist gesetzt.</precondition>
|
||||
<action>Den Stand nach Gitea pushen und den Pipeline-Lauf "Tessera CI/CD" beobachten — der Executor darf nicht pushen (Projektregel: Push nur durch Orchestrator/Nutzer, Push-Adresse zeigt dauerhaft auf `localhost:3002`, nie ueber `git.vicolab.de`).</action>
|
||||
<instructions>
|
||||
Der Executor hat den Job `desktop` um den Windows-Cross-Bau erweitert,
|
||||
Skripte und Workflow statisch geprueft und committet. Was jetzt nur der
|
||||
Orchestrator kann: `git push` auf `main` und den Lauf in Gitea verfolgen
|
||||
(Gitea-MCP oder Weboberflaeche). Der erste Lauf dauert deutlich laenger als
|
||||
bisher (Rust-Toolchain, zwei Release-Baue, Windows-SDK-Download); erst mit
|
||||
warmem Cache sinkt die Zeit.
|
||||
|
||||
Zurueckmelden — je nach Ausgang:
|
||||
|
||||
**Gruen:** Aus dem Job `desktop`, Schritt "Pakete einsammeln", die beiden
|
||||
Ausgabezeilen (`windows: Tessera-Setup-1.1.0-beta.{sha}.exe (…)` und
|
||||
`linux: Tessera-1.1.0-beta.{sha}.AppImage (…)`), dazu Status des Jobs
|
||||
`publish` (gruen) und die Zeile mit den gepushten Etiketten.
|
||||
|
||||
**Rot:** Name des gescheiterten Jobs und Schritts sowie die letzten rund 60
|
||||
Protokollzeilen dieses Schritts (mit der eigentlichen Fehlermeldung — bei
|
||||
Rust die Zeilen ab `error:` bzw. `error[E…]`, bei apt die Zeile `E:`, bei
|
||||
curl den HTTP-Code und die Antwort). Diese Runde zaehlt (Runde 1 von
|
||||
hoechstens 3).
|
||||
</instructions>
|
||||
<verification>Der Lauf ist gruen; Schritt "Pakete einsammeln" nennt genau eine `.exe` und genau ein `.AppImage`; der Job `publish` hat die Beta-Abbilder gepusht. Der Executor prueft nach der Rueckmeldung zusaetzlich `git status --porcelain` (leer) und dass `git log -1 --format=%H` dem vom Orchestrator genannten Lauf-Commit entspricht.</verification>
|
||||
<resume-signal>Antworte mit "gruen" plus den beiden Dateizeilen — oder mit "rot" plus Job, Schritt und Protokollauszug.</resume-signal>
|
||||
<verify>
|
||||
<human-check>Der Lauf ist gruen; Schritt "Pakete einsammeln" nennt genau eine `.exe` und genau ein `.AppImage`; der Job `publish` hat die Beta-Abbilder gepusht.</human-check>
|
||||
</verify>
|
||||
<done>
|
||||
Rueckmeldung liegt vor. Bei "gruen" ist der Plan fertig (Task 3 entfaellt).
|
||||
Bei "rot" geht es mit Task 3 weiter.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Iterationsschleife — Fehler lesen, Job anpassen, erneut pushen (hoechstens drei Runden)</name>
|
||||
<files>
|
||||
.gitea/workflows/ci.yml,
|
||||
.gitea/scripts/desktop-collect.sh,
|
||||
.gitea/scripts/publish-release.sh,
|
||||
apps/desktop/src-tauri/Cargo.toml,
|
||||
apps/desktop/src-tauri/Cargo.lock
|
||||
</files>
|
||||
<read_first>
|
||||
Der vom Orchestrator gelieferte Protokollauszug,
|
||||
.planning/phases/18-desktop-client-fertigstellen/18-RESEARCH.md (Abschnitte "Common Pitfalls 1-5", "Open Questions", "Assumptions Log"),
|
||||
.gitea/workflows/ci.yml,
|
||||
.gitea/scripts/desktop-collect.sh
|
||||
</read_first>
|
||||
<action>
|
||||
Nur ausfuehren, wenn Task 2 "rot" gemeldet hat. Je Runde: Ursache aus dem
|
||||
Protokoll bestimmen, **eine** gezielte Aenderung machen, lokal pruefen
|
||||
(`sh -n` fuer Skripte, `cargo check` bei Cargo-Aenderungen), committen mit
|
||||
`ci(desktop): Runde N — {Ursache in fuenf Woertern}`, dann zurueck zu
|
||||
Task 2 (der Orchestrator pusht und meldet). Nach der dritten roten Runde
|
||||
**stoppen** und dem Nutzer den Stand mit dem letzten Protokollauszug
|
||||
vorlegen (kein vierter Versuch ohne Ruecksprache).
|
||||
|
||||
Bekannte Fehlerbilder und die jeweils vorgesehene Aenderung (in dieser
|
||||
Reihenfolge pruefen):
|
||||
|
||||
| Signatur im Protokoll | Ursache | Aenderung |
|
||||
|---|---|---|
|
||||
| `E: Unable to locate package …` | Paketname falsch/umbenannt | Namen mit `docker run --rm gitea/runner-images:ubuntu-latest sh -c 'apt-get update -qq; apt-cache policy {name}'` pruefen und in der apt-Zeile korrigieren |
|
||||
| `The system library '…' required by crate '…' was not found` (pkg-config) | dev-Paket fehlt | fehlendes `lib…-dev` in die apt-Zeile aufnehmen (Pitfall 5) |
|
||||
| `failed to run custom build command for openssl-sys` beim Ziel `x86_64-pc-windows-msvc` | TLS-Backend zieht OpenSSL fuer das Windows-Ziel | in `Cargo.toml` `reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }` (Pitfall 3), lokal `cargo check`, `Cargo.lock` mit committen |
|
||||
| `makensis: not found` / `NSIS … not installed` | NSIS fehlt im PATH | `nsis` in der apt-Zeile pruefen; sonst Pfad `/usr/bin/makensis` per `which makensis` im Protokoll ausgeben lassen |
|
||||
| `failed to download NSIS plugin` / `nsis_tauri_utils` | Netz/GitHub | gleicher Stand, erneut pushen (leerer Commit `ci(desktop): Runde N — erneuter Lauf`) |
|
||||
| `llvm-rc` / `rc.exe` / `winres` / `embed-resource` | Ressourcen-Compiler nicht gefunden | `sudo ln -sf /usr/bin/llvm-rc-18 /usr/bin/llvm-rc` im Schritt "Windows-Werkzeuge" oder `env: RC: llvm-rc-18` am Bauschritt |
|
||||
| `xwin` / `Failed to download` / `manifest` beim ersten Cross-Bau | Windows-SDK-Download | erneut pushen; falls wiederholt: `env: XWIN_ARCH: x86_64` und `XWIN_CACHE_DIR: ${{ github.workspace }}/.xwin-cache` (dann `.xwin-cache` in die Cache-Pfade) |
|
||||
| `optional build metadata in app version must be numeric-only` | Version nicht numerisch | `git describe`-Ausgabe im Protokoll pruefen; `desktop-version.sh` haette abbrechen muessen — Regex im Skript nachziehen |
|
||||
| `genau eine Datei erwartet` (Sammel-Skript) | Bundle-Pfad oder Altbestand | Pfad mit `find apps/desktop/src-tauri/target -name '*.exe' -path '*bundle*'` im Protokoll ermitteln und im Skript anpassen; Aufraeum-Schritt pruefen |
|
||||
| `Cache service responded with 4xx/5xx` / `Failed to save` / `fail-on-cache-miss` obwohl gespeichert | actions/cache-Version vs. Cache-Server | `actions/cache/save@v3` und `actions/cache/restore@v3` (Open Question 3); bleibt es rot: `target` aus den Cache-Pfaden nehmen (zu gross) |
|
||||
| `Killed` / `signal: 9` / `memory` waehrend `rustc` | Speicher | `env: CARGO_BUILD_JOBS: 4` am Job (CONTEXT "Specific Ideas") |
|
||||
| Job-Zeitueberschreitung | Baudauer plus Cache-Sicherung | `target` aus den Cache-Pfaden nehmen, `~/.cargo/registry` und xwin-Ablage behalten |
|
||||
| `docker build` scheitert an `COPY desktop-dist` | Verzeichnis fehlt im Kontext | Cache-Restore-Pfad und `test -f`-Schritt in `publish` pruefen |
|
||||
| Release-Upload `413` (nur bei Tags) | Proxy-Groessengrenze vor `git.vicolab.de` | am Release-Schritt `env: GITEA_API: http://172.18.0.1:3002/api/v1` (Host-Adresse, ueber die der Runner auch seinen Cache-Server erreicht) |
|
||||
| Release-Upload `400` mit "file type" (nur bei Tags) | Gitea `[repository.release] ALLOWED_TYPES` eingeschraenkt | nicht im Repository loesbar — dem Nutzer melden (Server-Einstellung); Voreinstellung der Instanz laesst alle Typen zu (geprueft 2026-09-16) |
|
||||
| `cargo clippy` Fehler | Code | Stelle beheben, `cargo clippy` lokal gruen |
|
||||
|
||||
Jede Runde im SUMMARY festhalten: Signatur, Ursache, Aenderung, Commit.
|
||||
Trifft keine Signatur zu, die Ursache aus dem Protokoll ableiten und die
|
||||
kleinste plausible Aenderung waehlen; im Zweifel zuerst mehr Protokoll
|
||||
anfordern (z. B. `RUST_BACKTRACE=1` oder `--verbose` am Bauschritt), statt
|
||||
zu raten.
|
||||
</action>
|
||||
<acceptance_criteria>
|
||||
- Jede Runde ist genau ein Commit mit Praefix `ci(desktop): Runde N —` (`git log --oneline -5 | grep -c 'ci(desktop): Runde'` entspricht der Rundenzahl).
|
||||
- Nach jeder Aenderung: `sh -n` fuer geaenderte Skripte endet mit 0; bei Cargo-Aenderungen endet `cargo check` in `apps/desktop/src-tauri` mit 0.
|
||||
- Es gibt nie mehr als drei Runden; nach der dritten roten Runde wird der Stand dem Nutzer vorgelegt statt weiter zu pushen.
|
||||
- Am Ende: Task 2 hat "gruen" mit genau einer `.exe`- und einer `.AppImage`-Zeile gemeldet.
|
||||
</acceptance_criteria>
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && sh -n .gitea/scripts/desktop-collect.sh && sh -n .gitea/scripts/publish-release.sh && sh -n .gitea/scripts/desktop-version.sh && (cd apps/desktop/src-tauri && cargo check 2>&1 | tail -1 | grep -q Finished) && test "$(git rev-list --count --grep='ci(desktop): Runde' HEAD~6..HEAD)" -le 3 && echo ROUND-OK</automated>
|
||||
<fails_when>Ein Skript hat einen Syntaxfehler, `cargo check` scheitert nach einer Cargo-Aenderung, oder es gibt mehr als drei Runden-Commits — `ROUND-OK` fehlt.</fails_when>
|
||||
<human-check>Der Orchestrator bestaetigt nach der letzten Runde einen gruenen Lauf mit beiden Dateizeilen (Task 2).</human-check>
|
||||
</verify>
|
||||
<done>
|
||||
Ein gruener Pipeline-Lauf auf `main` mit `Tessera-Setup-1.1.0-beta.{sha}.exe`
|
||||
und `Tessera-1.1.0-beta.{sha}.AppImage` im Schritt "Pakete einsammeln" und
|
||||
gruenem `publish`; hoechstens drei dokumentierte Runden.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Runner -> Internet (rustup, crates.io, Microsoft-SDK ueber xwin, NSIS-Plugins von GitHub) | Der Cross-Bau laedt Werkzeuge und das Windows-SDK aus dem Netz. |
|
||||
| Runner-Cache -> Bau | Aus dem Cache wiederhergestellte Artefakte (SDK, target/) fliessen in das Paket ein. |
|
||||
| Gebautes `.exe` -> Anwender-PC | Unsigniert; SmartScreen warnt (D-09). |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-18-15 | Tampering | Werkzeugketten-Download (cargo-xwin, SDK, NSIS-Plugins) | medium | mitigate | `cargo install --locked cargo-xwin` (Lockfile des Werkzeugs), rustup von der offiziellen Adresse, SDK ueber cargo-xwin (prueft Microsoft-Manifest-Hashes), NSIS aus dem Ubuntu-Archiv; Tauri laedt seine NSIS-Plugins mit hinterlegten Pruefsummen. |
|
||||
| T-18-16 | Tampering | Cache-Vergiftung (`target/`, xwin-Ablage) | low | accept | Der Cache-Server laeuft nur lokal fuer diesen Runner (`172.18.0.1`), keine fremden Schreiber; Schluessel haengt am `Cargo.lock`-Hash. |
|
||||
| T-18-17 | Repudiation | Iterationsschleife | low | mitigate | Jede Runde ist ein eigener Commit mit Ursache im Titel und im SUMMARY dokumentiert. |
|
||||
| T-18-18 | Information Disclosure | Protokollauszuege (Token) | low | mitigate | Gitea maskiert Secrets im Log; `publish-release.sh` gibt das Token nie aus (T-18-03). |
|
||||
| T-18-SC | Tampering | `cargo install --locked cargo-xwin` (crates.io) | low | mitigate | Legitimitaetspruefung in RESEARCH: `OK` (rust-cross/cargo-xwin, seit 2022, 63k Downloads/Woche); kein `[ASSUMED]`/`[SUS]`, keine Sperr-Freigabe noetig. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
1. Statische Pruefung des Workflows (Kennzeichen, Reihenfolge AppImage vor
|
||||
NSIS, Einsammeln mit beiden Plattformen).
|
||||
2. Gruener Pipeline-Lauf auf `main` (Rueckmeldung des Orchestrators) mit
|
||||
beiden Dateizeilen und gruenem `publish`.
|
||||
3. Hoechstens drei dokumentierte Runden.
|
||||
4. Nach dem Lauf traegt das Beta-Abbild die Pakete — sichtbar, sobald der
|
||||
Nutzer den Testserver auf den neuen Stand zieht (18-06).
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Der Job `desktop` erzeugt auf dem Linux-Runner `Tessera-Setup-X.Y.Z[…].exe`
|
||||
und `Tessera-X.Y.Z[…].AppImage` in einem Lauf.
|
||||
- Der Lauf ist gruen; `publish` hat Beta-Abbilder mit Paketen gepusht.
|
||||
- Die Iterationsschleife ist dokumentiert und endete spaetestens nach drei
|
||||
Runden.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/18-desktop-client-fertigstellen/18-05-SUMMARY.md` when done.
|
||||
Im SUMMARY festhalten: Dauer des ersten und (falls vorhanden) eines zweiten
|
||||
Laufs mit warmem Cache, Groesse beider Dateien laut Sammel-Schritt, und die
|
||||
Tabelle der Runden (Signatur, Ursache, Aenderung, Commit).
|
||||
</output>
|
||||
@@ -0,0 +1,446 @@
|
||||
---
|
||||
phase: 18-desktop-client-fertigstellen
|
||||
plan: 06
|
||||
type: execute
|
||||
wave: 4
|
||||
depends_on: ["18-01", "18-02", "18-03", "18-04", "18-05"]
|
||||
files_modified:
|
||||
- docs/anleitung-anwender.md
|
||||
- docs/anleitung-betrieb.md
|
||||
- docs/anleitung-entwicklung.md
|
||||
- docs/ci-cd-setup.md
|
||||
- CHANGELOG.md
|
||||
- .planning/REQUIREMENTS.md
|
||||
autonomous: true
|
||||
requirements: [DESK-01, DESK-02, DESK-03, DESK-04, DESK-05]
|
||||
user_setup: []
|
||||
|
||||
estimate:
|
||||
tokens: 60000
|
||||
raw_tokens: 60000
|
||||
tasks: 3
|
||||
confidence: low
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Das Anwenderhandbuch hat ein Kapitel 'Desktop-App' mit Download in Tessera, Installation (Windows mit SmartScreen-Hinweis, Linux AppImage), Erststart mit Server-Adresse, Infobereich/Schliessen/Beenden, Autostart und Update-Hinweis (D-15)."
|
||||
- "Das Betriebshandbuch beschreibt den Pipeline-Job, den Cross-Bau, den Ablageort der Pakete im Abbild, die Release-Dateien, die Umgebungsvariable und die Fehlerbilder (D-15)."
|
||||
- "Das Entwicklungshandbuch fuehrt apps/desktop nicht mehr als Grundgeruest und beschreibt den lokalen Bau samt Voraussetzungen (D-15)."
|
||||
- "CHANGELOG 'Unveröffentlicht' -> '### Neu' traegt den Stichpunkt zur Desktop-App (D-17); REQUIREMENTS.md fuehrt DESK-01..05 mit Nachverfolgung."
|
||||
- "Alle Test-Suiten (API, Web) und Typpruefungen sind gruen; der Nutzer hat den Windows-Installer auf seinem PC durchgespielt (Erfolgskriterium 3)."
|
||||
artifacts:
|
||||
- path: "docs/anleitung-anwender.md"
|
||||
provides: "Kapitel '## Desktop-App' mit sieben Unterabschnitten"
|
||||
contains: "## Desktop-App"
|
||||
- path: "docs/anleitung-betrieb.md"
|
||||
provides: "Kapitel '## 10. Desktop-App: Pakete und Release-Dateien'"
|
||||
contains: "## 10. Desktop-App"
|
||||
- path: "docs/anleitung-entwicklung.md"
|
||||
provides: "Abschnitt '### Desktop-App lokal bauen'"
|
||||
contains: "Desktop-App lokal bauen"
|
||||
- path: "docs/ci-cd-setup.md"
|
||||
provides: "Job desktop im Pipeline-Ueberblick, Fehlerbehebung fuer Cross-Bau und Cache"
|
||||
contains: "desktop"
|
||||
- path: "CHANGELOG.md"
|
||||
provides: "Stichpunkt Desktop-App unter Unveröffentlicht/Neu"
|
||||
contains: "Desktop-App für Windows und Linux"
|
||||
- path: ".planning/REQUIREMENTS.md"
|
||||
provides: "Kategorie DESK mit DESK-01..05 und Traceability-Zeilen"
|
||||
contains: "DESK-05"
|
||||
key_links:
|
||||
- from: "docs/anleitung-anwender.md"
|
||||
to: "apps/web/src/messages/de.json"
|
||||
via: "Die im Handbuch genannten Beschriftungen entsprechen den de.json-Texten (Link- und Knopftexte, Tray-Eintraege)"
|
||||
pattern: "Desktop-App herunterladen"
|
||||
- from: "docs/anleitung-betrieb.md"
|
||||
to: "apps/api/src/desktop/desktop.service.ts"
|
||||
via: "Ablageort /app/desktop-dist und Variable DESKTOP_DIST_DIR"
|
||||
pattern: "DESKTOP_DIST_DIR"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Die Phase wird abgeschlossen: Handbuecher fuer Anwender, Betrieb und
|
||||
Entwicklung beschreiben die Desktop-App, die Pipeline und die
|
||||
Release-Dateien; CHANGELOG und REQUIREMENTS werden nachgezogen; alle Suiten
|
||||
laufen; und der Nutzer prueft den Windows-Installer auf seinem PC nach
|
||||
einer genauen Schrittfolge (Erfolgskriterien 3 und 4).
|
||||
|
||||
Purpose: D-15 und D-17 aus 18-CONTEXT.md; Nachverfolgung DESK-03/04/05.
|
||||
Output: Vier Dokumente, CHANGELOG-Stichpunkt, REQUIREMENTS-Abschnitt,
|
||||
gruene Gesamtlaeufe, Bedienprobe des Nutzers.
|
||||
|
||||
Alle Handbuchtexte in Sie-Form, mit echten Umlauten, ohne firmenspezifische
|
||||
Adressen (Platzhalter `https://tessera.example.com`; die Testserver-Adresse
|
||||
steht nur in der Bedienprobe fuer den Nutzer, nicht im Handbuch).
|
||||
</objective>
|
||||
|
||||
## Artifacts this phase produces
|
||||
|
||||
Dieser Plan: `docs/anleitung-anwender.md` (Kapitel "Desktop-App"),
|
||||
`docs/anleitung-betrieb.md` (Kapitel 10), `docs/anleitung-entwicklung.md`
|
||||
(Abschnitt "Desktop-App lokal bauen", Aktualisierung Monorepo-Aufbau und
|
||||
Tests), `docs/ci-cd-setup.md` (Job `desktop`, Fehlerbehebung),
|
||||
`CHANGELOG.md` (Stichpunkt), `.planning/REQUIREMENTS.md` (Kategorie DESK).
|
||||
Gesamtliste der Phase: siehe 18-01-PLAN.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-01-SUMMARY.md
|
||||
@.planning/phases/18-desktop-client-fertigstellen/18-02-SUMMARY.md
|
||||
@.planning/phases/18-desktop-client-fertigstellen/18-03-SUMMARY.md
|
||||
@.planning/phases/18-desktop-client-fertigstellen/18-04-SUMMARY.md
|
||||
@.planning/phases/18-desktop-client-fertigstellen/18-05-SUMMARY.md
|
||||
|
||||
@docs/anleitung-anwender.md
|
||||
@docs/anleitung-betrieb.md
|
||||
@docs/anleitung-entwicklung.md
|
||||
@docs/ci-cd-setup.md
|
||||
@CHANGELOG.md
|
||||
@.planning/REQUIREMENTS.md
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Anwenderhandbuch — Kapitel "Desktop-App"; CHANGELOG-Stichpunkt</name>
|
||||
<files>
|
||||
docs/anleitung-anwender.md,
|
||||
CHANGELOG.md
|
||||
</files>
|
||||
<read_first>
|
||||
docs/anleitung-anwender.md (Inhaltsverzeichnis Zeilen 6-22, Kapitel "Persönliche Einstellungen" ab Zeile 143 und "Einen Fehler melden" ab Zeile 160 als Stilvorlage),
|
||||
CHANGELOG.md (Zeilen 1-14),
|
||||
apps/web/src/messages/de.json (Bloecke `auth.desktopDownload` und `settings.desktop` aus 18-03 — Beschriftungen woertlich uebernehmen),
|
||||
apps/desktop/src-tauri/src/lib.rs (Tray-Texte und Benachrichtigungstext aus 18-04),
|
||||
apps/desktop/src/setup.html (Texte der Erststart-Seite aus 18-04)
|
||||
</read_first>
|
||||
<action>
|
||||
**Kapitel einfuegen** zwischen `## Persönliche Einstellungen` und
|
||||
`## Einen Fehler melden`: `## Desktop-App`, im Inhaltsverzeichnis als neuer
|
||||
Punkt 8 (`[Desktop-App](#desktop-app)`), die folgenden Punkte auf 9-11
|
||||
umnummerieren. Unterabschnitte (`###`) in dieser Reihenfolge, Sie-Form,
|
||||
kurze Absaetze, Beschriftungen exakt wie in der Oberflaeche:
|
||||
|
||||
1. **Was die Desktop-App ist** — eigenes Fenster statt Browser-Tab, Symbol
|
||||
im Infobereich der Taskleiste, dieselben Funktionen wie im Browser.
|
||||
2. **Herunterladen** — auf der Anmeldeseite unter dem Formular
|
||||
„Desktop-App herunterladen (Windows)" und „Linux-Version"; oder
|
||||
angemeldet unter Einstellungen → Allgemein → Desktop-App mit Version,
|
||||
Dateiname und Dateigroesse. Kein Zugang zu Gitea noetig.
|
||||
3. **Installation unter Windows** — Datei `Tessera-Setup-X.Y.Z.exe`
|
||||
ausfuehren; Windows-SmartScreen zeigt „Der Computer wurde durch Windows
|
||||
geschützt": auf „Weitere Informationen" und dann „Trotzdem ausführen"
|
||||
klicken; Grund in einem Satz (die App ist fuer den internen Gebrauch
|
||||
nicht signiert, das Paket stammt aus Ihrem Tessera-Server). Danach
|
||||
Startmenue-Eintrag „Tessera". Eine neuere Version wird einfach
|
||||
darueber installiert; die Server-Adresse bleibt erhalten.
|
||||
4. **Installation unter Linux** — `Tessera-X.Y.Z.AppImage` ausfuehrbar
|
||||
machen (Dateieigenschaften oder `chmod +x`) und starten; keine
|
||||
Installation noetig.
|
||||
5. **Erster Start: Server-Adresse** — die Adresse, unter der Sie Tessera im
|
||||
Browser oeffnen (Beispiel `https://tessera.example.com`); die App prueft
|
||||
die Adresse und meldet „Tessera X.Y.Z gefunden"; bei `http` erscheint
|
||||
ein Hinweis, die Verbindung ist trotzdem moeglich; danach die gewohnte
|
||||
Anmeldung.
|
||||
6. **Fenster, Infobereich und Beenden** — Schliessen (X) legt Tessera in
|
||||
den Infobereich; Linksklick auf das Symbol oeffnet das Fenster;
|
||||
Rechtsklick zeigt „Öffnen", „Update herunterladen", „Mit Windows
|
||||
starten" (Haken; unter Linux „Beim Anmelden starten") und „Beenden";
|
||||
nur „Beenden" beendet die App; Fenstergroesse und -position werden
|
||||
gemerkt.
|
||||
7. **Automatischer Start** — Haken im Menue setzen/entfernen; ab Werk aus.
|
||||
8. **Neue Version** — Benachrichtigung „Neue Version X.Y.Z verfügbar" beim
|
||||
Start, Menueeintrag „Version X.Y.Z herunterladen" oeffnet die Seite
|
||||
Einstellungen → Desktop-App im Browser; dort herunterladen und wie oben
|
||||
installieren. Kein automatisches Update.
|
||||
9. **Wenn etwas nicht klappt** — drei Faelle: „Unter dieser Adresse
|
||||
antwortet kein Tessera-Server" (Adresse pruefen, es ist die
|
||||
Browser-Adresse, nicht eine interne API-Adresse); der Download-Link fehlt
|
||||
auf der Anmeldeseite (der Server traegt noch keine Pakete — Betrieb
|
||||
fragen); SmartScreen blockiert (siehe Installation).
|
||||
|
||||
**CHANGELOG** (`## Unveröffentlicht` → `### Neu`): als neuen Stichpunkt in
|
||||
der bestehenden Liste `- Desktop-App für Windows und Linux: Download auf der
|
||||
Anmeldeseite und unter Einstellungen → Desktop-App` (D-17, Wortlaut exakt).
|
||||
</action>
|
||||
<acceptance_criteria>
|
||||
- `grep -c '^## Desktop-App$' docs/anleitung-anwender.md` ergibt 1; `grep -c '(#desktop-app)' docs/anleitung-anwender.md` ergibt 1.
|
||||
- `grep -c '^### ' docs/anleitung-anwender.md` ist um 9 groesser als vorher (neun Unterabschnitte); die Ueberschriften enthalten `Herunterladen`, `Installation unter Windows`, `Installation unter Linux`, `Erster Start`, `Infobereich`, `Automatischer Start`, `Neue Version`.
|
||||
- `grep -c 'Trotzdem ausführen' docs/anleitung-anwender.md` ergibt mindestens 1; `grep -c 'Desktop-App herunterladen (Windows)' docs/anleitung-anwender.md` ergibt mindestens 1; `grep -c 'Mit Windows starten' docs/anleitung-anwender.md` ergibt mindestens 1.
|
||||
- `grep -c 'ctl.de\|vicolab' docs/anleitung-anwender.md` ergibt 0 im neuen Kapitel (keine firmenspezifische Adresse).
|
||||
- `grep -c '^- Desktop-App für Windows und Linux: Download auf der Anmeldeseite und unter Einstellungen → Desktop-App$' CHANGELOG.md` ergibt 1, und die Zeile steht oberhalb der ersten `## 1.` Versionsueberschrift.
|
||||
</acceptance_criteria>
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && grep -q '^## Desktop-App$' docs/anleitung-anwender.md && grep -q '(#desktop-app)' docs/anleitung-anwender.md && grep -q 'Trotzdem ausführen' docs/anleitung-anwender.md && grep -q 'Desktop-App herunterladen (Windows)' docs/anleitung-anwender.md && grep -q 'Mit Windows starten' docs/anleitung-anwender.md && test "$(awk '/^## Desktop-App$/{f=1;next} /^## /{f=0} f' docs/anleitung-anwender.md | grep -c '^### ')" -ge 9 && test "$(awk '/^## Desktop-App$/{f=1;next} /^## /{f=0} f' docs/anleitung-anwender.md | grep -ci 'ctl\.de\|vicolab')" = "0" && node -e "const c=require('fs').readFileSync('CHANGELOG.md','utf8');const u=c.indexOf('## Unveröffentlicht'),v=c.search(/\n## [0-9]/);const b=c.indexOf('- Desktop-App für Windows und Linux: Download auf der Anmeldeseite und unter Einstellungen → Desktop-App');if(u===-1||b===-1||b>v||b<u)process.exit(1)" && echo DOCS1-OK</automated>
|
||||
<fails_when>Kapitel, Inhaltsverzeichnis-Eintrag, eine Pflichtbeschriftung oder ein Unterabschnitt fehlt, das Kapitel nennt eine Firmenadresse, oder der CHANGELOG-Stichpunkt steht nicht unter „Unveröffentlicht" — `DOCS1-OK` fehlt.</fails_when>
|
||||
</verify>
|
||||
<done>
|
||||
Kapitel „Desktop-App" mit neun Unterabschnitten im Anwenderhandbuch samt
|
||||
Inhaltsverzeichnis; CHANGELOG-Stichpunkt im Wortlaut von D-17.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Betriebshandbuch Kapitel 10, CI/CD-Runbook, Entwicklungshandbuch</name>
|
||||
<files>
|
||||
docs/anleitung-betrieb.md,
|
||||
docs/ci-cd-setup.md,
|
||||
docs/anleitung-entwicklung.md
|
||||
</files>
|
||||
<read_first>
|
||||
docs/anleitung-betrieb.md (Inhaltsverzeichnis Zeilen 12-22, Kapitel 8 ab Zeile 343, Kapitel 9 "Eine Version freigeben" ab Zeile 430),
|
||||
docs/ci-cd-setup.md (Abschnitt 4 "Pipeline-Ueberblick" ab Zeile 108, Abschnitt 6 "Fehlerbehebung" ab Zeile 211),
|
||||
docs/anleitung-entwicklung.md (Zeilen 23-58 Monorepo-Aufbau, "Lokale Entwicklungsumgebung" ab Zeile 58, "Tests" ab Zeile 403),
|
||||
.gitea/workflows/ci.yml (Endstand nach 18-05),
|
||||
.gitea/scripts/desktop-version.sh, .gitea/scripts/desktop-collect.sh, .gitea/scripts/publish-release.sh (Kopfkommentare),
|
||||
apps/api/src/desktop/desktop.service.ts (Variable DESKTOP_DIST_DIR, Vorgabepfad),
|
||||
.planning/phases/18-desktop-client-fertigstellen/18-05-SUMMARY.md (Rundentabelle — reale Fehlerbilder in die Fehlerbehebung uebernehmen)
|
||||
</read_first>
|
||||
<action>
|
||||
**`docs/anleitung-betrieb.md`** — neues `## 10. Desktop-App: Pakete und
|
||||
Release-Dateien` am Ende, Inhaltsverzeichnis um Punkt 10 ergaenzen. Der
|
||||
Sprachstil des Dokuments (Sie-Form, nummerierte Kapitel, `###`-Abschnitte).
|
||||
Abschnitte: `### Woher die Pakete kommen` (Job `desktop` nach `test`, auf
|
||||
`main` und bei Tags `v*`; Linux-AppImage und Windows-Installer per
|
||||
Cross-Bau auf dem Linux-Runner in einem Job; Einzelheiten der Werkzeugkette
|
||||
in `docs/ci-cd-setup.md`, Abschnitt 4); `### Wo die Pakete im Abbild
|
||||
liegen` (`/app/desktop-dist/` im API-Abbild mit `manifest.json`, Dateien
|
||||
`Tessera-Setup-X.Y.Z.exe` und `Tessera-X.Y.Z.AppImage`, auf Beta mit Suffix
|
||||
`-beta.{commit}`; Kontrolle: `docker compose exec api ls -l /app/desktop-dist`
|
||||
und `curl -s https://{ihre-adresse}/api-proxy/desktop/latest`; Ausgabe
|
||||
erklaeren); `### Release-Dateien in Gitea` (bei Tags haengt die Pipeline
|
||||
beide Dateien an den Release; die Datei am Release ist dieselbe wie im
|
||||
Abbild — Pruefsumme `sha256` aus dem Manifest); `### Umgebungsvariablen`
|
||||
(keine neue Pflichtvariable; optional `DESKTOP_DIST_DIR`, Vorgabe
|
||||
`/app/desktop-dist`; Tabelle im Stil von Kapitel 3); `### Fehlerbilder`
|
||||
als Tabelle Symptom → Ursache → Massnahme: Download-Link fehlt auf der
|
||||
Anmeldeseite bzw. `/api-proxy/desktop/latest` liefert 404 → Abbild ohne
|
||||
Pakete (Job `publish` haette abbrechen muessen; Lauf pruefen, erneut
|
||||
ausrollen); Download bricht bei grossen Dateien ab → Groessengrenze des
|
||||
vorgeschalteten Proxys (Nginx Proxy Manager, `client_max_body_size` bzw.
|
||||
Zeitlimits); Client meldet „Unter dieser Adresse antwortet kein
|
||||
Tessera-Server" → Anwender hat die API- statt der Web-Adresse eingetragen
|
||||
oder `/api-proxy` ist vom Client-Rechner nicht erreichbar; Windows warnt
|
||||
(SmartScreen) → erwartet, keine Signatur (Anwenderhandbuch). In Kapitel 9,
|
||||
Abschnitt „Eine Version freigeben", einen Satz ergaenzen: der Tag baut auch
|
||||
die Desktop-Pakete und haengt sie an den Release (Kapitel 10).
|
||||
|
||||
**`docs/ci-cd-setup.md`** — Abschnitt 4: aus „drei" werden „vier" Jobs;
|
||||
Job `desktop` zwischen `test` und `publish` beschreiben: Bedingung (`main`
|
||||
und Tags `v*`), Schritte (Rust per rustup, apt-Pakete, `cargo-xwin`,
|
||||
`rustup target add x86_64-pc-windows-msvc`, Version aus dem Tag per
|
||||
`desktop-version.sh` — immer rein numerisch, Grund Windows-Ressourcen;
|
||||
AppImage, dann NSIS-Cross-Bau; `desktop-collect.sh` mit Manifest;
|
||||
Uebergabe an `publish` per `actions/cache` mit Schluessel `desktop-dist-{sha}`
|
||||
und **warum nicht** upload-artifact (auf Gitea unzuverlaessig);
|
||||
Cache-Pfade und Schluessel `desktop-cargo-<Cargo.lock-Hash>`); `publish`:
|
||||
Restore mit hartem Abbruch, Pruefung des Manifests, Release-Upload der
|
||||
Manifest-Dateien (idempotent: vorhandene Datei gleichen Namens wird
|
||||
ersetzt). Abschnitt 6 Fehlerbehebung: neue Unterabschnitte „Job desktop
|
||||
schlaegt fehl" (apt-Paketname, pkg-config, openssl-sys beim Windows-Ziel →
|
||||
`rustls-tls`, NSIS-Plugin-Download, Speicher → `CARGO_BUILD_JOBS`),
|
||||
„publish: cache miss" (Schluessel/Cache-Server, Abschnitt 2 Runner-Config
|
||||
`[cache] enabled`), „Release-Upload 413" (`GITEA_API` auf die Host-Adresse
|
||||
`http://172.18.0.1:3002/api/v1` — nur, wenn der Proxy die Groesse
|
||||
abweist). Reale Fehlerbilder aus 18-05-SUMMARY (Rundentabelle) hier
|
||||
eintragen.
|
||||
|
||||
**`docs/anleitung-entwicklung.md`** — (1) Im Monorepo-Aufbau die Zeile zu
|
||||
`desktop/` und den Absatz bei Zeile 39, der `apps/desktop` als blosses
|
||||
Grundgeruest mit einer einzelnen `setup.html` beschreibt, ersetzen (das Wort
|
||||
„Grundgerüst" darf im Dokument danach nicht mehr im Zusammenhang mit Tauri
|
||||
stehen — Negativ-Tor in `<verify>`): `apps/desktop` ist der fertige
|
||||
Desktop-Client (Tauri 2): `src-tauri/src/lib.rs` (Tray, Erststart-Kommandos,
|
||||
Versionspruefung), `src/setup.html` (Erststart-Seite), Pakete entstehen im
|
||||
CI; `packages/shared` enthaelt jetzt auch die Manifest-Typen der
|
||||
Desktop-Pakete. (2) Unter „Lokale Entwicklungsumgebung" neuer Abschnitt
|
||||
`### Desktop-App lokal bauen`: Voraussetzungen (Rust stable per rustup,
|
||||
Ubuntu/Debian-Pakete `libwebkit2gtk-4.1-dev libjavascriptcoregtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev libgtk-3-dev libssl-dev patchelf`),
|
||||
Befehle `sh .gitea/scripts/desktop-version.sh` (schreibt die Version des
|
||||
letzten Tags — die eingecheckten Versionsdateien sind nur eine Basislinie),
|
||||
`pnpm --filter @tessera/desktop exec tauri build --bundles appimage`,
|
||||
Ausgabe unter `apps/desktop/src-tauri/target/release/bundle/appimage/`,
|
||||
`sh .gitea/scripts/desktop-collect.sh --require linux` fuer `desktop-dist/`
|
||||
(vom Git ausgeschlossen bis auf den Platzhalter), Hinweis: der
|
||||
Windows-Installer wird nur im CI gebaut (`cargo-xwin`, NSIS), lokal genuegt
|
||||
`cargo check`/`cargo clippy`; lokaler Docker-Stack: nach `docker compose build api`
|
||||
liefert die API die Pakete unter `/desktop/latest`. (3) Unter „Tests":
|
||||
`pnpm --filter @tessera/api exec vitest run src/desktop` (HTTP-Durchstich
|
||||
ueber `NestFactory`, echtes Temp-Verzeichnis) und die Rust-Pruefungen
|
||||
ergaenzen.
|
||||
</action>
|
||||
<acceptance_criteria>
|
||||
- `grep -c '^## 10. Desktop-App' docs/anleitung-betrieb.md` ergibt 1; das Inhaltsverzeichnis enthaelt einen Eintrag `10.`; `grep -c 'DESKTOP_DIST_DIR' docs/anleitung-betrieb.md` ergibt mindestens 1; `grep -c '/app/desktop-dist' docs/anleitung-betrieb.md` ergibt mindestens 1; `grep -c '### Fehlerbilder' docs/anleitung-betrieb.md` ergibt 1.
|
||||
- `grep -c 'vier aufeinander aufbauenden Jobs\|vier Jobs' docs/ci-cd-setup.md` ergibt mindestens 1; `grep -c 'cargo-xwin' docs/ci-cd-setup.md` ergibt mindestens 2; `grep -c 'upload-artifact' docs/ci-cd-setup.md` ergibt mindestens 1 (Begruendung, warum nicht); `grep -c 'desktop-dist-' docs/ci-cd-setup.md` ergibt mindestens 1.
|
||||
- `grep -c 'Tauri-Grundgerüst' docs/anleitung-entwicklung.md` ergibt 0; `grep -c '### Desktop-App lokal bauen' docs/anleitung-entwicklung.md` ergibt 1; `grep -c 'desktop-version.sh' docs/anleitung-entwicklung.md` ergibt mindestens 1; `grep -c 'vitest run src/desktop' docs/anleitung-entwicklung.md` ergibt mindestens 1.
|
||||
- Keine firmenspezifische Adresse in den neuen Abschnitten (die bestehenden Nennungen von `git.vicolab.de` im CI/CD-Runbook sind Infrastruktur und bleiben).
|
||||
</acceptance_criteria>
|
||||
<!-- planner-discipline-allow: Tauri-Grundgerüst -->
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && grep -q '^## 10. Desktop-App' docs/anleitung-betrieb.md && grep -q 'DESKTOP_DIST_DIR' docs/anleitung-betrieb.md && grep -q '/app/desktop-dist' docs/anleitung-betrieb.md && grep -q '### Fehlerbilder' docs/anleitung-betrieb.md && grep -Eq '^10\. \[' docs/anleitung-betrieb.md && test "$(grep -c 'cargo-xwin' docs/ci-cd-setup.md)" -ge 2 && grep -q 'desktop-dist-' docs/ci-cd-setup.md && grep -q 'upload-artifact' docs/ci-cd-setup.md && test "$(grep -c 'Tauri-Grundgerüst' docs/anleitung-entwicklung.md)" = "0" && grep -q '### Desktop-App lokal bauen' docs/anleitung-entwicklung.md && grep -q 'desktop-version.sh' docs/anleitung-entwicklung.md && grep -q 'vitest run src/desktop' docs/anleitung-entwicklung.md && echo DOCS2-OK</automated>
|
||||
<fails_when>Kapitel 10, Inhaltsverzeichnis-Eintrag, Variable, Ablageort, Fehlerbilder, Cross-Bau-Beschreibung, Cache-Schluessel oder der neue Entwicklungsabschnitt fehlen, oder das Entwicklungshandbuch nennt `apps/desktop` noch als Grundgeruest — `DOCS2-OK` fehlt.</fails_when>
|
||||
</verify>
|
||||
<done>
|
||||
Betriebshandbuch mit Kapitel 10 (Pipeline, Ablageort, Release-Dateien,
|
||||
Variable, Fehlerbilder), CI/CD-Runbook mit Job `desktop` und
|
||||
Fehlerbehebung, Entwicklungshandbuch mit lokalem Bau und aktualisiertem
|
||||
Monorepo-Aufbau.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: REQUIREMENTS nachziehen, Gesamtlaeufe, Bedienprobe des Nutzers</name>
|
||||
<files>
|
||||
.planning/REQUIREMENTS.md
|
||||
</files>
|
||||
<read_first>
|
||||
.planning/REQUIREMENTS.md (Abschnitte "SRC" ab Zeile 58 als Formvorlage, "Traceability" ab Zeile 101),
|
||||
.planning/ROADMAP.md (Phase 18: Requirements-Zeile und Erfolgskriterien),
|
||||
.planning/phases/06-desktop-client-ci-cd/06-CONTEXT.md (Ursprung DESK-01/02)
|
||||
</read_first>
|
||||
<action>
|
||||
**REQUIREMENTS.md.** Vor `## Future Requirements (deferred)` einen Abschnitt
|
||||
`## Phase 18 — Desktop-Client fertigstellen` mit `### DESK — Desktop-Client`
|
||||
und einem Einleitungssatz („Hinzugefügt 2026-09-16 — DESK-01/02 stammen aus
|
||||
v1.0 (Phase 6) und werden fortgeführt; DESK-03..05 aus
|
||||
`18-CONTEXT.md` abgeleitet") einfuegen. Eintraege im Stil der SRC-Zeilen:
|
||||
`- [x] **DESK-01**: Tauri-basierter Desktop-Wrapper für Windows und Linux (Phase 6, fortgeführt).`;
|
||||
`- [x] **DESK-02**: Die Desktop-App verbindet sich mit dem Web-Backend; die Server-Adresse wird beim ersten Start abgefragt (Phase 6, fortgeführt; D-02).`;
|
||||
`- [ ] **DESK-03**: Der Installer ist in Tessera herunterladbar — Link auf der Anmeldeseite und Seite Einstellungen → Desktop-App, Auslieferung über die Tessera-API ohne Gitea-Zugang (D-01, D-10, D-12).`;
|
||||
`- [ ] **DESK-04**: Ein Freigabe-Tag baut Windows-Installer und Linux-AppImage in der Pipeline und hängt beide als Dateien an den Gitea-Release (D-04..D-08).`;
|
||||
`- [ ] **DESK-05**: Der Client trägt die Freigabe-Version, vergleicht sie mit `/desktop/latest` und weist mit Download-Link auf eine neuere Version hin (D-07, D-11, D-13).`
|
||||
In der Traceability-Tabelle fuenf Zeilen ergaenzen: `DESK-01 | Phase 6 / 18 | Complete`,
|
||||
`DESK-02 | Phase 6 / 18 | Complete`, `DESK-03 | Phase 18 | Pending`,
|
||||
`DESK-04 | Phase 18 | Pending`, `DESK-05 | Phase 18 | Pending` (auf
|
||||
Complete setzt sie die Verifikation der Phase). Die Coverage-Zeile um einen
|
||||
Satz ergaenzen (5/5 DESK auf Phase 18 abgebildet).
|
||||
|
||||
**Gesamtlaeufe** (Endstand der Phase): `pnpm --filter @tessera/api exec vitest run`,
|
||||
`pnpm --filter @tessera/web exec vitest run`, `pnpm --filter @tessera/api type-check`,
|
||||
`pnpm --filter @tessera/web type-check`, `cargo check` in
|
||||
`apps/desktop/src-tauri`. Ergebnisse (Anzahl Dateien/Tests) im SUMMARY
|
||||
festhalten. `biome check` ist kein Tor (bekannter Fehler in der
|
||||
Wurzel-`biome.json`, nicht anfassen).
|
||||
|
||||
**Bedienprobe vorbereiten:** Den Text der `<human-check>` unten als
|
||||
Schrittfolge in das SUMMARY uebernehmen, damit der Nutzer sie zur Hand hat;
|
||||
die Testserver-Adresse dort einsetzen (`alpha.tessera.ctl.de`, nur im
|
||||
SUMMARY/Gespraech, nie im Handbuch).
|
||||
</action>
|
||||
<acceptance_criteria>
|
||||
- `grep -c '\*\*DESK-0[1-5]\*\*' .planning/REQUIREMENTS.md` ergibt 5; `grep -c '^| DESK-0[1-5] |' .planning/REQUIREMENTS.md` ergibt 5.
|
||||
- `pnpm --filter @tessera/api exec vitest run` und `pnpm --filter @tessera/web exec vitest run` melden 0 fehlgeschlagene Tests; beide Typpruefungen fehlerfrei; `cargo check` gruen.
|
||||
- Der Nutzer hat die Bedienprobe (human-check) durchgefuehrt und das Ergebnis liegt vor.
|
||||
</acceptance_criteria>
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && test "$(grep -c '\*\*DESK-0[1-5]\*\*' .planning/REQUIREMENTS.md)" = "5" && test "$(grep -c '^| DESK-0[1-5] |' .planning/REQUIREMENTS.md)" = "5" && echo REQ-OK</automated>
|
||||
<fails_when>Weniger oder mehr als fuenf DESK-Eintraege bzw. Traceability-Zeilen — `REQ-OK` fehlt.</fails_when>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/api exec vitest run && pnpm --filter @tessera/web exec vitest run && pnpm --filter @tessera/api type-check && pnpm --filter @tessera/web type-check && (cd apps/desktop/src-tauri && cargo check 2>&1 | tail -1 | grep -q Finished) && echo ALL-GREEN</automated>
|
||||
<fails_when>Eine Suite meldet "failed", tsc gibt Fehler aus, oder `cargo check` endet ohne `Finished` — `ALL-GREEN` fehlt.</fails_when>
|
||||
<human-check>
|
||||
Bedienprobe des Nutzers (Du-Form im Gespraech; Voraussetzung: der Testserver
|
||||
laeuft auf dem Beta-Stand mit den Paketen — `docker compose pull` und
|
||||
`docker compose up -d --force-recreate` machst du dort selbst; Windows-PC
|
||||
mit Browser):
|
||||
|
||||
1. Anmeldeseite des Testservers im Browser oeffnen: Unter dem Formular
|
||||
steht „Desktop-App herunterladen (Windows)", daneben „Linux-Version",
|
||||
darunter „Version 1.1.0".
|
||||
2. Auf den Windows-Link klicken: Es laedt `Tessera-Setup-1.1.0-beta.{kennung}.exe`
|
||||
(wenige MB).
|
||||
3. Datei ausfuehren. Windows zeigt die SmartScreen-Warnung: „Weitere
|
||||
Informationen" → „Trotzdem ausführen". Die Installation laeuft ohne
|
||||
weitere Fragen durch; Tessera startet (sonst ueber das Startmenue).
|
||||
4. Erststart-Seite: dunkle Karte mit Tessera-Zeichen und gelbem Schriftzug,
|
||||
Feld „Adresse Ihres Tessera-Servers". Adresse des Testservers eintragen
|
||||
(`https://…`), „Verbinden": kurz „Tessera 1.1.0 gefunden – Verbindung
|
||||
wird hergestellt …", dann erscheint die Tessera-Anmeldung **im
|
||||
App-Fenster**.
|
||||
5. Anmelden. Fenster mit X schliessen: Die App bleibt im Infobereich
|
||||
(Symbol mit Tessera-Zeichen). Linksklick auf das Symbol: Fenster ist
|
||||
wieder da.
|
||||
6. Rechtsklick auf das Symbol: Menue „Öffnen", „Update herunterladen"
|
||||
(ausgegraut, weil du die aktuelle Version hast), „Mit Windows starten"
|
||||
(ohne Haken), „Beenden" — mit Umlauten.
|
||||
7. „Mit Windows starten" anklicken: Haken erscheint; erneut anklicken:
|
||||
Haken verschwindet.
|
||||
8. „Beenden": App ist weg (auch aus dem Infobereich).
|
||||
9. App erneut starten: Sie geht **direkt** zu Tessera (Adresse gemerkt),
|
||||
Fenstergroesse und -position wie beim Beenden.
|
||||
10. In der App: Einstellungen → Allgemein → „Desktop-App": Seite mit
|
||||
„Aktuelle Version: 1.1.0", „Beta-Ausgabe, Stand {kennung}", zwei gelbe
|
||||
Knoepfe „Für Windows herunterladen" / „Für Linux herunterladen", darunter
|
||||
Dateiname und Groesse (z. B. „… · 101,5 MB" fuer Linux), und vier
|
||||
Saetze Erklaerung.
|
||||
11. Falls ein Linux-Rechner greifbar ist: AppImage herunterladen,
|
||||
ausfuehrbar machen, starten — Erststart-Seite wie unter 4.
|
||||
|
||||
Zwei Punkte lassen sich erst beim **naechsten Freigabe-Tag** pruefen und
|
||||
gehoeren in die Abnahme dieser Version, nicht in diese Phase: (a) Nach dem
|
||||
Tag `v1.2.0` zeigt der installierte 1.1.0-Client beim Start die
|
||||
Benachrichtigung „Neue Version 1.2.0 verfügbar …", und der Menueeintrag
|
||||
heisst „Version 1.2.0 herunterladen" und oeffnet die Seite Desktop-App im
|
||||
Browser. (b) Der Gitea-Release `v1.2.0` traegt `Tessera-Setup-1.2.0.exe`
|
||||
und `Tessera-1.2.0.AppImage` als Dateien.
|
||||
</human-check>
|
||||
</verify>
|
||||
<done>
|
||||
REQUIREMENTS.md fuehrt DESK-01..05 mit Nachverfolgung; alle Suiten und
|
||||
Typpruefungen gruen; die Bedienprobe des Nutzers ist durchgefuehrt und im
|
||||
SUMMARY dokumentiert (inklusive der zwei auf den naechsten Tag vertagten
|
||||
Punkte).
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Handbuecher -> Anwender | Anleitungen praegen das Verhalten der Anwender bei Sicherheitswarnungen (SmartScreen). |
|
||||
| Testserver -> Nutzer-PC | Der Nutzer installiert ein unsigniertes Paket vom Beta-Kanal. |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-18-19 | Spoofing | SmartScreen-Anleitung („Trotzdem ausführen") | low | mitigate | Das Handbuch koppelt die Anweisung an die Herkunft (Download nur aus dem eigenen Tessera-Server, Dateiname `Tessera-Setup-…`) und nennt keine allgemeine Empfehlung, Warnungen zu ignorieren. |
|
||||
| T-18-20 | Information Disclosure | Handbuecher mit Server-Adressen | low | mitigate | Nur Platzhalter (`https://tessera.example.com`); die Testserver-Adresse steht ausschliesslich im SUMMARY/Gespraech. |
|
||||
| T-18-SC | Tampering | Paketinstallationen | low | accept | Dieser Plan installiert kein Paket. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
1. Dokument-Kennzeichen (Kapitel, Inhaltsverzeichnis, Pflichtbegriffe) in
|
||||
allen vier Dokumenten erfuellt.
|
||||
2. CHANGELOG-Stichpunkt unter „Unveröffentlicht".
|
||||
3. REQUIREMENTS.md mit DESK-01..05 und Traceability.
|
||||
4. Gesamtlaeufe API/Web/Typpruefung/Cargo gruen.
|
||||
5. Bedienprobe des Nutzers auf Windows (Schritte 1-10) bestanden; Punkte
|
||||
(a) und (b) auf den naechsten Freigabe-Tag vertagt und so dokumentiert.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Anwender-, Betriebs- und Entwicklungshandbuch beschreiben Installation,
|
||||
Erststart, Tray-Verhalten, Pipeline, Release-Dateien und
|
||||
Umgebungsvariablen (Erfolgskriterium 4).
|
||||
- Der installierte Client zeigt nach Eingabe der Server-Adresse die
|
||||
Anmeldung und verhaelt sich im Infobereich wie beschrieben
|
||||
(Erfolgskriterium 3, Bedienprobe).
|
||||
- Alle Suiten gruen; CHANGELOG und REQUIREMENTS nachgezogen.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/18-desktop-client-fertigstellen/18-06-SUMMARY.md` when done.
|
||||
Im SUMMARY festhalten: Ergebnis der Bedienprobe je Schritt, die zwei
|
||||
vertagten Punkte, und die Zahlen der Gesamtlaeufe.
|
||||
</output>
|
||||
@@ -0,0 +1,27 @@
|
||||
# API Coverage — Gitea REST API (Releases und Release-Dateien)
|
||||
|
||||
> Full coverage by default. Opt-outs are explicit, reasoned decisions.
|
||||
|
||||
Einzige externe Schnittstelle dieser Phase: die Gitea-REST-API der eigenen
|
||||
Instanz (`git.vicolab.de`, Gitea 1.26.2), angesprochen aus
|
||||
`.gitea/scripts/publish-release.sh` im CI-Job `publish` (nur bei Tags `v*`).
|
||||
Alle Pfade liegen unter `/api/v1/repos/{owner}/{repo}` (in der Tabelle als `…` abgekuerzt). Der Bereich ist die Releases-Ressource eines Repositories; alles andere in
|
||||
Gitea (Issues, Pull Requests, Pakete, Wiki, Webhooks, Benutzer) liegt
|
||||
ausserhalb der Phase. Die drei mit "seit 18-01" markierten Faehigkeiten sind
|
||||
neu; die uebrigen INTEGRATE-Zeilen bestehen seit quick-260916-dcz.
|
||||
|
||||
| capability | decision | reason |
|
||||
|---|---|---|
|
||||
| releases: get by tag (`GET …/releases/tags/{tag}`) | INTEGRATE | bestehend — Idempotenz (Release vorhanden?) |
|
||||
| releases: create (`POST /repos/{owner}/{repo}/releases`) | INTEGRATE | bestehend — Release aus CHANGELOG-Abschnitt |
|
||||
| releases: update (`PATCH /repos/{owner}/{repo}/releases/{id}`) | INTEGRATE | bestehend — Text nachziehen |
|
||||
| release assets: list (`GET …/releases/{id}/assets`) | INTEGRATE | seit 18-01 — vorhandene Datei gleichen Namens finden |
|
||||
| release assets: delete (`DELETE …/releases/{id}/assets/{asset_id}`) | INTEGRATE | seit 18-01 — idempotentes Ersetzen |
|
||||
| release assets: upload (`POST …/releases/{id}/assets?name=`, multipart) | INTEGRATE | seit 18-01 — `Tessera-Setup-X.Y.Z.exe` und `Tessera-X.Y.Z.AppImage` |
|
||||
| release assets: edit name (`PATCH …/assets/{asset_id}`) | OPT-OUT | nicht noetig — Name wird beim Upload gesetzt, Ersetzen laeuft ueber delete + upload |
|
||||
| release assets: download via Gitea (`GET …/assets/{asset_id}`) | OPT-OUT | explizit ausserhalb — Anwender laden ueber die Tessera-API (D-01/D-08), nicht ueber Gitea |
|
||||
| releases: delete (`DELETE …/releases/{id}`) | OPT-OUT | nicht noetig — Releases werden nie automatisch entfernt |
|
||||
| releases: list (`GET …/releases`) | OPT-OUT | nicht noetig — Zugriff erfolgt per Tag |
|
||||
| settings: attachment limits (`GET /api/v1/settings/attachment`) | OPT-OUT | nur einmalig zur Planung abgefragt (2026-09-16); Release-Anhaenge unterliegen `[repository.release]` (Voreinstellung 2048 MB, alle Typen) — keine Laufzeitabfrage |
|
||||
| actions: runs/jobs/logs (`GET …/actions/...`) | OPT-OUT | explizit ausserhalb — der Orchestrator liest CI-Laeufe ueber Gitea-MCP/Weboberflaeche (18-04), kein Skript spricht diese Endpunkte |
|
||||
| packages / container registry API | OPT-OUT | nicht Teil der Phase — der Registry-Push laeuft weiterhin ueber `docker push` (Phase 6) |
|
||||
@@ -0,0 +1,755 @@
|
||||
# Phase 18: Desktop-Client fertigstellen - Pattern Map
|
||||
|
||||
**Mapped:** 2026-09-16
|
||||
**Files analyzed:** 24 (new/modified)
|
||||
**Analogs found:** 22 / 24 (2 have no direct in-repo analog — see "No Analog Found")
|
||||
|
||||
## File Classification
|
||||
|
||||
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|
||||
|-------------------|------|-----------|-----------------|---------------|
|
||||
| `.gitea/scripts/desktop-version.sh` | utility (CI script) | transform (write version into files) | `.gitea/scripts/publish-images.sh` | role-match (same POSIX-sh CI-script family) |
|
||||
| `.gitea/workflows/ci.yml` (new `desktop` job) | config (CI pipeline) | batch | same file, `publish`/`test` jobs | exact (extend existing job list) |
|
||||
| `.gitea/scripts/publish-images.sh` (modify: copy `desktop-dist/` into API build context) | utility (CI script) | file-I/O | itself (existing) | exact |
|
||||
| `.gitea/scripts/publish-release.sh` (modify: upload 2 release assets) | utility (CI script) | request-response (Gitea API) | itself (existing, idempotent GET→PATCH/POST shape) | exact |
|
||||
| `apps/api/src/desktop/desktop.module.ts` | module | — | `apps/api/src/health/health.module.ts` | exact |
|
||||
| `apps/api/src/desktop/desktop.controller.ts` | controller | request-response + streaming | `apps/api/src/health/health.controller.ts` (public-route shape) + `apps/api/src/dkv/dkv.controller.ts` (file-download route) | exact (composite of two analogs) |
|
||||
| `apps/api/src/desktop/desktop.service.ts` | service | file-I/O | `apps/api/src/dkv/dkv.service.ts` (`getExportFile`, lines 703-732) | exact |
|
||||
| `apps/api/src/desktop/desktop.service.spec.ts` | test | — | `apps/api/src/dkv/dkv.service.spec.ts` (fs-mocking pattern) + `apps/api/src/health/health.controller.spec.ts` (`@Public()` assertion pattern) | role-match (composite) |
|
||||
| `apps/api/Dockerfile` (modify: `COPY desktop-dist/`) | config | file-I/O | itself (existing multi-stage Dockerfile) | exact |
|
||||
| `packages/shared/src/index.ts` (add `DesktopManifest`/`DesktopManifestFile`) | model (shared types) | — | itself (existing `VersionResponse`/`HealthResponse` interfaces) | exact |
|
||||
| `apps/web/src/lib/desktop.ts` | service (client-side fetch helper) | request-response | `apps/web/src/lib/app-version.ts` (`loadApiVersion`, lines 50-63) | exact |
|
||||
| `apps/web/src/lib/desktop.test.ts` | test | — | `apps/web/src/lib/app-version.test.ts` | exact |
|
||||
| `apps/web/src/app/(auth)/login/page.tsx` (add download link block) | component | request-response | itself (existing login page) | exact |
|
||||
| `apps/web/src/app/(portal)/settings/general/desktop/page.tsx` | component (page) | request-response | `apps/web/src/app/(portal)/settings/general/account/page.tsx` | exact |
|
||||
| `apps/web/src/components/settings/settings-sidebar.tsx` (add "Desktop-App" nav item) | component | — | itself (existing sidebar, "Konto" item lines 48-60) | exact |
|
||||
| `apps/web/src/messages/de.json` / `en.json` (add `settings.desktop.*`, `auth.desktopDownload.*` keys) | config (i18n) | — | itself (existing `settings.account.*` block) | exact |
|
||||
| `apps/web/src/app/(portal)/settings/general/desktop/desktop-settings.test.tsx` | test | — | `apps/web/src/components/settings/widget-settings-panel.test.tsx` (next-intl mock + de.json import pattern) | role-match |
|
||||
| `apps/desktop/src-tauri/src/lib.rs` (modify: `/desktop/latest` check, opener call, autostart tray item, umlaut texts) | provider (Tauri app setup) | event-driven | itself (existing version-check block, lines 82-101; tray menu, lines 41-66) | exact |
|
||||
| `apps/desktop/src/setup.html` (polish: Sie-Form, Tessera-Farben) | component (static HTML) | — | itself (existing setup.html, already Tessera-oklch-themed) | exact |
|
||||
| `apps/desktop/src-tauri/capabilities/default.json` (add `opener:allow-open-url`, `autostart` toggle perms already present) | config | — | itself (existing permissions list) | exact |
|
||||
| `apps/desktop/src-tauri/Cargo.toml` (add `tauri-plugin-opener`) | config | — | itself | exact |
|
||||
| `docs/anleitung-anwender.md` (new "Desktop-App" chapter) | doc | — | itself (existing "Die Module" chapter pattern, e.g. "DKV-Rechnung" §120) | role-match |
|
||||
| `docs/anleitung-betrieb.md` (pipeline/desktop-dist/release section) | doc | — | itself (existing §9 "Zwei Kanäle: Live und Beta") | role-match |
|
||||
| `docs/anleitung-entwicklung.md` (update `apps/desktop` description, §39) | doc | — | itself (existing paragraph at line 39) | exact |
|
||||
| `CHANGELOG.md` (Unveröffentlicht → ### Neu bullet) | doc | — | itself (existing `### Neu` bullet style) | exact |
|
||||
|
||||
## Pattern Assignments
|
||||
|
||||
### `.gitea/scripts/desktop-version.sh` (utility, transform)
|
||||
|
||||
**Analog:** `.gitea/scripts/publish-images.sh`
|
||||
|
||||
**Style pattern to copy** (whole file is the model — POSIX `sh`, `set -eu`, German header comment explaining the "why", decision driven only by git state so it's testable locally):
|
||||
```sh
|
||||
#!/bin/sh
|
||||
# <script-name>.sh -- <one-line purpose> (phase-18)
|
||||
#
|
||||
# <what it decides and why, in German, matching the existing header style>
|
||||
set -eu
|
||||
|
||||
TAG_VERSION="$(git describe --tags --abbrev=0 2>/dev/null || echo v0.0.0)"
|
||||
VERSION="${TAG_VERSION#v}" # plain X.Y.Z only — NSIS numeric-version constraint (Pitfall 2)
|
||||
|
||||
CONF="apps/desktop/src-tauri/tauri.conf.json"
|
||||
CARGO="apps/desktop/src-tauri/Cargo.toml"
|
||||
|
||||
jq --arg v "$VERSION" '.version = $v' "$CONF" > "$CONF.tmp" && mv "$CONF.tmp" "$CONF"
|
||||
sed -i "s/^version = \".*\"/version = \"$VERSION\"/" "$CARGO"
|
||||
|
||||
echo "Desktop version set to $VERSION (from tag $TAG_VERSION)"
|
||||
```
|
||||
**Reusable conventions from `publish-images.sh`** (lines 22-46 of that file): `set -eu` at top; `REF="${GITHUB_REF:-}"`-style env-var-with-default reads; a `case` statement deciding behavior from `$REF` alone (never from a runtime API call) so the script is offline-testable; every echoed status line prefixed with what happened, not just a bare value. This script never touches secrets, matching `publish-images.sh`'s own closing comment ("Dieses Skript kennt kein Secret").
|
||||
|
||||
---
|
||||
|
||||
### `.gitea/workflows/ci.yml` (config, batch — new `desktop` job)
|
||||
|
||||
**Analog:** same file, existing `test`/`publish` job shape (lines 35-74)
|
||||
|
||||
**Job skeleton pattern** (copy the `needs`/`runs-on`/step-naming convention):
|
||||
```yaml
|
||||
test:
|
||||
name: Tests
|
||||
runs-on: ubuntu-latest
|
||||
needs: quality
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
- name: Enable pnpm via corepack
|
||||
run: corepack enable && corepack prepare pnpm@9.15.0 --activate
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Run tests
|
||||
run: pnpm test
|
||||
```
|
||||
New `desktop` job: `needs: test`, add `if: gitea.ref == 'refs/heads/main' || startsWith(gitea.ref, 'refs/tags/v')` (same conditional shape reasoning as the `case "$REF"` branches in `publish-images.sh`). `publish` job gains `needs: desktop` (currently `needs: test`, line 58) and a cache-restore step before its existing `docker build` invocation inside `publish-images.sh`. Step names stay in German, matching every existing step name in this file ("Enable pnpm via corepack" is the one English exception already present — follow whichever is already there per step, don't invent a third style).
|
||||
|
||||
---
|
||||
|
||||
### `.gitea/scripts/publish-images.sh` (utility, file-I/O — modify to copy `desktop-dist/`)
|
||||
|
||||
**Analog:** itself
|
||||
|
||||
**Insertion point** (before the existing build loop, lines 57-68):
|
||||
```sh
|
||||
for IMG in web api; do
|
||||
docker build -t "$REGISTRY/$IMG:$APP_CHANNEL" \
|
||||
--build-arg APP_VERSION="$APP_VERSION" \
|
||||
--build-arg APP_CHANNEL="$APP_CHANNEL" \
|
||||
--build-arg APP_COMMIT="$APP_COMMIT" \
|
||||
--build-arg APP_BUILD_TIME="$APP_BUILD_TIME" \
|
||||
-f "apps/$IMG/Dockerfile" .
|
||||
for TAG in $TAGS; do
|
||||
docker tag "$REGISTRY/$IMG:$APP_CHANNEL" "$REGISTRY/$IMG:$TAG"
|
||||
docker push "$REGISTRY/$IMG:$TAG"
|
||||
done
|
||||
done
|
||||
```
|
||||
`desktop-dist/manifest.json` (sha256/size/commit per D-08) must be generated and `desktop-dist/` must exist in the build context (project root `.`) before this loop runs, since the `docker build ... -f apps/api/Dockerfile .` context is the repo root — the API Dockerfile's new `COPY desktop-dist/ /app/desktop-dist/` step reads from there. Keep the "no secrets in this script" invariant (top-of-file comment, line 21) — manifest generation needs no secret.
|
||||
|
||||
---
|
||||
|
||||
### `.gitea/scripts/publish-release.sh` (utility, request-response — modify for asset upload)
|
||||
|
||||
**Analog:** itself (idempotent GET→PATCH/POST pattern, lines 125-155)
|
||||
|
||||
**Idempotency pattern to extend** (verbatim, this is the shape new asset-upload logic must match):
|
||||
```sh
|
||||
CODE=$(curl -sS --header @"$HDR" -o "$RESP" -w '%{http_code}' "$TAG_URL")
|
||||
case "$CODE" in
|
||||
200)
|
||||
ID=$(jq -r .id "$RESP")
|
||||
printf '%s' "$UPDATE_JSON" > "$JSONFILE"
|
||||
CODE=$(curl -sS --header @"$HDR" -X PATCH --data @"$JSONFILE" -o "$RESP" -w '%{http_code}' "$RELEASES_URL/$ID")
|
||||
if [ "$CODE" = "200" ]; then
|
||||
echo "Release $TAG aktualisiert (id $ID)"
|
||||
else
|
||||
echo "PATCH $RELEASES_URL/$ID antwortete mit $CODE:" >&2
|
||||
cat "$RESP" >&2
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
404)
|
||||
...
|
||||
;;
|
||||
*)
|
||||
echo "GET $TAG_URL antwortete mit $CODE:" >&2
|
||||
cat "$RESP" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
```
|
||||
**Secret-handling pattern to reuse exactly** (lines 117-123 — cited directly in RESEARCH.md's Security Domain section):
|
||||
```sh
|
||||
umask 077
|
||||
TMPDIR_REL=$(mktemp -d)
|
||||
trap 'rm -rf "$TMPDIR_REL"' EXIT INT TERM
|
||||
HDR="$TMPDIR_REL/headers"
|
||||
RESP="$TMPDIR_REL/response.json"
|
||||
JSONFILE="$TMPDIR_REL/payload.json"
|
||||
printf 'Authorization: token %s\nContent-Type: application/json\n' "$GITEA_TOKEN" > "$HDR"
|
||||
```
|
||||
New `upload_asset()` function (per RESEARCH.md Code Example #6) should follow the same "GET, decide by HTTP code via `case`, act" shape — for assets: `GET .../assets`, find existing by `name` via `jq`, `DELETE` if found, then `POST` multipart. This keeps one idiom in the file instead of introducing a second (per RESEARCH.md's "Don't Hand-Roll" table).
|
||||
|
||||
---
|
||||
|
||||
### `apps/api/src/desktop/desktop.module.ts` (module)
|
||||
|
||||
**Analog:** `apps/api/src/health/health.module.ts` (entire file, 7 lines)
|
||||
|
||||
```typescript
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HealthController } from './health.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class HealthModule {}
|
||||
```
|
||||
Copy verbatim, swap names. Since `DesktopController` needs `DesktopService` (unlike the dependency-free `HealthController`), add `providers: [DesktopService]` — no other analog needed, this is the standard NestJS module shape used throughout `apps/api/src/*` (confirmed by `DkvModule`'s equivalent `controllers`+`providers` shape).
|
||||
|
||||
---
|
||||
|
||||
### `apps/api/src/desktop/desktop.controller.ts` (controller, request-response + streaming)
|
||||
|
||||
**Analog A — public-route shape:** `apps/api/src/health/health.controller.ts` (whole file, 25 lines)
|
||||
```typescript
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import type { HealthResponse, VersionResponse } from '@tessera/shared';
|
||||
import { Public } from '../auth/decorators/public.decorator';
|
||||
import { getAppVersion } from './app-version';
|
||||
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
@Public()
|
||||
@Get()
|
||||
check(): HealthResponse {
|
||||
return { status: 'ok', timestamp: new Date().toISOString() };
|
||||
}
|
||||
|
||||
// Bewusst oeffentlich (T-KU1-03): Betreiber-Kontrolle per `curl` auf dem
|
||||
// Server ohne Anmeldung. ...
|
||||
@Public()
|
||||
@Get('version')
|
||||
getVersion(): VersionResponse {
|
||||
return getAppVersion();
|
||||
}
|
||||
}
|
||||
```
|
||||
`DesktopController` follows the identical `@Public() @Get(...)` shape for `GET /desktop/latest`, with the same style of a comment explaining *why* it's public (D-10: login page shows the link before auth exists).
|
||||
|
||||
**Analog B — file-download route + error mapping:** `apps/api/src/dkv/dkv.controller.ts` (lines 133-160)
|
||||
```typescript
|
||||
@Get('exports/:filename')
|
||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||
async downloadExport(
|
||||
@Req() req: any,
|
||||
@Param('filename') filename: string,
|
||||
@Res() res: any,
|
||||
) {
|
||||
const tenantId = this._requireTenant(req);
|
||||
try {
|
||||
const buffer = await this.dkvService.getExportFile(tenantId, filename);
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||
res.send(buffer);
|
||||
} catch (error) {
|
||||
if (error instanceof NotFoundException || error instanceof BadRequestException) throw error;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
```
|
||||
**Difference to apply deliberately:** `dkv.controller.ts` buffers the whole file in memory (`fs.readFileSync` inside the service, `res.send(buffer)`). Installer files are much larger than xlsx exports, so `desktop.controller.ts` should stream instead — use NestJS's `StreamableFile` (no in-repo precedent; follow RESEARCH.md Code Example #2 / NestJS official docs verbatim: `fs.createReadStream`, `res.set({...})`, `return new StreamableFile(stream)`). Keep `@Public()` (no `@Roles()`!) on both new routes — this is the one deliberate deviation from the `dkv.controller.ts` analog, which is `@Roles(Role.ADMIN, Role.SUPER_ADMIN)`-gated.
|
||||
|
||||
**Auth pattern (what NOT to add):** confirm via `apps/api/src/auth/decorators/public.decorator.ts` (whole file):
|
||||
```typescript
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const IS_PUBLIC_KEY = 'isPublic';
|
||||
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||
```
|
||||
The global `JwtAuthGuard` checks this metadata to skip auth — both new routes need `@Public()`, matching `HealthController`.
|
||||
|
||||
---
|
||||
|
||||
### `apps/api/src/desktop/desktop.service.ts` (service, file-I/O)
|
||||
|
||||
**Analog:** `apps/api/src/dkv/dkv.service.ts`, `getExportFile()` (lines 703-732, verbatim)
|
||||
```typescript
|
||||
async getExportFile(tenantId: string, filename: string): Promise<Buffer> {
|
||||
// Stage 1 (unchanged, T-07-09): traversal guard, whitelist-validate the
|
||||
// filename before doing anything else with it.
|
||||
if (
|
||||
filename.includes('/') ||
|
||||
filename.includes('\\') ||
|
||||
filename.includes('..') ||
|
||||
!/^(RG-DKV-|DKV_)[\w\-]+\.xlsx$/.test(filename)
|
||||
) {
|
||||
throw new BadRequestException('Invalid export filename');
|
||||
}
|
||||
// Stage 2: ownership/whitelist gate ...
|
||||
const filePath = path.join(this.userFilesDir, filename);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
throw new NotFoundException(`Export file not found: ${filename}`);
|
||||
}
|
||||
return fs.readFileSync(filePath);
|
||||
}
|
||||
```
|
||||
**Direct application (per RESEARCH.md Code Example #2 and D-10):** whitelist `platform` against a fixed `const PLATFORMS = ['windows', 'linux'] as const` enum (equivalent to the regex-whitelist stage above, just simpler since there's no dynamic filename from the request at all), resolve the filename **exclusively** from `manifest.json` (never from `:platform` directly — stronger than the DKV pattern, which at least regex-validates a request-supplied filename; here the request never supplies a filename at all), then `fs.existsSync`/stream. Imports pattern to copy (`dkv.service.ts` lines 1-9):
|
||||
```typescript
|
||||
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
```
|
||||
**Manifest-reading + platform-whitelist shape** (already fully worked out in RESEARCH.md Code Examples §5, cite as-is):
|
||||
```typescript
|
||||
const PLATFORMS = ['windows', 'linux'] as const;
|
||||
type Platform = (typeof PLATFORMS)[number];
|
||||
|
||||
async getManifest(): Promise<DesktopManifest | null> {
|
||||
const manifestPath = path.join(this.desktopDistDir, 'manifest.json');
|
||||
if (!fs.existsSync(manifestPath)) return null;
|
||||
return JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
|
||||
}
|
||||
|
||||
async getPackageStream(platform: string): Promise<{ stream: fs.ReadStream; entry: ManifestFileEntry }> {
|
||||
if (!PLATFORMS.includes(platform as Platform)) {
|
||||
throw new BadRequestException(`Unknown platform: ${platform}`);
|
||||
}
|
||||
const manifest = await this.getManifest();
|
||||
if (!manifest) throw new NotFoundException('Desktop packages not available');
|
||||
const entry = manifest.files[platform as Platform];
|
||||
if (!entry) throw new NotFoundException(`No package for platform: ${platform}`);
|
||||
const filePath = path.join(this.desktopDistDir, entry.name);
|
||||
if (!fs.existsSync(filePath)) throw new NotFoundException(`Package file missing: ${entry.name}`);
|
||||
return { stream: fs.createReadStream(filePath), entry };
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `apps/api/src/desktop/desktop.service.spec.ts` (test)
|
||||
|
||||
**Analog A — fs mocking under ESM:** `apps/api/src/dkv/dkv.service.spec.ts` (lines 27-31, verbatim — this exact technique is required, `vi.spyOn(fs, ...)` does not work under this project's ESM setup)
|
||||
```typescript
|
||||
// `import * as fs from 'fs'` under ESM has a non-configurable module
|
||||
// namespace — vi.spyOn(fs, 'existsSync') fails with "Cannot redefine
|
||||
// property". vi.mock() replaces the module at import time instead, which
|
||||
// works regardless of namespace configurability (Tests 8-10, Aufgabe 3).
|
||||
vi.mock('fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('fs')>();
|
||||
return { ...actual, existsSync: vi.fn(), readFileSync: vi.fn() };
|
||||
});
|
||||
```
|
||||
**Analog B — `@Public()` metadata assertion + header-comment style + numbered `it()` naming:** `apps/api/src/health/health.controller.spec.ts` (whole file, especially Test 6, lines 94-97)
|
||||
```typescript
|
||||
it('Test 6 (bewusst oeffentlich, T-KU1-03): getVersion und check tragen @Public()', () => {
|
||||
expect(Reflect.getMetadata(IS_PUBLIC_KEY, HealthController.prototype.getVersion)).toBe(true);
|
||||
expect(Reflect.getMetadata(IS_PUBLIC_KEY, HealthController.prototype.check)).toBe(true);
|
||||
});
|
||||
```
|
||||
Required test cases per D-16/RESEARCH.md Test Map: manifest present → 200 JSON; manifest/dir missing → 404; unknown platform → 400 (BadRequestException); traversal-style input (`../../etc/passwd` as `:platform` value) rejected by the whitelist before any `fs` call — assert `fs.existsSync`/`readFileSync` mocks were never called with a traversal string, same spirit as the DKV spec's bound-vs-unbound-client double-mock technique for proving isolation.
|
||||
|
||||
---
|
||||
|
||||
### `apps/api/Dockerfile` (config, file-I/O — modify)
|
||||
|
||||
**Analog:** itself (existing multi-stage `runner` stage, lines 28-52)
|
||||
|
||||
**Insertion pattern** — follow the existing `COPY --from=builder ... ./`-then-chown convention (lines 36-49):
|
||||
```dockerfile
|
||||
RUN addgroup --system --gid 1001 nestjs && \
|
||||
adduser --system --uid 1001 nestjs && \
|
||||
mkdir -p /app/user-files && \
|
||||
chown nestjs:nestjs /app/user-files
|
||||
...
|
||||
COPY --from=builder /app/packages/shared/src ./packages/shared/src
|
||||
COPY apps/api/scripts ./apps/api/scripts
|
||||
USER nestjs
|
||||
```
|
||||
Add `COPY desktop-dist ./desktop-dist` (build context is repo root, matching `publish-images.sh`'s `docker build ... -f "apps/$IMG/Dockerfile" .`) before `USER nestjs`, and extend the `mkdir`/`chown` line if the runtime reads need write-free but readable-by-`nestjs` permissions (it's read-only at runtime, so a plain `COPY` — which defaults to root-owned, world-readable — is sufficient; no `chown` needed unless the file server needs to write, which D-08 says it doesn't).
|
||||
|
||||
---
|
||||
|
||||
### `packages/shared/src/index.ts` (model — add types)
|
||||
|
||||
**Analog:** itself (existing `HealthResponse`/`VersionResponse` interfaces, lines 3-20ish)
|
||||
|
||||
```typescript
|
||||
export interface HealthResponse {
|
||||
status: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface VersionResponse {
|
||||
name: string;
|
||||
version: string;
|
||||
channel: string;
|
||||
commit: string;
|
||||
buildTime: string;
|
||||
}
|
||||
```
|
||||
Add `DesktopManifestFile`/`DesktopManifest` in the same file, same flat-interface style (per RESEARCH.md Code Example #7):
|
||||
```typescript
|
||||
export interface DesktopManifestFile {
|
||||
name: string;
|
||||
size: number;
|
||||
sha256: string;
|
||||
}
|
||||
export interface DesktopManifest {
|
||||
version: string;
|
||||
commit: string;
|
||||
buildTime: string;
|
||||
files: {
|
||||
windows: DesktopManifestFile;
|
||||
linux: DesktopManifestFile;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `apps/web/src/lib/desktop.ts` (service — client fetch helper)
|
||||
|
||||
**Analog:** `apps/web/src/lib/app-version.ts`, `loadApiVersion()` (lines 50-63, verbatim)
|
||||
```typescript
|
||||
let apiVersionPromise: Promise<ApiVersionInfo | null> | null = null;
|
||||
|
||||
export function loadApiVersion(): Promise<ApiVersionInfo | null> {
|
||||
if (!apiVersionPromise) {
|
||||
apiVersionPromise = fetch(`${API_URL}/health/version`, { credentials: 'include' })
|
||||
.then((res) => (res.ok ? (res.json() as Promise<ApiVersionInfo>) : null))
|
||||
.catch(() => null);
|
||||
}
|
||||
return apiVersionPromise;
|
||||
}
|
||||
```
|
||||
Copy the memoized-single-promise, fail-silent-to-`null` shape exactly for `loadDesktopLatest()`. Note: the login page renders unauthenticated, so **omit** `credentials: 'include'` (or keep it — the file's own doc-comment at lines 8-14 explains it's harmless either way since `/desktop/latest` is `@Public()`). `API_URL` constant pattern to reuse (line 37):
|
||||
```typescript
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `apps/web/src/lib/desktop.test.ts` (test)
|
||||
|
||||
**Analog:** `apps/web/src/lib/app-version.test.ts` (whole file, 84 lines)
|
||||
```typescript
|
||||
async function importFresh() {
|
||||
vi.resetModules();
|
||||
return import('./app-version');
|
||||
}
|
||||
...
|
||||
it('Test 4 (Laden, memoisiert): zwei Aufrufe liefern das Objekt, fetch laeuft genau einmal mit Cookie', async () => {
|
||||
const fetchMock = vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve(payload) }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const mod = await importFresh();
|
||||
const first = await mod.loadApiVersion();
|
||||
const second = await mod.loadApiVersion();
|
||||
expect(first).toEqual(payload);
|
||||
expect(second).toEqual(payload);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Test 5 (still bei Fehler): Netzfehler und ok=false liefern null, nichts wird geworfen', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(() => Promise.reject(new Error('netz'))));
|
||||
const rejected = await importFresh();
|
||||
await expect(rejected.loadApiVersion()).resolves.toBeNull();
|
||||
});
|
||||
```
|
||||
Same `vi.resetModules()` + dynamic re-import pattern is required because the module-level promise is memoized — reuse verbatim for `loadDesktopLatest()` (module-reset-per-test, fetch mocked once/twice/error cases).
|
||||
|
||||
---
|
||||
|
||||
### `apps/web/src/app/(auth)/login/page.tsx` (component — add download link block)
|
||||
|
||||
**Analog:** itself (existing file, `'use client'`, `useTranslations('auth')`, structure lines 1-38 + submit button area ~150-165)
|
||||
|
||||
Insertion pattern — new block below the `<form>`, following the existing `Link`+`useTranslations` conventions already used for `forgotPassword` (lines 143-150):
|
||||
```tsx
|
||||
<div className="flex justify-end">
|
||||
<Link
|
||||
href="/reset-password"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{t('forgotPassword')}
|
||||
</Link>
|
||||
</div>
|
||||
```
|
||||
The new desktop-download block needs a client-side `useEffect`+`useState` pair calling `loadDesktopLatest()` (unlike the rest of the page, which is a synchronous form) — mirror the `AppVersionBadge` component's consumption of `loadApiVersion()` for that async-render-then-hide-if-null pattern (`apps/web/src/components/layout/app-version-badge.tsx`, cited in RESEARCH.md Sources, not independently re-read this session since the shape is identical to the `lib/desktop.ts` mirror above — read it before writing this component if the exact hook shape is needed).
|
||||
|
||||
---
|
||||
|
||||
### `apps/web/src/app/(portal)/settings/general/desktop/page.tsx` (component — page)
|
||||
|
||||
**Analog:** `apps/web/src/app/(portal)/settings/general/account/page.tsx` (whole file, 21 lines)
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { AccountSettingsForm } from '@/components/settings/account-settings-form';
|
||||
|
||||
/**
|
||||
* Account settings page — /settings/general/account.
|
||||
* Shows avatar upload and (for local users only) password change form.
|
||||
*/
|
||||
export default function AccountSettingsPage() {
|
||||
const t = useTranslations('settings');
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="mb-6 text-lg font-semibold text-foreground">
|
||||
{t('account.title')}
|
||||
</h1>
|
||||
<AccountSettingsForm />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
Copy this exact page-shell shape: `'use client'`, `useTranslations('settings')`, `<h1>` title, then delegate the real content to a dedicated component (`DesktopAppSettings` or similar, under `apps/web/src/components/settings/`, matching the codebase's page-vs-component split already used for `account`/`calendar`/`widget` settings).
|
||||
|
||||
---
|
||||
|
||||
### `apps/web/src/components/settings/settings-sidebar.tsx` (component — add nav item)
|
||||
|
||||
**Analog:** itself (existing "Konto" nav item under "Allgemein" category, lines 41-61)
|
||||
```tsx
|
||||
{/* Allgemein category — above Dashboard (Surface C, 07-06) */}
|
||||
<div className="p-4 pb-2">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{t('categoryGeneral')}
|
||||
</h2>
|
||||
</div>
|
||||
<nav className="mb-2 flex flex-col gap-1 px-3">
|
||||
<Link
|
||||
href="/settings/general/account"
|
||||
className={`flex items-center rounded-md px-2 py-1.5 text-sm transition-colors ${
|
||||
isActive('/settings/general/account')
|
||||
? 'bg-sidebar-accent text-sidebar-accent-foreground font-medium'
|
||||
: 'text-sidebar-foreground hover:bg-muted'
|
||||
}`}
|
||||
aria-current={isActive('/settings/general/account') ? 'page' : undefined}
|
||||
>
|
||||
{t('categoryAccount')}
|
||||
</Link>
|
||||
</nav>
|
||||
```
|
||||
Add a second `<Link href="/settings/general/desktop">` inside the same `<nav>` under "Allgemein", using `t('categoryDesktopApp')` (new i18n key) — same `isActive()`/`aria-current` pattern, since `isActive()` (lines 27-33) already does a generic `pathname.startsWith(href)` fallback that works unmodified for the new route.
|
||||
|
||||
---
|
||||
|
||||
### `apps/web/src/messages/de.json` / `en.json` (i18n)
|
||||
|
||||
**Analog:** itself — existing `settings.account.*` nested block
|
||||
```json
|
||||
"account": {
|
||||
"title": "Konto",
|
||||
"avatarLabel": "Profilbild",
|
||||
...
|
||||
}
|
||||
```
|
||||
Add `settings.desktop.*` (title, version label, download buttons, file-size format, 3-4 explanatory sentences, all in Sie-Form per D-12/D-13) and `settings.categoryDesktopApp` (nav label) plus `auth.desktopDownload.*` (login-page link labels) following the identical flat-nested-object convention. Mirror every German key 1:1 into `en.json` (confirmed both files share identical key structure across all existing namespaces).
|
||||
|
||||
---
|
||||
|
||||
### `apps/web/src/app/(portal)/settings/general/desktop/desktop-settings.test.tsx` (test)
|
||||
|
||||
**Analog:** `apps/web/src/components/settings/widget-settings-panel.test.tsx` (next-intl mock, lines 1-30, and `de.json`-driven text assertions)
|
||||
```tsx
|
||||
vi.mock('next-intl', async () => {
|
||||
const messages = (await import('@/messages/de.json')).default as Record<string, unknown>;
|
||||
const lookup = (path: string): string | undefined =>
|
||||
path.split('.').reduce<unknown>((o, k) => (o && typeof o === 'object' ? (o as any)[k] : undefined), messages) as
|
||||
| string
|
||||
| undefined;
|
||||
return {
|
||||
useTranslations:
|
||||
(ns?: string) =>
|
||||
(key: string, values?: Record<string, unknown>) => {
|
||||
const raw = lookup(ns ? `${ns}.${key}` : key) ?? key;
|
||||
return values ? raw.replace(/\{(\w+)\}/g, (_: string, n: string) => String(values[n] ?? '')) : raw;
|
||||
},
|
||||
};
|
||||
});
|
||||
```
|
||||
Combine with `apps/web/src/lib/app-version.test.ts`'s `vi.stubGlobal('fetch', ...)` pattern to mock `/desktop/latest` responses for the two required cases (DESK-03 test map): link/section renders with version+size+buttons when the API responds 200; link/section is absent when the API 404s. Same combination applies to the login-page test (new or extended file — none found for `login` in this research pass per RESEARCH.md Wave 0 Gaps).
|
||||
|
||||
---
|
||||
|
||||
### `apps/desktop/src-tauri/src/lib.rs` (provider, event-driven — modify)
|
||||
|
||||
**Analog:** itself, existing version-check block (lines 82-101) and tray menu (lines 41-66)
|
||||
|
||||
**Existing version-check block to redirect** (verbatim, current state):
|
||||
```rust
|
||||
if let Some(server_url) = url_for_check {
|
||||
let app_handle = app.handle().clone();
|
||||
let app_version = env!("CARGO_PKG_VERSION").to_string();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let url = format!("{}/health/version", server_url.trim_end_matches('/'));
|
||||
if let Ok(resp) = reqwest::get(&url).await {
|
||||
if let Ok(info) = resp.json::<VersionResponse>().await {
|
||||
if info.version != app_version {
|
||||
let _ = app_handle
|
||||
.notification()
|
||||
.builder()
|
||||
.title("Tessera Update")
|
||||
.body("Eine neue Version ist verfuegbar.")
|
||||
.show();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
Change target URL to `/desktop/latest`, update the notification body per D-13 ("Neue Version X.Y.Z verfuegbar" — interpolate `info.version`), and enable the tray "Update herunterladen" item on version mismatch (needs holding a `MenuItem` handle created during `.setup()`, same builder family as `open`/`quit` below).
|
||||
|
||||
**Existing tray-menu pattern to extend** (verbatim, lines 41-66 — note current "Oeffnen"/"Beenden" lack umlauts, D-13 requires fixing to "Öffnen"/"Beenden"):
|
||||
```rust
|
||||
let open = MenuItemBuilder::with_id("open", "Oeffnen").build(app)?;
|
||||
let quit = MenuItemBuilder::with_id("quit", "Beenden").build(app)?;
|
||||
let menu = MenuBuilder::new(app)
|
||||
.item(&open)
|
||||
.separator()
|
||||
.item(&quit)
|
||||
.build()?;
|
||||
...
|
||||
.on_menu_event(|app, event| match event.id().as_ref() {
|
||||
"open" => { ... }
|
||||
"quit" => { app.exit(0); }
|
||||
_ => {}
|
||||
})
|
||||
```
|
||||
Add `update` (opener call, RESEARCH.md Code Example #4) and `autostart` (`CheckMenuItemBuilder`, RESEARCH.md Code Example #5) items into this same `MenuBuilder` chain and `match` arm list — same builder/match idiom, no new pattern needed.
|
||||
|
||||
**Imports to add** at the top (alongside existing `use tauri_plugin_...` lines 7-9):
|
||||
```rust
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
use tauri_plugin_autostart::ManagerExt;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `apps/desktop/src/setup.html` (component — polish)
|
||||
|
||||
**Analog:** itself — already Tessera-themed (oklch brand colors, e.g. `oklch(0.91 0.19 102)` for the `<h1>`, `oklch(0.17 0.01 260)` background, lines 1-60). D-13's "Tessera-Farben" requirement is largely already satisfied; the remaining work is auditing body-text strings for Du-form and converting to Sie-form (per project convention: app texts always use Sie-form, per user's global memory `feedback_anrede_du.md`). No structural analog change needed — read the full 254-line file directly when executing, since it's small enough for one `Read` call, and grep for `du/dein/dich/deine` occurrences to fix.
|
||||
|
||||
---
|
||||
|
||||
### `apps/desktop/src-tauri/capabilities/default.json` (config)
|
||||
|
||||
**Analog:** itself (existing permissions array, whole file)
|
||||
```json
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Tessera desktop capabilities",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"store:default",
|
||||
"notification:default",
|
||||
"notification:allow-is-permission-granted",
|
||||
"notification:allow-request-permission",
|
||||
"notification:allow-notify",
|
||||
"autostart:allow-enable",
|
||||
"autostart:allow-disable",
|
||||
"autostart:allow-is-enabled",
|
||||
"window-state:default"
|
||||
]
|
||||
}
|
||||
```
|
||||
Append a scoped opener permission object (not a bare string, since it needs a URL scope) per RESEARCH.md Code Example #4:
|
||||
```json
|
||||
{ "identifier": "opener:allow-open-url", "allow": [{ "url": "https://*" }, { "url": "http://*" }] }
|
||||
```
|
||||
`http://*` is required because D-02 permits non-HTTPS server addresses for internal LAN use (same reasoning already present in `setup.html`'s existing HTTP warning). Autostart permissions (`allow-enable`/`allow-disable`/`allow-is-enabled`) are already present — no change needed there.
|
||||
|
||||
---
|
||||
|
||||
### `apps/desktop/src-tauri/Cargo.toml` (config)
|
||||
|
||||
**Analog:** itself (existing `[dependencies]` block, lines 13-21)
|
||||
```toml
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = ["tray-icon"] }
|
||||
tauri-plugin-store = "2"
|
||||
tauri-plugin-notification = "2"
|
||||
tauri-plugin-autostart = "2"
|
||||
tauri-plugin-window-state = "2"
|
||||
reqwest = { version = "0.12", features = ["json"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
```
|
||||
Add `tauri-plugin-opener = "2"` in the same unpinned-major style as every other `tauri-plugin-*` line (no lockfile hand-editing — `cargo add tauri-plugin-opener` regenerates `Cargo.lock`, matching how the other four plugins were presumably added in Phase 6).
|
||||
|
||||
---
|
||||
|
||||
### Docs (`docs/anleitung-anwender.md`, `docs/anleitung-betrieb.md`, `docs/anleitung-entwicklung.md`)
|
||||
|
||||
**Analog for anwender.md:** existing `### DKV-Rechnung` module sub-chapter (line 120) under `## Die Module` (line 95) — same H2/H3 nesting and "what it is / how to use it" narrative tone in Sie-Form. New "Desktop-App" content per D-15 fits better as its own `##` chapter (parallel to `## Dashboard`, `## Marktplatz`) since it's not a module in the marketplace sense — insert after `## Persönliche Einstellungen` (line 143) and before `## Einen Fehler melden` (line 160), and add it to the `## Inhaltsverzeichnis` (line 6) in the same list style as every other chapter entry there.
|
||||
|
||||
**Analog for betrieb.md:** existing `## 9. Zwei Kanäle: Live und Beta` (line 357), specifically its `### Die eine Zeile je Server` (line 385) and `### Eine Version freigeben` (line 430) sub-sections — same numbered-`##`-chapter, `###`-subsection, imperative-instruction style. New pipeline/desktop-dist/release content fits as a new numbered section (e.g. `## 10.`) or a new `###` under an existing pipeline-adjacent section (`## 8. Abgrenzung zur CI/CD-Pipeline`, line 343) — follow whichever the phase's plan decides, but match this file's existing numbered-heading + Inhaltsverzeichnis-list convention (line 12).
|
||||
|
||||
**Analog for entwicklung.md:** existing paragraph at line 39 (exact text to replace):
|
||||
```
|
||||
`apps/desktop` besteht bislang nur aus dem Tauri-Grundgerüst (`src-tauri/`) und einer einzelnen
|
||||
```
|
||||
Replace this sentence to reflect the finished state (no longer "nur ... Grundgerüst") and add the local-build instructions (`pnpm --filter @tessera/desktop build`) per D-15, matching this file's existing code-block + prose style used elsewhere in `## Lokale Entwicklungsumgebung` (line 58, `### Stack starten`, line 82).
|
||||
|
||||
---
|
||||
|
||||
### `CHANGELOG.md` (doc)
|
||||
|
||||
**Analog:** itself — existing `## Unveröffentlicht` → `### Neu` bullet list (lines 5-9)
|
||||
```markdown
|
||||
## Unveröffentlicht
|
||||
|
||||
### Neu
|
||||
|
||||
- Kalender-Widget: Monatsübersicht mit Terminanzahl je Tag, Termine beim Überfahren, darunter „Nächste Termine“
|
||||
- Kalender-Widget: Einstellungen für Monatsansicht, Anzahl und Zeitraum der Termine
|
||||
- Favoriten-Widget: optionaler Titel (ohne Titel keine Kopfzeile)
|
||||
```
|
||||
Add per D-17, same bullet style (bold-free, colon-separated feature:description shape):
|
||||
```markdown
|
||||
- Desktop-App für Windows und Linux: Download auf der Anmeldeseite und unter Einstellungen → Desktop-App
|
||||
```
|
||||
|
||||
## Shared Patterns
|
||||
|
||||
### Public, unauthenticated route (`@Public()`)
|
||||
**Source:** `apps/api/src/auth/decorators/public.decorator.ts` (whole file) + `apps/api/src/health/health.controller.ts` (lines 8-9, 20-21)
|
||||
**Apply to:** Both `apps/api/src/desktop/desktop.controller.ts` routes (`GET /desktop/latest`, `GET /desktop/download/:platform`)
|
||||
```typescript
|
||||
@Public()
|
||||
@Get('version')
|
||||
getVersion(): VersionResponse {
|
||||
return getAppVersion();
|
||||
}
|
||||
```
|
||||
Pin with a spec test asserting `Reflect.getMetadata(IS_PUBLIC_KEY, DesktopController.prototype.getLatest)` (and `.download`) `=== true`, matching `health.controller.spec.ts` Test 6 — this is explicitly called out in RESEARCH.md's V4 Access Control row as the negative case to guard (routes must NOT accidentally inherit tenant/role checks).
|
||||
|
||||
### Whitelist-then-lookup file access (never trust request input for a filesystem path)
|
||||
**Source:** `apps/api/src/dkv/dkv.service.ts:703-729` (`getExportFile`)
|
||||
**Apply to:** `apps/api/src/desktop/desktop.service.ts` (`getPackageStream`)
|
||||
Two-stage gate: (1) reject the identifier via a fixed whitelist before any filesystem touch (regex for DKV filenames; a 2-item `const PLATFORMS` array for desktop platforms — stricter, since desktop never even accepts a filename from the request), (2) resolve the actual file path only from a trusted, non-request-derived source (DKV: an ownership row in the DB; desktop: `manifest.json`, written only by CI). Both throw `BadRequestException` for the whitelist failure and `NotFoundException` for the missing-file case — reuse these same two exception types.
|
||||
|
||||
### Memoized public fetch, fail-silent-to-null
|
||||
**Source:** `apps/web/src/lib/app-version.ts:50-63` (`loadApiVersion`)
|
||||
**Apply to:** `apps/web/src/lib/desktop.ts` (`loadDesktopLatest`), and by extension every component consuming it (login page, settings page) which should treat `null` as "hide this UI", never as an error to surface
|
||||
```typescript
|
||||
let apiVersionPromise: Promise<ApiVersionInfo | null> | null = null;
|
||||
export function loadApiVersion(): Promise<ApiVersionInfo | null> {
|
||||
if (!apiVersionPromise) {
|
||||
apiVersionPromise = fetch(`${API_URL}/health/version`, { credentials: 'include' })
|
||||
.then((res) => (res.ok ? (res.json() as Promise<ApiVersionInfo>) : null))
|
||||
.catch(() => null);
|
||||
}
|
||||
return apiVersionPromise;
|
||||
}
|
||||
```
|
||||
|
||||
### CI script idempotency (GET → decide by HTTP code → PATCH-or-POST)
|
||||
**Source:** `.gitea/scripts/publish-release.sh:125-155`
|
||||
**Apply to:** New asset-upload logic in the same script (D-08); any future CI script touching the Gitea API
|
||||
```sh
|
||||
CODE=$(curl -sS --header @"$HDR" -o "$RESP" -w '%{http_code}' "$TAG_URL")
|
||||
case "$CODE" in
|
||||
200) ... PATCH ... ;;
|
||||
404) ... POST ... ;;
|
||||
*) echo "... antwortete mit $CODE:" >&2; cat "$RESP" >&2; exit 1 ;;
|
||||
esac
|
||||
```
|
||||
|
||||
### fs mocking under ESM (Vitest)
|
||||
**Source:** `apps/api/src/dkv/dkv.service.spec.ts:27-31`
|
||||
**Apply to:** `apps/api/src/desktop/desktop.service.spec.ts` (manifest read + platform whitelist + traversal tests all need `fs.existsSync`/`readFileSync` mocked)
|
||||
```typescript
|
||||
vi.mock('fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('fs')>();
|
||||
return { ...actual, existsSync: vi.fn(), readFileSync: vi.fn() };
|
||||
});
|
||||
```
|
||||
`vi.spyOn(fs, 'existsSync')` fails under this project's ESM setup ("Cannot redefine property") — `vi.mock()` is mandatory, not optional style.
|
||||
|
||||
### German-first documentation and UI copy in Sie-Form
|
||||
**Source:** every file in `docs/`, every `apps/web/src/messages/de.json` string, every CI script's German header comments
|
||||
**Apply to:** all new docs chapters, all new i18n keys, all new `lib.rs`/`setup.html` user-facing strings (tray texts, notifications, setup-page copy) — matches the user's standing global instruction (Sie-Form for app texts, Du-form only in conversation) and this repo's own established convention.
|
||||
|
||||
## No Analog Found
|
||||
|
||||
| File | Role | Data Flow | Reason |
|
||||
|------|------|-----------|--------|
|
||||
| `apps/api/src/desktop/desktop.controller.ts` (streaming half only — `StreamableFile` usage) | controller | streaming | No route in this codebase currently streams a file via `StreamableFile`; `dkv.controller.ts`'s equivalent buffers the whole file with `res.send(buffer)` instead. Use RESEARCH.md Code Example #2 (cites `docs.nestjs.com` Techniques > Streaming Files directly) rather than an in-repo precedent. |
|
||||
| `apps/desktop/src-tauri/src/lib.rs` (`CheckMenuItemBuilder` for the autostart tray toggle) | provider | event-driven | No existing `CheckMenuItem` (checkbox-style tray item) exists in `lib.rs` today — only plain `MenuItemBuilder` items (`open`, `quit`). RESEARCH.md Code Example #5 (cites `v2.tauri.app/plugin/autostart/`) is the reference; the builder/match-arm *shape* to slot it into is still the existing tray-menu pattern above. |
|
||||
|
||||
## Metadata
|
||||
|
||||
**Analog search scope:** `apps/api/src/health/`, `apps/api/src/dkv/`, `apps/api/src/auth/decorators/`, `apps/api/Dockerfile`, `packages/shared/src/`, `apps/web/src/lib/`, `apps/web/src/app/(auth)/login/`, `apps/web/src/app/(portal)/settings/`, `apps/web/src/components/settings/`, `apps/web/src/messages/`, `.gitea/workflows/`, `.gitea/scripts/`, `apps/desktop/src-tauri/`, `apps/desktop/src/`, `docs/`, `CHANGELOG.md`
|
||||
**Files scanned:** ~30 (all read fully or via targeted `sed -n`/`grep -n` ranges; no re-reads of the same line range)
|
||||
**Pattern extraction date:** 2026-09-16
|
||||
**Tracked-source gate:** all 27 analog paths verified via `git ls-files` — all tracked, none are gitignored mirrors.
|
||||
@@ -19,20 +19,22 @@ created: "2026-09-16"
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| **Framework** | {pytest 7.x / jest 29.x / vitest / go test / other} |
|
||||
| **Config file** | {path or "none — Wave 0 installs"} |
|
||||
| **Quick run command** | `{quick command}` |
|
||||
| **Full suite command** | `{full command}` |
|
||||
| **Estimated runtime** | ~18 seconds |
|
||||
| **Framework** | Vitest 3.2.6 (`apps/api`, `environment: node`), Vitest 4.1.9 (`apps/web`, `environment: jsdom`), Cargo/Clippy 1.96 (`apps/desktop/src-tauri`), POSIX `sh -n` fuer CI-Skripte |
|
||||
| **Config file** | `apps/api/vitest.config.ts`, `apps/web/vitest.config.ts`, `apps/desktop/src-tauri/Cargo.toml` |
|
||||
| **Quick run command** | `pnpm --filter @tessera/api exec vitest run src/desktop` · `pnpm --filter @tessera/web exec vitest run src/lib/desktop.test.ts src/components/desktop src/components/settings/desktop-app-settings.test.tsx` · `cd apps/desktop/src-tauri && cargo check` |
|
||||
| **Full suite command** | `pnpm --filter @tessera/api exec vitest run && pnpm --filter @tessera/web exec vitest run && pnpm --filter @tessera/api type-check && pnpm --filter @tessera/web type-check` |
|
||||
| **Estimated runtime** | ~18 seconds (Quick), ~90 seconds (Full; Web-Suite 52 Dateien / 354 Tests am 2026-09-16 plus die neuen) |
|
||||
|
||||
`biome check` ist kein Tor (bekannter Fehler in der Wurzel-`biome.json`, nicht anfassen).
|
||||
|
||||
---
|
||||
|
||||
## Sampling Rate
|
||||
|
||||
- **After every task commit:** Run `{quick run command}`
|
||||
- **After every plan wave:** Run `{full suite command}`
|
||||
- **Before `/gsd-verify-work`:** Full suite must be green
|
||||
- **Max feedback latency:** 18 seconds
|
||||
- **After every task commit:** Run the quick command of the touched workspace (siehe Verification Map)
|
||||
- **After every plan wave:** Run `pnpm --filter @tessera/api exec vitest run && pnpm --filter @tessera/web exec vitest run`
|
||||
- **Before `/gsd-verify-work`:** Full suite must be green; zusaetzlich ein gruener Pipeline-Lauf mit beiden Paketen (18-05, Phasen-Tor per D-16)
|
||||
- **Max feedback latency:** 18 seconds (Quick); der lokale AppImage-Bau (18-01 T1/T2, 18-04 T2) und der Docker-Neubau (18-01 T1) sind bewusste Ausnahmen von mehreren Minuten
|
||||
|
||||
---
|
||||
|
||||
@@ -40,7 +42,22 @@ created: "2026-09-16"
|
||||
|
||||
| Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status |
|
||||
|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------|
|
||||
| 18-01-01 | 01 | 1 | REQ-{XX} | T-18-01 / — | {expected secure behavior or "N/A"} | unit | `{command}` | ✅ / ❌ W0 | ⬜ pending |
|
||||
| 18-01-01 | 01 | 1 | DESK-03, DESK-05 | T-18-01 / T-18-02 | Plattform-Whitelist vor Dateisystemzugriff; Dateiname nur aus Manifest; Namensmuster-Pruefung | HTTP-Durchstich (NestFactory) + Unit | `pnpm --filter @tessera/api exec vitest run src/desktop` | ❌ W0 (`apps/api/src/desktop/desktop.service.spec.ts`) | ⬜ pending |
|
||||
| 18-01-01 | 01 | 1 | DESK-03 | T-18-06 | Manifest-Hash stimmt mit Datei ueberein | Skript-Probe | `sh .gitea/scripts/desktop-collect.sh --require linux` + sha256-Vergleich | ✅ (Skript entsteht in der Task) | ⬜ pending |
|
||||
| 18-01-01 | 01 | 1 | DESK-03 | T-18-01 | Abbild liefert nur Manifest-Dateien, `attachment`-Header | Integration (lokaler Docker-Stack) | `curl -sf http://localhost:3001/desktop/latest` + Header-Check `/desktop/download/linux` + `/api-proxy/desktop/latest` | ✅ | ⬜ pending |
|
||||
| 18-01-02 | 01 | 1 | DESK-05 | — | Nur rein numerische Versionen werden geschrieben (NSIS) | Skript-Probe (positiv + negativ) | `sh .gitea/scripts/desktop-version.sh --print` = `1.1.0`; `DESKTOP_TAG=v1.2.3-beta … --print` endet mit Exit 1 | ✅ (Skript entsteht in der Task) | ⬜ pending |
|
||||
| 18-02-01 | 02 | 2 | DESK-01, DESK-04 | T-18-06 / T-18-21 | publish bricht ohne Manifest ab; kein upload-artifact; Cache-Schluessel exakt am SHA | Statisch (Workflow-Greps, `sh -n`, Probelauf) | `grep` auf `fail-on-cache-miss`, `needs: desktop`, `desktop-dist-${{ gitea.sha }}` (2x), `upload-artifact`=0; `publish-images.sh --print-plan` (4 push-Zeilen) | ✅ | ⬜ pending |
|
||||
| 18-02-02 | 02 | 2 | DESK-04 | T-18-03 | Token nur ueber Header-Datei, nie in einer curl-Zeile | Statisch (`sh -n`, Probelauf, Greps) | `sh -n publish-release.sh`; `publish-release.sh --dry-run --tag v1.1.0` nennt `assets?name=Tessera-1.1.0.AppImage`; `grep -c 'curl.*GITEA_TOKEN'`=0 | ✅ | ⬜ pending |
|
||||
| 18-03-01 | 03 | 2 | DESK-03 | T-18-07 | Linkziel nur aus `API_URL` + relativem `url` | Unit + Komponente | `pnpm --filter @tessera/web exec vitest run src/lib/desktop.test.ts src/components/desktop` | ❌ W0 (`apps/web/src/lib/desktop.test.ts`, `apps/web/src/components/desktop/desktop-download-links.test.tsx`) | ⬜ pending |
|
||||
| 18-03-02 | 03 | 2 | DESK-03 | T-18-08 | Hinweistext statt Knoepfe ohne Manifest; Text escaped | Komponente + i18n-Paritaet/Umlaut-Guard + Web-Suite | `pnpm --filter @tessera/web exec vitest run src/components/settings/desktop-app-settings.test.tsx …`; node-Paritaetsskript (`i18n OK`); `pnpm --filter @tessera/web exec vitest run` | ❌ W0 (`apps/web/src/components/settings/desktop-app-settings.test.tsx`) | ⬜ pending |
|
||||
| 18-04-01 | 04 | 2 | DESK-02, DESK-05 | T-18-10 / T-18-12 | Nur http/https; Opener nur mit gespeicherter `server_url`; Capability-Scope | Compile + Clippy + Kennzeichen-Greps | `cargo check && cargo clippy` (in `apps/desktop/src-tauri`); Greps auf `fn check_server`, `api-proxy`, `"Öffnen"`, `opener:allow-open-url` | ✅ (kein Vitest; Rust-Toolchain vorhanden) | ⬜ pending |
|
||||
| 18-04-02 | 04 | 2 | DESK-01, DESK-02 | T-18-11 | Kein Fremdcode in CSP; kein Modul-Import; keine vorbelegte Adresse | Statisch + lokaler Bau | Greps auf `window.__TAURI__.core`, `invoke('check_server'`, `unpkg.com`=0; `magick identify` Icon-Groessen; AppImage neuer als `lib.rs`; `desktop-collect.sh --require linux` | ✅ | ⬜ pending |
|
||||
| 18-05-01 | 05 | 3 | DESK-01, DESK-04 | T-18-15 | `--locked` Werkzeuginstallation; Reihenfolge AppImage vor NSIS | Statisch | Greps auf `cargo-xwin` (≥3), `--target x86_64-pc-windows-msvc --bundles nsis`, `--require linux,windows`; node-Reihenfolgepruefung | ✅ | ⬜ pending |
|
||||
| 18-05-02 | 05 | 3 | DESK-01, DESK-04, DESK-05 | T-18-18 | Secrets im Log maskiert | Manuell (Checkpoint: Orchestrator pusht und liest den Lauf) | — (human-action) | N/A | ⬜ pending |
|
||||
| 18-05-03 | 05 | 3 | DESK-01, DESK-04 | T-18-17 | Jede Runde ein Commit mit Ursache | Statisch + Compile | `sh -n` (drei Skripte); `cargo check`; `git rev-list --count --grep='ci(desktop): Runde' HEAD~6..HEAD` ≤ 3 | ✅ | ⬜ pending |
|
||||
| 18-06-01 | 06 | 4 | DESK-03, DESK-05 | T-18-19 / T-18-20 | SmartScreen-Hinweis an Herkunft gekoppelt; keine Firmenadresse | Doku-Greps | Greps auf `## Desktop-App`, `(#desktop-app)`, `Trotzdem ausführen`, ≥9 `###` im Kapitel, 0 Firmenadressen; CHANGELOG-Position (node) | ✅ | ⬜ pending |
|
||||
| 18-06-02 | 06 | 4 | DESK-04 | T-18-20 | Keine Firmenadresse in neuen Abschnitten | Doku-Greps | Greps auf `## 10. Desktop-App`, `DESKTOP_DIST_DIR`, `/app/desktop-dist`, `### Fehlerbilder`, `cargo-xwin` (≥2), `### Desktop-App lokal bauen`, `Tauri-Grundgerüst`=0 | ✅ | ⬜ pending |
|
||||
| 18-06-03 | 06 | 4 | DESK-01..05 | — | — | Gesamtlauf + Bedienprobe | `grep -c` DESK-Eintraege = 5 und Traceability = 5; Full suite + `cargo check` (`ALL-GREEN`) | ✅ | ⬜ pending |
|
||||
|
||||
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
|
||||
|
||||
@@ -48,11 +65,11 @@ created: "2026-09-16"
|
||||
|
||||
## Wave 0 Requirements
|
||||
|
||||
- [ ] `{tests/test_file.py}` — stubs for REQ-{XX}
|
||||
- [ ] `{tests/conftest.py}` — shared fixtures
|
||||
- [ ] `{framework install}` — if no framework detected
|
||||
|
||||
*If none: "Existing infrastructure covers all phase requirements."*
|
||||
- [ ] `apps/api/src/desktop/desktop.service.spec.ts` — HTTP-Durchstich ueber `NestFactory.create(DesktopModule)` mit echtem Temp-Verzeichnis: Manifest vorhanden (200), fehlt (404), unbekannte Plattform und Traversal (400, vor jedem Dateisystemzugriff), fehlende Plattform im Manifest (404), Manifest-Name mit Pfadzeichen (404), `@Public()`-Metadaten — entsteht in 18-01 Task 1 (DESK-03, DESK-05, T-18-01/02)
|
||||
- [ ] `apps/web/src/lib/desktop.test.ts` — memoisiertes Laden, still bei Fehler, `desktopDownloadUrl`, `formatFileSize` — 18-03 Task 1 (DESK-03)
|
||||
- [ ] `apps/web/src/components/desktop/desktop-download-links.test.tsx` — Link erscheint/verschwindet je nach API-Antwort, nur-Linux-Fall — 18-03 Task 1 (DESK-03, D-12)
|
||||
- [ ] `apps/web/src/components/settings/desktop-app-settings.test.tsx` — Version/Knoepfe/Groesse, Beta-Zeile, Hinweisfall — 18-03 Task 2 (DESK-03, D-12)
|
||||
- [ ] Framework install: none — Vitest ist in beiden Apps konfiguriert; Rust/Clippy und ImageMagick sind auf dem Entwicklungsrechner vorhanden (18-RESEARCH.md, Environment Availability; am 2026-09-16 geprueft)
|
||||
|
||||
---
|
||||
|
||||
@@ -60,9 +77,10 @@ created: "2026-09-16"
|
||||
|
||||
| Behavior | Requirement | Why Manual | Test Instructions |
|
||||
|----------|-------------|------------|-------------------|
|
||||
| {behavior} | REQ-{XX} | {reason} | {steps} |
|
||||
|
||||
*If none: "All phase behaviors have automated verification."*
|
||||
| Windows-NSIS-Cross-Bau erzeugt eine gueltige `.exe` | DESK-01, DESK-04 | Kein Windows-Werkzeug lokal (kein `makensis`, kein `cargo-xwin` auf dem Entwicklungsrechner); nur in der Pipeline beweisbar (D-16) | 18-05 Task 2: Orchestrator pusht, liest den Job `desktop`, meldet beide Dateizeilen aus "Pakete einsammeln"; Iterationsschleife max. 3 Runden |
|
||||
| Installer laeuft auf einem Windows-PC, Erststart zeigt die Anmeldung, Tray/Schliessen/Autostart/Beenden, Einstellungsseite | DESK-01, DESK-02, DESK-03 | Bedienung eines echten Windows-Systems | 18-06 Task 3 `<human-check>`, Schritte 1-10 (Nutzer) |
|
||||
| Update-Hinweis bei neuerer Client-Version | DESK-05 | Braucht einen Server mit hoeherer Version als der installierte Client — erst nach dem naechsten Freigabe-Tag | 18-06 Task 3 `<human-check>` Punkt (a): nach Tag `v1.2.0` zeigt der 1.1.0-Client die Benachrichtigung und den Menueeintrag "Version 1.2.0 herunterladen" |
|
||||
| Release-Dateien am Gitea-Release | DESK-04 | Upload laeuft nur bei Tags; ein Test-Tag wuerde den Live-Kanal ausloesen | 18-06 Task 3 `<human-check>` Punkt (b): nach Tag `v1.2.0` traegt der Release `Tessera-Setup-1.2.0.exe` und `Tessera-1.2.0.AppImage`; bis dahin: `publish-release.sh --dry-run --tag v1.1.0` nennt die Uploads (18-02 Task 2) |
|
||||
|
||||
---
|
||||
|
||||
@@ -75,4 +93,4 @@ created: "2026-09-16"
|
||||
- [ ] Feedback latency < 18s
|
||||
- [ ] `nyquist_compliant: true` set in frontmatter
|
||||
|
||||
**Approval:** {pending / approved YYYY-MM-DD}
|
||||
**Approval:** pending
|
||||
|
||||
Reference in New Issue
Block a user