Files
tessera-ctl/.planning/quick/260917-kgc-desktop-client-update-in-der-app-herunte/260917-kgc-PLAN.md
T
schalli efbd6e8974
Tessera CI/CD / Lint & Type Check (push) Successful in 53s
Tessera CI/CD / Tests (push) Successful in 1m5s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 18s
Tessera CI/CD / Build & Publish Images (push) Successful in 4m22s
docs(quick-260917-kgc): Aktenstand — Update in der Desktop-App, alle Nachweise erbracht, Wiedereinstieg bereinigt
Quick 260917-kgc (Plan/Recherche/Bericht/Verifikation) und Schnellfix a6d1a64
in der Quick-Task-Tabelle; Nachweise in allen sechs Berichten nachgetragen
(Playwright lokal, CI-Laeufe 382-384, Windows-Test-VM: In-App-Update
7479cb4 -> a6d1a64). Ueberholte .continue-here-Dateien entfernt,
Desktop-Client-Todo geschlossen.

Dieser Push aendert nichts unter apps/desktop -- er ist zugleich der
Beweisfall 2 des CI-Desktop-Skips (Pakete aus dem Zwischenspeicher).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016g2npLxzH5gZpg8s2S6vKh
2026-09-18 11:29:14 +02:00

313 lines
78 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
phase: quick-260917-kgc
plan: 01
type: execute
wave: 1
depends_on: []
autonomous: true
requirements: [QUICK-260917-KGC]
files_modified:
- apps/desktop/src-tauri/Cargo.toml
- apps/desktop/src-tauri/Cargo.lock
- apps/desktop/src-tauri/tauri.conf.json
- apps/desktop/src-tauri/src/lib.rs
- packages/shared/src/index.ts
- apps/api/src/desktop/desktop.service.ts
- apps/api/src/desktop/desktop.controller.ts
- apps/api/src/desktop/desktop.service.spec.ts
- .gitea/scripts/desktop-collect.sh
- .gitea/scripts/desktop-stamp.sh
- .gitea/workflows/ci.yml
- .gitignore
- docs/anleitung-anwender.md
- docs/anleitung-betrieb.md
- docs/anleitung-entwicklung.md
- docs/ci-cd-setup.md
- CHANGELOG.md
estimate:
tokens: 95000
raw_tokens: 95000
tasks: 4
confidence: low
must_haves:
truths:
- "Client (lib.rs): `tauri-plugin-updater = \"2\"` und `semver = \"1\"` in Cargo.toml, `.plugin(tauri_plugin_updater::Builder::new().build())` in `run()`; keine JS-Nutzung, capabilities/default.json unveraendert. Cargo.lock loest `tauri-plugin-updater` auf 2.11.x auf (nicht 3.0.0-alpha), `semver` bleibt 1.0.28."
- "Client (tauri.conf.json): `bundle.createUpdaterArtifacts: true`; `plugins.updater.pubkey` = exakt der vom Orchestrator genannte Schluessel; `plugins.updater.windows.installMode: \"passive\"`; KEIN `endpoints`-Eintrag (Laufzeit), keine `dangerous*`-Schalter."
- "Client: reine, getestete Funktionen `is_update_newer(current: &Version, current_commit: &str, release: &Version) -> bool` (Basis groesser → true; kleiner → false; gleich → true genau dann, wenn `release.pre` die Form `beta.g<sha7>` hat, `current_commit` nicht leer ist und `<sha7>` != `current_commit`; gleiche Basis ohne Prerelease → false), `beta_commit(release: &Version) -> Option<&str>`, `update_endpoint(server: &str) -> Option<tauri::Url>` (Pfad `/api-proxy/desktop/update`, Query `target`/`arch`/`current` mit den Roh-Platzhaltern `{{target}}`/`{{arch}}`/`{{current_version}}` per `query_pairs_mut` und `base=<server ohne Schlussstrich>`), `release_labels(current, release)` und `update_labels` mit den neuen Texten `Auf Version {v} aktualisieren` / `Auf Beta-Stand {sha7} aktualisieren`. `cargo fmt --check`, `cargo check`, `cargo clippy`, `cargo test --lib` in apps/desktop/src-tauri gruen (CARGO_BUILD_JOBS=4)."
- "Client: `spawn_version_check(app, server_url)` ruft `app.updater_builder().endpoints(vec![endpoint])?.timeout(15 s).version_comparator(is_update_newer …).build()?.check().await` statt `reqwest::get(/desktop/latest)`; `Some(update)` → Benachrichtigung, Menuetext aus `release_labels`, Eintrag aktiv, `update.timeout = Some(600 s)` und Ablage in `app.state::<PendingUpdate>()`; `None` → Standardtext gesperrt; `Err(InsecureTransportProtocol)` (http-Server im Release-Bau) → Menuetext `Update nur über https möglich`, gesperrt, kein Absturz; andere Fehler still wie bisher. Der Eintrag wird zu Beginn jeder Pruefung zurueckgesetzt und der abgelegte Update-Stand geleert."
- "Client: Tray-Klick `update` nimmt den abgelegten Stand (`take()`), sperrt den Eintrag, zeigt die Benachrichtigung „Update wird heruntergeladen…“, ruft in `tauri::async_runtime::spawn` `update.download_and_install(on_chunk, on_finish)` (Fortschritt als Menuetext `Lädt … {n} %`, nach dem Download `Wird installiert…`), danach `app.restart()` (Windows: nie erreicht, das Plugin beendet den Prozess selbst; Linux: AppImage ersetzt → Neustart). Fehler → Menuetext/Stand zurueck, Eintrag wieder aktiv, Benachrichtigung „Update fehlgeschlagen: …“ und die Einstellungsseite `{server}/settings/general/desktop` oeffnet im Browser (bisheriges Verhalten als Rueckfall). Ist kein Stand abgelegt, oeffnet der Klick nur die Einstellungsseite."
- "API: `GET /desktop/update?target=&arch=&current=&base=` (`@Public()`, im Controller VOR `download/:platform`): `base` ist Pflicht und muss ein reiner http/https-Origin sein (kein Pfad ausser `/`, keine Query, kein Fragment, keine Userinfo) — sonst 400; `target` nicht `windows`/`linux`, `arch` nicht `x86_64`, Manifest fehlt, Plattform-Eintrag fehlt, `signature` fehlt oder `updateVersion` fehlt/ungueltig → 204 ohne Body; sonst 200 `{ version: <updateVersion>, pub_date: <buildTime, nur wenn RFC-3339-Form>, url: \"<origin>/api-proxy/desktop/download/<platform>\", signature, notes: \"Tessera <updateVersion>\" }`. Kein serverseitiger Abruf von `base`. `/desktop/latest` und `download/:platform` unveraendert. `pnpm --filter @tessera/api exec vitest run src/desktop` gruen (Bestand 11 Tests + neue), `pnpm --filter @tessera/api type-check` und `pnpm --filter @tessera/shared type-check` gruen."
- "Manifest/Bau: `desktop-collect.sh` schreibt `updateVersion` (`X.Y.Z` bei live/dev, `X.Y.Z-beta.g<sha7>` bei beta) und je Plattform `files.<p>.signature` = Inhalt der `.sig`-Datei neben dem Bundle (`<bundle>.sig`, eine Base64-Zeile); fehlt die `.sig`, bricht das Skript ab, wenn `TAURI_SIGNING_PRIVATE_KEY` gesetzt ist ODER der Kanal nicht `dev` ist (main/Tag), sonst Warnung und Feld weglassen. Dateinamen der Pakete bleiben (`Tessera-Setup-X.Y.Z[-beta.<sha7>].exe`, `Tessera-X.Y.Z[-beta.<sha7>].AppImage`), `.sig`-Dateien werden nicht kopiert. `desktop-stamp.sh check` liefert `reuse=false`, wenn `updateVersion` oder eine `files.<p>.signature` im gecachten Manifest fehlt."
- "CI: die beiden `tauri build`-Schritte im Job `desktop` tragen `env: TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}` und `TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}` — nur dort, kein Job-`env`, kein `--no-sign`; alle anderen Jobs/Schritte unveraendert (js-yaml-Tiefenvergleich gegen `git show HEAD:.gitea/workflows/ci.yml`). `.gitignore` bekommt `*.key`."
- "Doku: Anwenderhandbuch (Update per Klick im Infobereich, Ablauf Windows/Linux, https-Bedingung, einmaliger manueller Wechsel fuer bereits installierte 1.2.0-Clients); Betriebshandbuch Kap. 10 (Signierschluessel: Secret-Namen, Ablage `~/.tessera/desktop-updater/` auf dem Entwicklungsrechner, Sicherung, Verlust = alle Clients einmal manuell neu installieren, `--no-sign` nur lokal, Tempordner unter Windows, neuer Endpunkt in der Kontrollzeile); Entwicklungshandbuch (`tauri build --no-sign` lokal); ci-cd-setup.md (zwei neue Secrets, ASCII ohne Umlaute); CHANGELOG `## Unveröffentlicht` `### Neu` eine Zeile `Desktop-App: …`."
- "Vier Commits: `feat(desktop): …` (Task 1), `feat(api): …` (Task 2), `ci: …` (Task 3), `docs: …` (Task 4). Kein `git push`, keine `.planning/`-Commits, kein `tauri build`, kein Docker; `~/.tessera/desktop-updater/` wird nie gelesen oder ausgegeben."
artifacts:
- "apps/desktop/src-tauri/src/lib.rs — `is_update_newer`, `beta_commit`, `update_endpoint`, `release_labels`, `update_labels` (neue Texte), `PendingUpdate`, `check_for_update`, `spawn_update_install`, `open_download_page`, umgebautes `spawn_version_check`, Tray-Klick `update`, `mod tests` erweitert; `DesktopLatest`-Struct entfernt"
- "apps/desktop/src-tauri/Cargo.toml + Cargo.lock — `tauri-plugin-updater = \"2\"`, `semver = \"1\"`; tauri.conf.json — `createUpdaterArtifacts`, `plugins.updater`"
- "packages/shared/src/index.ts — `DesktopManifestFile.signature?`, `DesktopManifest.updateVersion?`, `DesktopUpdateResponse`"
- "apps/api/src/desktop/desktop.service.ts — `safeOrigin` (exportiert), `getUpdate`, `isValidManifestFileEntry` mit optionaler `signature`; desktop.controller.ts — `update()`; desktop.service.spec.ts — Tests 12-20"
- ".gitea/scripts/desktop-collect.sh — `.sig` → `signature`, `updateVersion`, Pflichtregel; desktop-stamp.sh — `check` verlangt `updateVersion` + `signature`; .gitea/workflows/ci.yml — Secrets an beiden Bau-Schritten; .gitignore — `*.key`"
- "docs/anleitung-anwender.md, docs/anleitung-betrieb.md, docs/anleitung-entwicklung.md, docs/ci-cd-setup.md, CHANGELOG.md"
key_links:
- "Vertrauenskette: CI signiert das Bundle mit dem privaten Schluessel (nur als Gitea-Secret) → `.sig` neben dem Bundle → `desktop-collect.sh` traegt den Inhalt als `files.<p>.signature` ins Manifest → API liefert `signature` + absolute `url` → Plugin laedt die Bytes, prueft die minisign-Signatur gegen `plugins.updater.pubkey` und installiert erst danach. Bricht ein Glied (kein `.sig`, falscher Schluessel, manipulierte Bytes), gibt es KEIN Update — nie eine Installation ohne gueltige Signatur."
- "Der `version_comparator` ersetzt den Standardvergleich vollstaendig (updater.rs Z. 576-579). Ohne ihn gilt SemVer `1.2.0-beta.gXXXX < 1.2.0` und ein Beta-Client saehe nie einen neueren Beta-Bau. Deshalb muss `updateVersion` fuer beta IMMER `X.Y.Z-beta.g<sha7>` sein (Praefix `g`: ein rein numerischer SHA mit fuehrender Null waere kein gueltiger SemVer-Identifier — `1.2.0-beta.0123456` scheitert beim Parsen, `check()` liefert Err)."
- "`UpdaterBuilder::timeout` wird in `Update.timeout` uebernommen und beim Download als reqwest-GESAMT-Timeout angewandt (updater.rs Z. 504-506 und 698-700). 15 s fuer die Pruefung sind richtig, wuerden aber den ~100-MB-Download abbrechen — darum `update.timeout = Some(Duration::from_secs(600))` (pub-Feld) vor der Ablage."
- "Die Endpunkt-URL traegt die Platzhalter als Query-Werte; `query_pairs_mut` kodiert sie zu `%7B%7Btarget%7D%7D` — das Plugin ersetzt BEIDE Schreibweisen (updater.rs Z. 476-486). `base` wird dadurch sauber prozent-kodiert; die API nimmt nur den Origin."
- "NestJS: `@Res({ passthrough: true })` + `res.status(204)` + `return undefined` ergibt 204 ohne Body, weil Nest den Standardstatus VOR dem Handler setzt (`setStatus(res, 200)`) und `reply()` ohne eigenen Statuscode aufruft (router-execution-context.js Z. 44-48 und createHandleResponseFn ohne 4. Argument) — der Handler-Status gewinnt."
- "Neustart nach dem Update: Windows — `Update::install` startet den NSIS-Installer (`/P /UPDATE /R /ARGS`) und beendet den Prozess per `std::process::exit(0)` aus dem Tokio-Thread; der Run-Handler (`ExitRequested { code: None }` → `prevent_exit`) wird nicht durchlaufen. Linux — `app.restart()` aus dem Nebenthread setzt `restart_on_exit` und `request_exit(RESTART_EXIT_CODE)` = `code: Some(i32::MAX)` (tauri app.rs Z. 588-611), das der Handler durchlaesst. Run-Handler und `on_window_event` bleiben unangetastet."
- "Skip-Mechanismus (jdh): das gecachte Manifest traegt `signature`/`updateVersion` mit; ein Cache-Stand VOR dieser Aenderung hat beides nicht → `check` gibt `reuse=false` → Neubau. Ein Schluesselwechsel (`pubkey` in tauri.conf.json unter `apps/desktop`) aendert den Stempel automatisch."
---
<objective>
Der Desktop-Client aktualisiert sich selbst: Die Versionspruefung beim Start (und nach einem Serverwechsel) fragt ueber `tauri-plugin-updater` den neuen API-Endpunkt `GET /desktop/update`, und ein Klick auf den Tray-Eintrag laedt das signierte Paket, prueft die Signatur, installiert es (Windows: NSIS passiv, Linux: AppImage an Ort und Stelle) und startet die App neu. Die Pakete werden im CI mit dem minisign-Schluessel signiert; der oeffentliche Schluessel steckt im Client.
1. **Client (Vorgaben 1, 2, 3, 6, 7).** Plugin einbauen, `pubkey`/`installMode`/`createUpdaterArtifacts` in tauri.conf.json, eigener `version_comparator` (`is_update_newer`), Endpunkt zur Laufzeit aus `stored_server_url`, Tray-Eintrag `update` wird zum Installieren (`Auf Version {v} aktualisieren` / `Auf Beta-Stand {sha7} aktualisieren`), Download mit Fortschritt im Menuetext, Neustart, Fehler-Rueckfall auf die Einstellungsseite im Browser. `http://`-Server: Plugin lehnt im Release ab → Menuetext `Update nur über https möglich`, gesperrt, kein Absturz.
2. **API (Vorgabe 4).** `GET /desktop/update` mit `base`-Origin-Validierung (400), 204-Faellen und dynamischem Updater-Format; `signature`/`updateVersion` als optionale Manifest-Felder in `@tessera/shared`.
3. **Manifest/Bau/CI (Vorgabe 5).** `desktop-collect.sh` sammelt die `.sig` als `signature`, schreibt `updateVersion`; `desktop-stamp.sh check` verlangt beides; Secrets nur an den zwei `tauri build`-Schritten; `*.key` in `.gitignore`.
4. **Doku (Vorgabe 8).** Anwender-, Betriebs-, Entwicklungshandbuch, ci-cd-setup.md, CHANGELOG.
**Entscheidung zur Vergleichsregel (Vorgabe 3, „einfachste tragfaehige Regel“):** gleiche Basis X.Y.Z und Server-Version OHNE Prerelease (Live-Freigabe) → KEIN Update. Begruendung: (a) Das ist exakt die heutige Regel in `spawn_version_check` (`version_changed || (channel == "beta" && commit != app_commit)`) — Verhalten bleibt fuer alle Live-Clients identisch, ein Live-Client 1.2.0 kann sich nie selbst 1.2.0 anbieten (keine Update-Schleife). (b) Die Variante „JA, wenn der Client selbst ein Beta-Stand ist“ ist client-seitig nicht entscheidbar: der Client kennt nur `CARGO_PKG_VERSION` (immer reines X.Y.Z, D-07) und `APP_COMMIT`, aber nicht den Tag-Commit der Live-Freigabe; die Live-Antwort traegt per Vorgabe 4 die reine `X.Y.Z` ohne Commit. Jede Loesung braeuchte ein zusaetzliches Feld (Build-Metadaten `+g<sha7>` oder maschinenlesbare `notes`) und damit eine Aenderung am Endpunkt-Vertrag. (c) Der einzige betroffene Fall — ein Beta-Client wird auf einen Live-Server mit derselben Basisversion umgestellt — bekommt das Update spaetestens mit der naechsten Freigabe X.Y.Z+1; bis dahin bleibt der Browser-Download. Die Regel steht als Kommentar an `is_update_newer`. Leerer `APP_COMMIT` (Quell-Tarball ohne git) → nur Basisvergleich, wie heute.
**Entscheidung `http://`-Meldung (Vorgabe 2/6):** keine Benachrichtigung bei jedem Start (das waere fuer Dauer-`http`-Server eine Nervmeldung), sondern der gesperrte Menuetext `Update nur über https möglich` — dauerhaft sichtbar, sobald der Nutzer das Menue oeffnet. Im Debug-Bau (`tauri dev`) erlaubt das Plugin `http` mit Warnung; dort darf `download_and_install` NICHT ausgeloest werden (ohne `APPIMAGE` wuerde es die Binary in `target/` ueberschreiben) — Handbuch-Hinweis, keine Code-Sperre noetig, weil CI nur Release baut.
Task-Zuschnitt: Task 1 ist der Tracer (Client-Kette Pruefung → Anzeige → Klick → Download → Signatur → Installation → Neustart), Task 2 die API, Task 3 Skripte + CI, Task 4 Doku. Vier Commits `feat(desktop)`/`feat(api)`/`ci`/`docs` (Vorgabe 10). Der echte Beweis — CI baut signierte Pakete, ein alter Client auf der Windows-VM aktualisiert sich per Tray-Klick — ist „Nachweis durch Orchestrator“ (Vorgabe 9) und wird im SUMMARY als offen gefuehrt.
Purpose: Der Nutzer aktualisiert die Desktop-App mit einem Klick im Infobereich statt Download, Installer und Neustart von Hand — und jedes installierte Paket ist nachweislich vom eigenen CI signiert.
Output: lib.rs mit Updater-Kette + Tests; Cargo.toml/Cargo.lock/tauri.conf.json; `@tessera/shared`-Typen; API-Endpunkt + Tests; collect/stamp-Skripte; ci.yml; .gitignore; vier Handbuch-/Doku-Dateien; CHANGELOG; vier Commits.
</objective>
<execution_context>
@~/.claude/gsd-core/workflows/execute-plan.md
@~/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@/home/vicolab/projects/tessera-ctl/CLAUDE.md
@/home/vicolab/projects/tessera-ctl/.planning/quick/260917-kgc-desktop-client-update-in-der-app-herunte/260917-kgc-RESEARCH.md
@/home/vicolab/projects/tessera-ctl/.planning/quick/260917-jn2-desktop-client-aktuelle-server-adresse-s/260917-jn2-SUMMARY.md
@/home/vicolab/projects/tessera-ctl/apps/desktop/src-tauri/src/lib.rs
@/home/vicolab/projects/tessera-ctl/apps/desktop/src-tauri/tauri.conf.json
@/home/vicolab/projects/tessera-ctl/apps/desktop/src-tauri/Cargo.toml
@/home/vicolab/projects/tessera-ctl/apps/desktop/src-tauri/build.rs
@/home/vicolab/projects/tessera-ctl/apps/api/src/desktop/desktop.service.ts
@/home/vicolab/projects/tessera-ctl/apps/api/src/desktop/desktop.controller.ts
@/home/vicolab/projects/tessera-ctl/apps/api/src/desktop/desktop.service.spec.ts
@/home/vicolab/projects/tessera-ctl/packages/shared/src/index.ts
@/home/vicolab/projects/tessera-ctl/.gitea/scripts/desktop-collect.sh
@/home/vicolab/projects/tessera-ctl/.gitea/scripts/desktop-stamp.sh
@/home/vicolab/projects/tessera-ctl/.gitea/workflows/ci.yml
</context>
<tasks>
<task type="tracer" tdd="true">
<name>Task 1: Client — Updater-Plugin, Config, Vergleichsregel, Endpunkt-URL, Tray-Fluss Pruefen → Herunterladen → Installieren → Neustart, Tests</name>
<files>apps/desktop/src-tauri/Cargo.toml, apps/desktop/src-tauri/Cargo.lock, apps/desktop/src-tauri/tauri.conf.json, apps/desktop/src-tauri/src/lib.rs</files>
<read_first>
- apps/desktop/src-tauri/src/lib.rs komplett (599 Zeilen, Stand nach jn2): Z. 12-28 `UPDATE_ITEM_DEFAULT`, `VersionResponse`, `DesktopLatest`; Z. 30-78 reine Helfer inkl. `update_labels`; Z. 142-216 `stored_server_url`, `TrayItems`, `apply_server`, `spawn_version_check` (der reqwest-Block Z. 188-215 wird ersetzt); Z. 291-306 `run()` mit Plugins/Handlern; Z. 385-393 Menue-Zweig `"update"` (Browser-Fallback, wird zum Rueckfall); Z. 440-461 `on_window_event` + Run-Handler (NICHT anfassen); Z. 464-599 `mod tests` (Stil: deutsche snake_case-Namen)
- apps/desktop/src-tauri/build.rs (setzt `APP_COMMIT`, 7 Zeichen, aus `TESSERA_COMMIT` oder `git rev-parse --short=7`; die Vorgabe nennt `env!("TESSERA_COMMIT")` — im Code heisst die Variable `APP_COMMIT`, das ist die zu nutzende)
- apps/desktop/src-tauri/tauri.conf.json (Z. 28-51 `bundle`; es gibt noch keinen `plugins`-Block) und Cargo.toml (Z. 13-22)
- ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tauri-plugin-updater-2.11.0/src/updater.rs: Z. 88-110 `RemoteRelease { version: semver::Version, notes, pub_date, data }`, Z. 197 `current_version: app.package_info().version` (semver::Version), Z. 211-215 `version_comparator<F: Fn(Version, RemoteRelease) -> bool + Send + Sync + 'static>`, Z. 224-231 `endpoints(Vec<Url>) -> Result<Self>` (https-Zwang im Release), Z. 264 `timeout`, Z. 365-388 `build()`, Z. 432 `check() -> Result<Option<Update>>`, Z. 476-486 Platzhalter-Ersetzung (beide Schreibweisen), Z. 504-506 + 698-700 Timeout = Gesamt-Timeout auch fuer den Download, Z. 576-579 Comparator-Aufruf, Z. 642-676 `Update` (pub-Felder `version: String`, `timeout: Option<Duration>`, `download_url`), Z. 680 `download<C: FnMut(usize, Option<u64>), D: FnOnce()>`, Z. 752-768 `download_and_install(&self, on_chunk, on_download_finish)` (Windows: beendet den Prozess; Linux: Neustart selbst ausloesen)
- …/tauri-plugin-updater-2.11.0/src/lib.rs Z. 41 `UpdaterExt` (`updater_builder()` fuer jeden Manager), Z. 217 `Builder::build()`; src/error.rs Z. 91 `Error::InsecureTransportProtocol`; src/config.rs Z. 127-137 (`pubkey: String` Pflicht, `endpoints` default leer, `windows.installMode` camelCase)
- …/tauri-2.11.3/src/app.rs Z. 588-611 (`restart()` aus dem Nebenthread → `request_exit(RESTART_EXIT_CODE)`), …/semver-1.0.28/src/lib.rs Z. 309 `Prerelease`, Z. 544 `as_str()`
- .planning/quick/260917-kgc-…/260917-kgc-RESEARCH.md Abschnitte 1, 2, 4, 5, 7, 8 und „Common Pitfalls“
</read_first>
<behavior>
Neue/geaenderte Tests in `#[cfg(test)] mod tests` (deutsche snake_case-Namen wie im Bestand; `use semver::Version;` im Testmodul, Hilfsfunktion `v(s: &str) -> Version { Version::parse(s).unwrap() }`):
- `is_update_newer(&v("1.2.0"), "abc1234", &v("1.3.0"))` → true (hoehere Basis, Live)
- `is_update_newer(&v("1.2.0"), "abc1234", &v("1.3.0-beta.gdef5678"))` → true (hoehere Basis, Beta)
- `is_update_newer(&v("1.3.0"), "abc1234", &v("1.2.0-beta.g9999999"))` → false (niedrigere Basis)
- `is_update_newer(&v("1.2.0"), "abc1234", &v("1.2.0-beta.gdef5678"))` → true (gleiche Basis, anderer Beta-Stempel)
- `is_update_newer(&v("1.2.0"), "abc1234", &v("1.2.0-beta.gabc1234"))` → false (gleicher Stempel)
- `is_update_newer(&v("1.2.0"), "abc1234", &v("1.2.0"))` → false (gleiche Basis, Live ohne Prerelease — siehe Entscheidung im Objective)
- `is_update_newer(&v("1.2.0"), "", &v("1.2.0-beta.gdef5678"))` → false (leerer Client-Commit → nur Basisvergleich)
- `is_update_newer(&v("1.2.0"), "abc1234", &v("1.2.0-rc.1"))` → false (fremdes Prerelease-Format)
- `Version::parse("1.2.0-beta.g0123456").is_ok()` → true und `beta_commit(&v("1.2.0-beta.g0123456"))` → `Some("0123456")` (Praefix `g` macht den fuehrende-Null-SHA gueltig); `beta_commit(&v("1.2.0"))` → None; `beta_commit(&v("1.2.0-rc.1"))` → None
- `update_endpoint("https://tessera.ctl.de/").unwrap().as_str()` → `"https://tessera.ctl.de/api-proxy/desktop/update?target=%7B%7Btarget%7D%7D&arch=%7B%7Barch%7D%7D&current=%7B%7Bcurrent_version%7D%7D&base=https%3A%2F%2Ftessera.ctl.de"` (Reihenfolge target, arch, current, base; `base` ohne Schlussstrich); `update_endpoint("kein url")` → None
- `update_labels(true, "1.2.0", "abc1234")` → (`"Auf Version 1.2.0 aktualisieren"`, `"Neue Version 1.2.0 verfügbar – Aktualisieren über das Symbol im Infobereich."`); `update_labels(false, "1.1.0", "abc1234")` → (`"Auf Beta-Stand abc1234 aktualisieren"`, `"Neuer Beta-Stand abc1234 verfügbar – Aktualisieren über das Symbol im Infobereich."`) — die beiden bestehenden `update_labels_*`-Tests werden auf diese Texte umgestellt
- `release_labels(&v("1.2.0"), &v("1.3.0-beta.gdef5678")).0` → `"Auf Version 1.3.0 aktualisieren"` (neue Basis wird als Version genannt, auch bei Beta); `release_labels(&v("1.2.0"), &v("1.2.0-beta.gdef5678")).0` → `"Auf Beta-Stand def5678 aktualisieren"`
- Alle 18 Bestandstests (ausser den zwei umgestellten `update_labels_*`) bleiben unveraendert gruen.
</behavior>
<action>
Zuerst die Tests aus `<behavior>` in `mod tests` schreiben und `CARGO_BUILD_JOBS=4 cargo test --lib` rot sehen (Compile-Fehler wegen fehlender Funktionen sind der erwartete RED-Zustand). Dann:
1. **Cargo.toml** (Vorgabe 1): unter `[dependencies]` `tauri-plugin-updater = "2"` und `semver = "1"` ergaenzen (das Plugin re-exportiert `semver` nicht, `is_update_newer` braucht `semver::Version` als eigenen Typ; `semver 1.0.28` liegt bereits transitiv im Cargo.lock). `cargo check` aktualisiert Cargo.lock; danach pruefen, dass `tauri-plugin-updater` auf `2.11.x` aufgeloest ist (nicht `3.0.0-alpha`) — die Crates liegen laut Recherche schon unter `~/.cargo/registry`. Das Projekt-`reqwest` bleibt auf `0.12` (Pitfall 8: nicht anheben, `aws-lc-rs` waere ein Cross-Bau-Risiko).
2. **tauri.conf.json** (Vorgabe 1): in `bundle` `"createUpdaterArtifacts": true` ergaenzen; neuer Top-Level-Block `"plugins": { "updater": { "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEIxNDIxMTZDMUFGRkJCRjEKUldUeHUvOGFiQkZDc2V0cnJZcmJVSURraGJHcE5TODVDeUp3UEVpM0xOc3I5TUZrNW5qZUw0d2gK", "windows": { "installMode": "passive" } } }` — exakt dieser Schluessel, kein `endpoints`, keine `dangerous*`-Schalter. `version` (1.1.0) und alles andere unveraendert (desktop-version.sh schreibt die Version im CI).
3. **lib.rs — Imports/Konstanten**: `use semver::Version;` und `use tauri_plugin_updater::{Update, UpdaterExt};`. `UPDATE_ITEM_DEFAULT` auf `"Update installieren"` setzen und den Doc-Kommentar anpassen (der Eintrag installiert kuenftig, er oeffnet nicht mehr den Browser). Neue Konstante `UPDATE_ITEM_INSECURE: &str = "Update nur über https möglich"`. Struct `DesktopLatest` ersatzlos entfernen (sonst `dead_code`-Warnung; `VersionResponse` bleibt fuer `check_server`).
4. **lib.rs — reine Helfer** (Doc-Kommentare deutsch mit ae/oe/ue wie im Bestand):
- `fn beta_commit(release: &Version) -> Option<&str>`: `release.pre.as_str().strip_prefix("beta.g")`, nur nicht-leere Reste. Doc: Beta-Stempel `beta.g<sha7>`; das `g` ist Pflicht, weil ein rein numerischer SHA mit fuehrender Null (`0123456`) kein gueltiger SemVer-Identifier waere und `check()` dann mit Err endet (Recherche Pitfall 2).
- `fn is_update_newer(current: &Version, current_commit: &str, release: &Version) -> bool` (Vorgabe 3): Basistupel `(major, minor, patch)` vergleichen — groesser → true, kleiner → false; gleich → `match beta_commit(release) { Some(sha) => !current_commit.is_empty() && sha != current_commit, None => false }`. Doc-Kommentar mit der Regel und ihrer Begruendung (Text aus dem Objective, Kurzform: heutige Regel beibehalten; Beta-Client auf Live-Server gleicher Basis ist client-seitig nicht entscheidbar, bekommt das Update mit der naechsten Freigabe; leerer Commit → nur Basisvergleich; ersetzt den Standardvergleich vollstaendig, updater.rs Z. 576-579).
- `fn update_endpoint(server: &str) -> Option<tauri::Url>`: `tauri::Url::parse(&api_url(server, "/desktop/update")).ok()?`, dann `query_pairs_mut()` mit `append_pair("target", "{{target}}")`, `("arch", "{{arch}}")`, `("current", "{{current_version}}")`, `("base", server.trim_end_matches('/'))`. Doc: Platzhalter stehen als Rohtext im Wert, `query_pairs_mut` kodiert sie zu `%7B%7B…%7D%7D`, das Plugin ersetzt beide Schreibweisen (updater.rs Z. 476-486); `base` ist der Origin fuer die absolute Rueckgabe-URL (das Plugin verlangt `url::Url`, relative Pfade scheitern); `api_url` bleibt die einzige Stelle mit dem `/api-proxy`-Praefix.
- `update_labels(version_changed: bool, version: &str, commit: &str)` behaelt die Signatur, Texte neu: `Auf Version {version} aktualisieren` / `Neue Version {version} verfügbar – Aktualisieren über das Symbol im Infobereich.` bzw. `Auf Beta-Stand {commit} aktualisieren` / `Neuer Beta-Stand {commit} verfügbar – Aktualisieren über das Symbol im Infobereich.` (Gedankenstrich U+2013 wie bisher). Doc-Kommentar anpassen (Vorgabe 6).
- `fn release_labels(current: &Version, release: &Version) -> (String, String)`: `version_changed` = Basistupel verschieden; `version` = `format!("{}.{}.{}", release.major, release.minor, release.patch)`; `commit` = `beta_commit(release).unwrap_or("")`; Rueckgabe `update_labels(version_changed, &version, commit)`.
5. **lib.rs — State und Pruefung**: `struct PendingUpdate(std::sync::Mutex<Option<(Update, String)>>)` (gefundenes Update + zugehoeriger Menuetext; `Update` ist `Clone + Send + Sync`, `impl Resource`), in `setup` direkt nach `app.manage(TrayItems …)` per `app.manage(PendingUpdate(Mutex::new(None)))` anlegen. `async fn check_for_update(app: &AppHandle, endpoint: tauri::Url) -> tauri_plugin_updater::Result<Option<Update>>`: `let current_commit = env!("APP_COMMIT");` dann `app.updater_builder().endpoints(vec![endpoint])?.timeout(Duration::from_secs(15)).version_comparator(move |current, release| is_update_newer(&current, current_commit, &release.version)).build()?.check().await`. `spawn_version_check(app, server_url)` umbauen: Reset des Eintrags wie bisher (`UPDATE_ITEM_DEFAULT`, gesperrt) PLUS `*app.state::<PendingUpdate>().0.lock()… = None` (Lock-Fehler ignorieren, `if let Ok(mut g)`); `let Some(endpoint) = update_endpoint(&server_url) else { return; };` (gespeicherte Adressen sind immer parsebar — reine Absicherung); im `tauri::async_runtime::spawn`: `match check_for_update(&app, endpoint).await { Ok(Some(mut update)) => { let (menu_text, body) = Version::parse(&update.version).map(|r| release_labels(&current, &r)).unwrap_or_else(|_| update_labels(true, &update.version, "")); update.timeout = Some(Duration::from_secs(600)); Benachrichtigung (Titel `Tessera-Update`, `body`) wie bisher; `update_item.set_text(&menu_text)`, `set_enabled(true)`; Ablage `Some((update, menu_text))` im State }, Ok(None) => {}, Err(tauri_plugin_updater::Error::InsecureTransportProtocol) => { update_item.set_text(UPDATE_ITEM_INSECURE) — gesperrt lassen }, Err(_) => {} }` — `current` = `Version::parse(env!("CARGO_PKG_VERSION"))` (immer reines X.Y.Z per D-07; `expect` mit Meldung ist hier vertretbar, weil Cargo die Version garantiert). Kommentar am Timeout: 15 s gelten fuer die Pruefung; das Plugin uebernimmt denselben Wert als Gesamt-Timeout des Downloads (updater.rs Z. 698-700), darum vor der Ablage `update.timeout = Some(600 s)` — sonst braeche der ~100-MB-Download ab. Kommentar am `InsecureTransportProtocol`-Zweig: Release-Bau verweigert `http` (config.rs `validate_endpoints`), Debug-Bau warnt nur; keine `dangerousInsecureTransportProtocol`-Freigabe (T-KGC-08). Der bisherige Kommentar zum Beta-Kanal (WR-02) wandert sinngemaess an `is_update_newer`.
6. **lib.rs — Installation** (Vorgabe 6/7): `fn open_download_page(app: &AppHandle)` = der bisherige Inhalt des `"update"`-Zweigs (`stored_server_url` → `{server}/settings/general/desktop` → `app.opener().open_url(target, None::<&str>)`), ohne `desktop=1` wie bisher. `fn spawn_update_install(app: AppHandle, update: Update, menu_text: String)`: `let item = app.state::<TrayItems>().update.clone(); item.set_enabled(false); item.set_text("Update wird heruntergeladen…");` Benachrichtigung „Update wird heruntergeladen…“; dann `tauri::async_runtime::spawn(async move { … })` mit: Fortschritts-Closure (`FnMut(usize, Option<u64>)`, Zaehler `received: u64`, `last_percent: Option<u64>`; bei bekanntem `total > 0` Prozent berechnen und nur bei Aenderung `item_progress.set_text(format!("Lädt … {} %", percent))`), Abschluss-Closure (`FnOnce`, `item_finish.set_text("Wird installiert…")`), `let result = update.download_and_install(on_chunk, on_finish).await;` — `Ok(())` → `app.restart()` (Kommentar: Windows nie erreicht — das Plugin startet den Installer mit `/P /UPDATE /R /ARGS` und ruft `std::process::exit(0)`, der Run-Handler wird dabei nicht durchlaufen; Linux — AppImage ist ersetzt, `restart()` aus dem Nebenthread laeuft ueber `RESTART_EXIT_CODE` = `code: Some(..)` am `prevent_exit` vorbei); `Err(e)` → Menuetext zurueck auf `menu_text`, Stand zurueck in `PendingUpdate` (`Some((update, menu_text.clone()))`), `set_enabled(true)`, Benachrichtigung Titel `Tessera-Update`, Body `format!("Update fehlgeschlagen: {}. Die Download-Seite wird im Browser geöffnet.", e)`, dann `open_download_page(&app)`. Menue-Zweig `"update"`: `let pending = app.state::<PendingUpdate>().0.lock().ok().and_then(|mut g| g.take()); match pending { Some((update, menu_text)) => spawn_update_install(app.clone(), update, menu_text), None => open_download_page(app) }`. Kommentar im Zweig: `take()` verhindert Doppelklick-Downloads, der gesperrte Eintrag ebenso; ohne abgelegten Stand bleibt der Browser-Weg. Menue-Kommentar in `setup` (Z. 318-323) auf „Update installieren“ anpassen.
7. **`run()`**: `.plugin(tauri_plugin_updater::Builder::new().build())` nach `tauri_plugin_opener::init()`. `on_window_event`, Run-Handler, `generate_handler!`, Commands, `apply_server`, `TrayItems`, `stored_server_url`: unveraendert (Vorgabe 7 — keine Aenderung an der `prevent_exit`-Logik). `capabilities/default.json` bleibt unangetastet (Rust-Aufrufe laufen am ACL vorbei; kein `updater:default` noetig).
8. `CARGO_BUILD_JOBS=4 cargo fmt` anwenden; erster `cargo check` laedt Plugin + reqwest 0.13 + rustls/ring und dauert mehrere Minuten (Timeout grosszuegig setzen). Kein `tauri build`, kein `tauri dev`, kein Docker. `~/.tessera/desktop-updater/` nie lesen.
9. Commit nur der vier Task-Dateien: `git add apps/desktop/src-tauri/Cargo.toml apps/desktop/src-tauri/Cargo.lock apps/desktop/src-tauri/tauri.conf.json apps/desktop/src-tauri/src/lib.rs` — nie `git add -A` (parallele Arbeiten im selben Baum).
</action>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl/apps/desktop/src-tauri && export CARGO_BUILD_JOBS=4 && cargo fmt --check && cargo check && cargo clippy && cargo test --lib && command grep -q '^tauri-plugin-updater = "2"' Cargo.toml && command grep -q '^semver = "1"' Cargo.toml && command grep -q '^reqwest = { version = "0.12"' Cargo.toml && command grep -A1 '^name = "tauri-plugin-updater"' Cargo.lock | command grep -q '^version = "2\.' && jq -e '.bundle.createUpdaterArtifacts == true and .plugins.updater.pubkey == "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEIxNDIxMTZDMUFGRkJCRjEKUldUeHUvOGFiQkZDc2V0cnJZcmJVSURraGJHcE5TODVDeUp3UEVpM0xOc3I5TUZrNW5qZUw0d2gK" and .plugins.updater.windows.installMode == "passive" and ((.plugins.updater | has("endpoints")) | not) and ((.plugins.updater | keys | map(startswith("dangerous")) | any) | not) and .version == "1.1.0"' tauri.conf.json >/dev/null && command grep -q 'tauri_plugin_updater::Builder::new().build()' src/lib.rs && command grep -q 'fn is_update_newer(current: &Version, current_commit: &str, release: &Version) -> bool' src/lib.rs && command grep -q 'fn beta_commit(' src/lib.rs && command grep -q 'fn update_endpoint(' src/lib.rs && command grep -q 'fn release_labels(' src/lib.rs && command grep -q 'fn spawn_update_install(' src/lib.rs && command grep -q 'fn open_download_page(' src/lib.rs && command grep -q 'struct PendingUpdate(' src/lib.rs && command grep -q 'download_and_install(' src/lib.rs && command grep -q 'version_comparator(' src/lib.rs && command grep -q 'Duration::from_secs(600)' src/lib.rs && command grep -q 'InsecureTransportProtocol' src/lib.rs && command grep -q 'env!("APP_COMMIT")' src/lib.rs && ! command grep -q 'struct DesktopLatest' src/lib.rs && ! command grep -q '"/desktop/latest"' src/lib.rs && command grep -q 'code: None, api' src/lib.rs && git -C /home/vicolab/projects/tessera-ctl diff --quiet -- apps/desktop/src-tauri/capabilities/default.json apps/desktop/src/setup.html && echo CLIENT-OK</automated>
</verify>
<done>Plugin eingebaut, `pubkey`/`installMode`/`createUpdaterArtifacts` gesetzt; `is_update_newer`, `beta_commit`, `update_endpoint`, `release_labels`, `update_labels` rein und getestet (mindestens 12 neue Tests, 16 unveraenderte Bestandstests, 2 umgestellte); Versionspruefung laeuft ueber `check()` mit eigenem Comparator, http-Server ergibt den gesperrten Text `Update nur über https möglich`; Tray-Klick laedt mit Fortschritt, installiert, startet neu, faellt bei Fehler auf die Browser-Seite zurueck; fmt/check/clippy/test gruen; Cargo.lock auf 2.11.x; Commit `feat(desktop): Update in der App — Herunterladen, Installieren und Neustart per Klick im Infobereich (tauri-plugin-updater)`. Der Nachweis am Bildschirm (Windows-VM: Tray-Text, Klick, passiver Installer, Neustart auf neuem Stand; SmartScreen-Verhalten notieren) folgt durch den Orchestrator mit dem CI-Paket — im SUMMARY als offen fuehren.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: API — `GET /desktop/update` (dynamisches Updater-Format, `base`-Origin-Validierung, 204-Faelle), Manifest-Felder `signature`/`updateVersion` in `@tessera/shared`, Spec-Tests</name>
<files>packages/shared/src/index.ts, apps/api/src/desktop/desktop.service.ts, apps/api/src/desktop/desktop.controller.ts, apps/api/src/desktop/desktop.service.spec.ts</files>
<read_first>
- apps/api/src/desktop/desktop.service.ts komplett (176 Zeilen): Z. 16-40 `PLATFORMS`, `SHA256_HEX_RE`, `isValidManifestFileEntry`; Z. 64-95 `getManifest()`; Z. 102-121 `getLatest()` (unveraendert lassen, spreadet den Manifest-Eintrag — ein optionales `signature` landet dort mit, ist oeffentlich und unkritisch); Z. 128-175 `getPackage()` (unveraendert)
- apps/api/src/desktop/desktop.controller.ts (37 Zeilen): `@Inject(DesktopService)`-Begruendung Z. 8-13 (Vitest/esbuild), `@Public()` an beiden Routen
- apps/api/src/desktop/desktop.service.spec.ts komplett (218 Zeilen): HTTP-Durchstich ueber `NestFactory.create(DesktopModule)` + `fetch`, `writeManifest()` Z. 37-48, `afterEach` stellt das Standard-Manifest wieder her, Test 11 prueft `IS_PUBLIC_KEY`
- packages/shared/src/index.ts Z. 22-53 (Desktop-Typen)
- Recherche Abschnitt 2 (Antwortformat: `version` SemVer Pflicht, `url` absolut Pflicht, `signature` Pflicht, `pub_date` RFC 3339 optional, `notes` optional; 204 → `Ok(None)`) und Abschnitt 6 („Neuer Endpunkt“)
- Nest-Statuslogik fuer 204 (bereits geprueft, siehe key_links): `@Res({ passthrough: true })` + `res.status(204)` + `return undefined` → 204 ohne Body
</read_first>
<behavior>
desktop.service.spec.ts — `writeManifest(files, head = {})` erweitern: `files`-Eintraege duerfen `signature?: string` tragen; `head` ueberschreibt `version`/`channel`/`commit`/`buildTime`/`updateVersion` (Standard wie bisher: `1.1.0`/`dev`/`abc1234`/`2026-09-16T00:00:00Z`, ohne `updateVersion`). Konstante `ORIGIN = 'https://tessera.example.com'`, `SIG = 'dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZQo='` (beliebige Base64-Zeile). Neue Tests (Nummern fortlaufend, deutsch wie Bestand):
- Test 12 (update, beta, signiert): Manifest `{ linux: { name, size, sha256, signature: SIG } }`, head `{ channel: 'beta', updateVersion: '1.1.0-beta.gabc1234' }`; `GET /desktop/update?target=linux&arch=x86_64&current=1.1.0&base=${ORIGIN}` → 200, `content-type` enthaelt `application/json`, Body `toEqual({ version: '1.1.0-beta.gabc1234', pub_date: '2026-09-16T00:00:00Z', url: `${ORIGIN}/api-proxy/desktop/download/linux`, signature: SIG, notes: 'Tessera 1.1.0-beta.gabc1234' })`
- Test 13 (update, live): head `{ channel: 'live', updateVersion: '1.1.0' }` → 200, `version === '1.1.0'`, `notes === 'Tessera 1.1.0'`
- Test 14 (base mit Schlussstrich): `base=${ORIGIN}/` → 200 und `url === `${ORIGIN}/api-proxy/desktop/download/linux`` (Origin ohne Schlussstrich)
- Test 15 (ohne Signatur): Standard-Manifest (keine `signature`, kein `updateVersion`) → 204, `await res.text()` ist `''`
- Test 16 (updateVersion fehlt trotz Signatur): `signature: SIG`, kein `updateVersion` → 204; (ungueltige Form) `updateVersion: '1.1.0-beta.abc1234'` (ohne `g`) → 204
- Test 17 (Plattform/Architektur): signiertes Manifest; `target=darwin` → 204; `target=windows` (kein windows-Eintrag) → 204; `arch=aarch64` → 204; `target` fehlt → 204
- Test 18 (base-Validierung, HTTP): jeweils 400 fuer `base` fehlt, `base=ftp://host`, `base=https://user:pw@host`, `base=https://host/pfad`, `base=https://host/?x=1`, `base=https://host/#f`, `base=kein url`
- Test 19 (`safeOrigin` direkt): `safeOrigin('https://tessera.example.com')` → `'https://tessera.example.com'`; `safeOrigin('http://localhost:3000/')` → `'http://localhost:3000'`; `safeOrigin('HTTPS://Tessera.Example.com')` → `'https://tessera.example.com'`; `safeOrigin(['https://a', 'https://b'])` → null (Array aus doppeltem Query-Parameter); `safeOrigin(undefined)`, `safeOrigin('')`, `safeOrigin('https://host/pfad')`, `safeOrigin('javascript:alert(1)')` → null
- Test 20 (Manifest ohne Datei → 204): `fs.rmSync(manifest.json)` im Test, Anfrage → 204 (afterEach stellt es wieder her)
- Test 21 (Manifest-Validierung): `signature: 123` (Zahl) im linux-Eintrag → `GET /desktop/download/linux` → 404 (Eintrag ungueltig, wie Test 10)
- Test 11 erweitern: `DesktopController.prototype.update` traegt `@Public()`
- Tests 1-10 bleiben unveraendert gruen (Test 1 `toEqual` ist ohne `signature` im Standard-Manifest weiterhin exakt).
</behavior>
<action>
Tests zuerst schreiben, `pnpm --filter @tessera/api exec vitest run src/desktop` rot sehen, dann:
1. **packages/shared/src/index.ts**: `DesktopManifestFile` um `signature?: string` (Kommentar: Base64-Inhalt der `.sig`-Datei des Tauri-Bundlers, minisign; geschrieben von desktop-collect.sh, gelesen nur von `GET /desktop/update`), `DesktopManifest` um `updateVersion?: string` (Kommentar: `X.Y.Z` bei live, `X.Y.Z-beta.g<sha7>` bei beta — die SemVer-Form, die der Updater vergleicht; Praefix `g` Pflicht). Neues `export interface DesktopUpdateResponse { version: string; pub_date?: string; url: string; signature: string; notes?: string }` mit Kommentar: dynamisches Antwortformat von `tauri-plugin-updater` (Feldnamen sind vom Plugin vorgegeben, darum snake_case `pub_date`).
2. **desktop.service.ts**:
- `isValidManifestFileEntry`: zusaetzlich `(candidate.signature === undefined || typeof candidate.signature === 'string')`.
- `getManifest()`: `updateVersion`, falls vorhanden, muss ein String sein — sonst warnen und `null` (gleiche Stelle wie die Kopf-Form-Pruefung).
- `export function safeOrigin(base: unknown): string | null` (reine Funktion, oberhalb der Klasse, mit Doc-Kommentar T-KGC-02): `typeof base !== 'string'` oder leer oder laenger als 2048 → null; `new URL(base)` in try/catch → null; Protokoll nur `http:`/`https:`; `username`/`password` leer; `pathname === '/'`, `search === ''`, `hash === ''`; Rueckgabe `url.origin` (kleingeschriebener Host, ohne Schlussstrich). Kommentar: `base` wird NUR zum Bauen der Rueckgabe-URL fuer den Anfragenden verwendet, nie serverseitig abgerufen (kein SSRF); ein Angreifer koennte damit hoechstens seinen eigenen Client auf einen fremden Download lenken, den die Signaturpruefung im Client ablehnt.
- Konstante `UPDATE_VERSION_RE = /^\d+\.\d+\.\d+(-beta\.g[0-9a-f]{7})?$/` und `RFC3339_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/`.
- `getUpdate(input: { target: unknown; arch: unknown; origin: string }): DesktopUpdateResponse | null`: Reihenfolge (1) `target` per Whitelist auf `DesktopPlatform` (`'windows' | 'linux'`, sonst null), (2) `arch !== 'x86_64'` → null, (3) `getManifest()` null → null, (4) `updateVersion` fehlt oder nicht `UPDATE_VERSION_RE` → null, (5) Eintrag der Plattform fehlt oder `signature` fehlt/leer → null, (6) Antwort `{ version: manifest.updateVersion, url: `${origin}/api-proxy/desktop/download/${platform}`, signature: entry.signature, notes: `Tessera ${manifest.updateVersion}` }` plus `pub_date: manifest.buildTime` nur wenn `RFC3339_RE` passt (sonst weglassen — ein unparsebares Datum liesse `check()` im Client scheitern). Doc-Kommentar: Vertrag des Plugins (Pflichtfelder), 204 = „kein Update“, `current` wird bewusst nicht ausgewertet (die Entscheidung trifft der Client-Comparator, die API kennt den Client-Commit nicht); `/desktop/latest` bleibt fuer die Web-Oberflaeche mit relativen URLs.
3. **desktop.controller.ts**: Imports `BadRequestException`, `Query`, `Res` aus `@nestjs/common`, `type { Response } from 'express'`, `DesktopUpdateResponse` aus `@tessera/shared`, `safeOrigin` aus `./desktop.service`. Neue Methode VOR `download` (Konvention Route-Order, auch wenn `update` und `download/:platform` sich hier nicht ueberschatten): `@Public() @Get('update') update(@Query('target') target: unknown, @Query('arch') arch: unknown, @Query('base') base: unknown, @Res({ passthrough: true }) res: Response): DesktopUpdateResponse | undefined` — `const origin = safeOrigin(base); if (!origin) throw new BadRequestException('base must be an http(s) origin without path, query or credentials');` `const result = this.desktopService.getUpdate({ target, arch, origin }); if (!result) { res.status(204); return undefined; } return result;`. Kommentar: bewusst oeffentlich (D-10, wie `latest`/`download`: der Client prueft vor der Anmeldung); 204 ist der vom Plugin definierte „kein Update“-Status; `passthrough` + `res.status(204)` funktioniert, weil Nest den Standardstatus vor dem Handler setzt. `@Query('current')` nicht deklarieren (nicht genutzt).
4. Keine Aenderung an `getLatest`/`getPackage`/`DesktopModule`; keine neue Abhaengigkeit. Formatierung im Stil der Datei (Biome-Konfiguration ist derzeit nicht ausfuehrbar — Zeilenbreite 100, zwei Leerzeichen, einfache Anfuehrungszeichen von Hand einhalten).
5. Commit nur der vier Task-Dateien: `git add packages/shared/src/index.ts apps/api/src/desktop/desktop.service.ts apps/api/src/desktop/desktop.controller.ts apps/api/src/desktop/desktop.service.spec.ts`.
</action>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/api exec vitest run src/desktop && pnpm --filter @tessera/api type-check && pnpm --filter @tessera/shared type-check && command grep -q 'signature?: string' packages/shared/src/index.ts && command grep -q 'updateVersion?: string' packages/shared/src/index.ts && command grep -q 'export interface DesktopUpdateResponse' packages/shared/src/index.ts && command grep -q 'export function safeOrigin(base: unknown): string | null' apps/api/src/desktop/desktop.service.ts && command grep -q 'getUpdate(' apps/api/src/desktop/desktop.service.ts && command grep -q "@Get('update')" apps/api/src/desktop/desktop.controller.ts && test "$(awk "/@Get\('update'\)/{u=NR} /@Get\('download\/:platform'\)/{d=NR} END{print (u>0 && d>0 && u<d) ? 1 : 0}" apps/api/src/desktop/desktop.controller.ts)" = 1 && command grep -q 'passthrough: true' apps/api/src/desktop/desktop.controller.ts && command grep -q 'res.status(204)' apps/api/src/desktop/desktop.controller.ts && ! command grep -q 'fetch(\|axios\|http.get(' apps/api/src/desktop/desktop.service.ts && test "$(command grep -c "^ it('Test " apps/api/src/desktop/desktop.service.spec.ts)" -ge 21 && git diff --quiet -- apps/api/src/desktop/desktop.module.ts && echo API-OK</automated>
</verify>
<done>`GET /desktop/update` liefert fuer signierte Pakete das dynamische Updater-Format mit absoluter URL aus dem validierten `base`-Origin, 204 in allen „kein Update“-Faellen, 400 bei ungueltigem `base`; `/desktop/latest` und `download/:platform` unveraendert; Typen in `@tessera/shared`; mindestens 21 Spec-Tests (11 Bestand, davon Test 11 erweitert, + 10 neue) gruen, api- und shared-type-check gruen; Commit `feat(api): GET /desktop/update — signierte Desktop-Pakete im Format des Tauri-Updaters ausliefern`.</done>
</task>
<task type="auto">
<name>Task 3: Skripte + CI — `.sig` als `signature` und `updateVersion` ins Manifest, `stamp check` verlangt beides, Signier-Secrets an den zwei `tauri build`-Schritten, `*.key` in .gitignore</name>
<files>.gitea/scripts/desktop-collect.sh, .gitea/scripts/desktop-stamp.sh, .gitea/workflows/ci.yml, .gitignore</files>
<read_first>
- .gitea/scripts/desktop-collect.sh komplett (156 Zeilen): Kanalentscheidung Z. 49-63, Versionspruefung Z. 65-77, Linux-Block Z. 97-112 (`find … -name '*.AppImage'` matcht `.AppImage.sig` NICHT — Zaehler bleiben korrekt), Windows-Block Z. 114-129, `jq -n`-Manifest Z. 131-154
- .gitea/scripts/desktop-stamp.sh komplett (162 Zeilen): `cmd_check` Z. 85-149, `check_file` Z. 99-115, `no_reuse` Z. 92-97
- .gitea/workflows/ci.yml komplett (241 Zeilen): Job `desktop` Z. 67-208, Bau-Schritte Z. 179-185 (`Linux-AppImage bauen`, `Windows-Installer bauen (Cross-Bau)`), Kopfkommentar Z. 1-16 (Stil fuer den neuen Absatz)
- .gitignore Z. 44-45 (`desktop-dist/*`, `!desktop-dist/.gitkeep`) — es gibt keine getrackten `*.key`-Dateien (in der Ausgabe von `git ls-files` endet keine Zeile auf `.key`)
- Recherche Abschnitt 3 (Signatur entsteht in der CLI nach dem Buendeln als `<bundle>.sig`, Base64-Zeile, host-unabhaengig — auch im xwin-Cross-Bau; Bau ohne Schluessel bei gesetztem `pubkey` bricht ab, Ausweg nur `tauri build --no-sign`), Abschnitt 6 („desktop-collect.sh“, „Skip-Mechanismus“, „ci.yml“)
- .planning/quick/260917-jdh-…/260917-jdh-PLAN.md Task-1-`<verify>` (Muster fuer Mini-Fixtures im Scratchpad und die js-yaml-Pruefung ueber `node_modules/.pnpm/js-yaml@4.2.0/node_modules/js-yaml`)
- WICHTIG: das echte `desktop-dist/` im Arbeitsbaum enthaelt eine lokale AppImage — NIE als Probe-Ziel verwenden, Proben nur in `mktemp -d`-Ordnern mit `DESKTOP_DIST`/`TAURI_DIR`
</read_first>
<action>
1. **desktop-collect.sh** (Vorgabe 5):
- Kopfkommentar: Absatz „Signatur (quick-260917-kgc)“: die Tauri-CLI legt beim Bau mit `createUpdaterArtifacts` neben jedem Bundle eine `<bundle>.sig` ab (minisign, eine Base64-Zeile); dieses Skript traegt deren INHALT als `files.<plattform>.signature` ins Manifest — die `.sig`-Datei selbst wird nicht kopiert (die API liefert JSON). Pflichtregel: fehlt die `.sig`, bricht das Skript ab, wenn `TAURI_SIGNING_PRIVATE_KEY` gesetzt ist ODER der Kanal nicht `dev` ist (main/Tag — dort sind Signaturen Pflicht, `--no-sign` gibt es nur lokal); sonst Warnung und Feld weglassen (lokaler Bau mit `tauri build --no-sign` bleibt moeglich, die API antwortet dann 204). Neues Feld `updateVersion` = die SemVer-Form fuer den Updater. Der Satz „Dieses Skript kennt kein Secret.“ bleibt wahr: der Schluessel wird nur auf Gesetztsein geprueft, nie gelesen oder ausgegeben.
- Nach der Kanalentscheidung: `UPDATE_VERSION="$VERSION"`, im `refs/heads/main`-Zweig `UPDATE_VERSION="$VERSION-beta.g$SHA_SHORT"` (nach `VERSION` bekannt ist — Reihenfolge im Skript beachten: `SUFFIX` wird vor `VERSION` gesetzt, `UPDATE_VERSION` also NACH der Versionspruefung aus `CHANNEL`/`SHA_SHORT` bilden). `SIGN_REQUIRED=0; if [ -n "${TAURI_SIGNING_PRIVATE_KEY:-}" ] || [ "$CHANNEL" != dev ]; then SIGN_REQUIRED=1; fi`.
- Funktion `read_signature` (Argument: Bundle-Pfad; schreibt den Inhalt von `<pfad>.sig` nach stdout): existiert die Datei → Inhalt lesen, gegen `^[A-Za-z0-9+/=]+$` pruefen (genau eine Base64-Zeile; sonst Fehler „Signaturdatei … hat nicht die erwartete Form“ → exit 1); existiert sie nicht → bei `SIGN_REQUIRED=1` Fehlermeldung nach stderr (Text: Signaturdatei fehlt; im CI muessen `TAURI_SIGNING_PRIVATE_KEY`/`…_PASSWORD` an den Bau-Schritten gesetzt sein; `--no-sign` ist nur lokal erlaubt) → exit 1, sonst Warnung nach stderr und leere Ausgabe. Aufruf jeweils als eigene Zeile `LINUX_SIG="$(read_signature "$APPIMAGE_SRC")"` bzw. `WINDOWS_SIG="$(read_signature "$NSIS_SRC")"` — mit `set -e` beendet ein `exit 1` in der Kommandosubstitution das Skript ueber den Status der Zuweisung; zur Sicherheit direkt danach `[ -n "$LINUX_SIG" ] || [ "$SIGN_REQUIRED" = 0 ] || exit 1`. Variablen `LINUX_SIG=""`/`WINDOWS_SIG=""` bei den anderen Vorbelegungen. Log-Zeilen um `signiert`/`ohne Signatur` ergaenzen.
- `jq -n`: `--arg updateVersion "$UPDATE_VERSION" --arg linuxSig "$LINUX_SIG" --arg windowsSig "$WINDOWS_SIG"`; Ausgabe `updateVersion: $updateVersion` nach `version`; Plattform-Objekte per `+ (if $linuxSig != "" then { signature: $linuxSig } else {} end)` bzw. windows analog erweitern (weiterhin nur `jq -n` mit `--arg`, kein String-Zusammenbau).
- `rm -f`-Zeile unveraendert (keine `.sig` im Zielordner). Dateinamen der Pakete unveraendert (Vorgabe 5: die CLI erzeugt `Tessera_X.Y.Z_x64-setup.exe` + `.sig` bzw. `Tessera_X.Y.Z_amd64.AppImage` + `.sig`; `find -name '*.exe'`/`'*.AppImage'` matchen die `.sig` nicht, die `.sig` liegt exakt unter `<gefundene Datei>.sig`).
2. **desktop-stamp.sh** `cmd_check`: nach der Versionspruefung `UPDATE_VERSION="$(jq -r '.updateVersion // empty' "$MANIFEST")"`; leer → `no_reuse "updateVersion fehlt im Manifest (Stand vor der Update-Funktion)"`. In `check_file` nach der Pruefsumme: `SIG="$(jq -r ".files.$1.signature // empty" "$MANIFEST")"`; leer → `no_reuse "Signatur fuer $2 fehlt im Manifest"`. Kopfkommentar: Absatz, dass ein Cache-Stand ohne `updateVersion`/`signature` nie uebernommen wird (sonst lieferte der Update-Endpunkt dauerhaft 204) und dass ein Schluesselwechsel (`pubkey` in tauri.conf.json unter `apps/desktop`) den Stempel automatisch aendert. `DESKTOP_PATHS` unveraendert.
3. **ci.yml**: Kopfkommentar-Absatz `# quick-260917-kgc: Die beiden tauri-build-Schritte signieren die Pakete mit dem` … `# Updater-Schluessel (Secrets TAURI_SIGNING_PRIVATE_KEY/_PASSWORD, nur an diesen` … `# zwei Schritten); desktop-collect.sh traegt die .sig-Inhalte ins Manifest.` An den Schritten `Linux-AppImage bauen` und `Windows-Installer bauen (Cross-Bau)` jeweils zwischen `if:` und `run:` einen `env:`-Block: `TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}` und `TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}` — Entscheidung Schritt- statt Job-Ebene: geringste Sichtbarkeit (`pnpm install`, `apt-get`, `cargo install cargo-xwin`, Cache-Schritte sehen den Schluessel nicht); Gitea maskiert Secret-Werte im Log, das Skript gibt sie nie aus. Sonst NICHTS aendern (kein `--no-sign`, kein Job-`env`, `Pakete einsammeln` unveraendert — die Pflichtregel greift dort ueber den Kanal).
4. **.gitignore**: nach dem `desktop-dist`-Block eine Zeile `*.key` mit Kommentar `# Privater Updater-Signierschluessel liegt ausserhalb des Repos (~/.tessera/desktop-updater/) -- nie einchecken`. Vorher bestaetigen, dass in der Ausgabe von `git ls-files` keine Zeile auf `.key` endet.
5. Lokale Proben laut `<verify>` nur mit Mini-Fixtures im Scratchpad (`TAURI_DIR`, `DESKTOP_DIST`, `GITHUB_REF`, `TAURI_SIGNING_PRIVATE_KEY=dummy` — der Dummy ist ein beliebiger Platzhalterstring, KEIN echter Schluessel; `~/.tessera/desktop-updater/` nie lesen).
6. Commit nur der vier Task-Dateien: `git add .gitea/scripts/desktop-collect.sh .gitea/scripts/desktop-stamp.sh .gitea/workflows/ci.yml .gitignore`.
</action>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl && sh -n .gitea/scripts/desktop-collect.sh && sh -n .gitea/scripts/desktop-stamp.sh && command grep -q '^\*\.key$' .gitignore && TRACKED="$(git ls-files)" && ! printf '%s\n' "$TRACKED" | command grep -q '\.key$' && T=$(mktemp -d) && mkdir -p "$T/tauri/target/release/bundle/appimage" "$T/tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis" "$T/dist" && printf '{"version":"1.2.0"}' > "$T/tauri/tauri.conf.json" && printf 'linuxbytes' > "$T/tauri/target/release/bundle/appimage/Tessera_1.2.0_amd64.AppImage" && printf 'dW50cnVzdGVkIGNvbW1lbnQ6IGxpbnV4Cg==' > "$T/tauri/target/release/bundle/appimage/Tessera_1.2.0_amd64.AppImage.sig" && printf 'winbytes' > "$T/tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/Tessera_1.2.0_x64-setup.exe" && printf 'dW50cnVzdGVkIGNvbW1lbnQ6IHdpbmRvd3MK' > "$T/tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/Tessera_1.2.0_x64-setup.exe.sig" && SHA7="$(git rev-parse --short=7 HEAD)" && GITHUB_REF=refs/heads/main TAURI_DIR="$T/tauri" DESKTOP_DIST="$T/dist" sh .gitea/scripts/desktop-collect.sh --require linux,windows && jq -e --arg u "1.2.0-beta.g$SHA7" '.version == "1.2.0" and .channel == "beta" and .updateVersion == $u and .files.linux.signature == "dW50cnVzdGVkIGNvbW1lbnQ6IGxpbnV4Cg==" and .files.windows.signature == "dW50cnVzdGVkIGNvbW1lbnQ6IHdpbmRvd3MK" and (.files.linux.name | test("^Tessera-1\\.2\\.0-beta\\.[0-9a-f]{7}\\.AppImage$")) and (.files.windows.name | test("^Tessera-Setup-1\\.2\\.0-beta\\.[0-9a-f]{7}\\.exe$"))' "$T/dist/manifest.json" >/dev/null && test -z "$(find "$T/dist" -name '*.sig')" && GITHUB_REF=refs/tags/v1.2.0 TAURI_DIR="$T/tauri" DESKTOP_DIST="$T/dist" sh .gitea/scripts/desktop-collect.sh --require linux,windows && jq -e '.channel == "live" and .updateVersion == "1.2.0" and .files.linux.name == "Tessera-1.2.0.AppImage" and .files.windows.name == "Tessera-Setup-1.2.0.exe" and (.files.linux.signature | length > 0)' "$T/dist/manifest.json" >/dev/null && CACHE_HIT=true STAMP_VERSION=1.2.0 STAMP_SHA7="$SHA7" DESKTOP_DIST="$T/dist" sh .gitea/scripts/desktop-stamp.sh check | command grep -qx 'reuse=false' && GITHUB_REF=refs/heads/main TAURI_DIR="$T/tauri" DESKTOP_DIST="$T/dist" sh .gitea/scripts/desktop-collect.sh --require linux,windows >/dev/null && CACHE_HIT=true STAMP_VERSION=1.2.0 STAMP_SHA7="$SHA7" DESKTOP_DIST="$T/dist" sh .gitea/scripts/desktop-stamp.sh check | command grep -qx 'reuse=true' && jq 'del(.files.windows.signature)' "$T/dist/manifest.json" > "$T/m2.json" && cp "$T/m2.json" "$T/dist/manifest.json" && CACHE_HIT=true STAMP_VERSION=1.2.0 STAMP_SHA7="$SHA7" DESKTOP_DIST="$T/dist" sh .gitea/scripts/desktop-stamp.sh check | command grep -qx 'reuse=false' && GITHUB_REF=refs/heads/main TAURI_DIR="$T/tauri" DESKTOP_DIST="$T/dist" sh .gitea/scripts/desktop-collect.sh --require linux,windows >/dev/null && jq 'del(.updateVersion)' "$T/dist/manifest.json" > "$T/m3.json" && cp "$T/m3.json" "$T/dist/manifest.json" && CACHE_HIT=true STAMP_VERSION=1.2.0 STAMP_SHA7="$SHA7" DESKTOP_DIST="$T/dist" sh .gitea/scripts/desktop-stamp.sh check | command grep -qx 'reuse=false' && rm -f "$T/tauri/target/release/bundle/appimage/Tessera_1.2.0_amd64.AppImage.sig" && ! GITHUB_REF=refs/heads/main TAURI_DIR="$T/tauri" DESKTOP_DIST="$T/dist" sh .gitea/scripts/desktop-collect.sh --require linux,windows 2>/dev/null && ! GITHUB_REF=refs/tags/v1.2.0 TAURI_DIR="$T/tauri" DESKTOP_DIST="$T/dist" sh .gitea/scripts/desktop-collect.sh --require linux,windows 2>/dev/null && ! GITHUB_REF= TAURI_SIGNING_PRIVATE_KEY=dummy TAURI_DIR="$T/tauri" DESKTOP_DIST="$T/dist" sh .gitea/scripts/desktop-collect.sh --require linux 2>/dev/null && GITHUB_REF= TAURI_DIR="$T/tauri" DESKTOP_DIST="$T/dist" sh .gitea/scripts/desktop-collect.sh --require linux 2>"$T/warn.txt" && command grep -qi 'signatur' "$T/warn.txt" && jq -e '.channel == "dev" and .updateVersion == "1.2.0" and ((.files.linux | has("signature")) | not)' "$T/dist/manifest.json" >/dev/null && printf 'zwei\nzeilen\n' > "$T/tauri/target/release/bundle/appimage/Tessera_1.2.0_amd64.AppImage.sig" && ! GITHUB_REF= TAURI_DIR="$T/tauri" DESKTOP_DIST="$T/dist" sh .gitea/scripts/desktop-collect.sh --require linux 2>/dev/null && rm -rf "$T" && node -e 'const y=require("./node_modules/.pnpm/js-yaml@4.2.0/node_modules/js-yaml");const fs=require("fs");const cp=require("child_process");const d=y.load(fs.readFileSync(".gitea/workflows/ci.yml","utf8"));const o=y.load(cp.execSync("git show HEAD:.gitea/workflows/ci.yml").toString());for(const j of["quality","test","publish"]){if(JSON.stringify(d.jobs[j])!==JSON.stringify(o.jobs[j]))throw new Error("Job veraendert: "+j)}if(JSON.stringify(d.on)!==JSON.stringify(o.on))throw new Error("on veraendert");const n=d.jobs.desktop,p=o.jobs.desktop;for(const k of["env","needs","if","runs-on"]){if(JSON.stringify(n[k])!==JSON.stringify(p[k]))throw new Error("desktop."+k+" veraendert")}if(n.steps.length!==p.steps.length)throw new Error("Schrittzahl veraendert");const names=["Linux-AppImage bauen","Windows-Installer bauen (Cross-Bau)"];let signed=0;for(let i=0;i<n.steps.length;i++){const a=n.steps[i],b=p.steps[i];const isBuild=names.includes(a.name);if(isBuild){const e=a.env||{};if(e.TAURI_SIGNING_PRIVATE_KEY!=="${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}"||e.TAURI_SIGNING_PRIVATE_KEY_PASSWORD!=="${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}"||Object.keys(e).length!==2)throw new Error("env am Bau-Schritt falsch: "+a.name);if(String(a.run).includes("--no-sign"))throw new Error("--no-sign im CI");const c={...a};delete c.env;if(JSON.stringify(c)!==JSON.stringify(b))throw new Error("Bau-Schritt sonst veraendert: "+a.name);signed++}else{if(JSON.stringify(a)!==JSON.stringify(b))throw new Error("Schritt veraendert: "+(a.name||a.uses));if(JSON.stringify(a).includes("TAURI_SIGNING"))throw new Error("Secret an fremdem Schritt: "+(a.name||a.uses))}}if(signed!==2)throw new Error("erwartet 2 signierte Bau-Schritte");console.log("CI-OK")' && command grep -q 'quick-260917-kgc' .gitea/workflows/ci.yml && test "$(command grep -c 'secrets.TAURI_SIGNING_PRIVATE_KEY' .gitea/workflows/ci.yml)" -ge 4 && echo SCRIPTS-OK</automated>
</verify>
<done>`desktop-collect.sh` schreibt `updateVersion` und je Plattform `signature` aus der `.sig` neben dem Bundle, bricht auf main/Tag oder bei gesetztem Schluessel ohne `.sig` ab und warnt sonst nur; `desktop-stamp.sh check` uebernimmt keinen Cache-Stand ohne `updateVersion`/`signature`; die beiden `tauri build`-Schritte tragen die zwei Secrets, alles andere in ci.yml ist byte- bzw. strukturgleich; `*.key` ignoriert; alle Proben (Beta/Live/Dev, fehlende und zweizeilige `.sig`, Cache-Uebernahme mit/ohne Felder, js-yaml-Tiefenvergleich) gruen; Commit `ci: Desktop-Pakete mit dem Updater-Schluessel signieren, Signatur und updateVersion ins Manifest`.</done>
</task>
<task type="auto">
<name>Task 4: Doku — Anwender-, Betriebs- und Entwicklungshandbuch, ci-cd-setup.md, CHANGELOG</name>
<files>docs/anleitung-anwender.md, docs/anleitung-betrieb.md, docs/anleitung-entwicklung.md, docs/ci-cd-setup.md, CHANGELOG.md</files>
<read_first>
- docs/anleitung-anwender.md Z. 161-222 (Desktop-App; Tray-Liste Z. 190-197 mit `**Update herunterladen**`, Abschnitt „Neue Version“ Z. 213-215, „Wenn etwas nicht klappt“ Z. 217-222); Anfuehrungszeichen: oeffnend „ (U+201E), schliessend gerades " — beibehalten; Sie-Form; Alltagssprache ohne Fachbegriffe
- docs/anleitung-betrieb.md Z. 552-660 (Kap. 10: „Wo die Pakete im Abbild liegen“ mit `curl`-Kontrollzeile Z. 617-627, „Umgebungsvariablen“ Z. 638-644, „Fehlerbilder“ Z. 646-660); Umlaute echt
- docs/anleitung-entwicklung.md Z. 128-172 („Desktop-App lokal bauen“ — der Befehl `tauri build --bundles appimage` bricht seit Task 1 ohne Schluessel ab)
- docs/ci-cd-setup.md Z. 81-107 (Secrets-Tabelle „Benoetigt wird genau eines“) und Z. 135-210 (Job `desktop`, Punkt 5/6) — diese Datei ist ASCII ohne Umlaute (ae/oe/ue), so bleiben
- CHANGELOG.md Z. 1-25 — FRISCH lesen: `## Unveröffentlicht` hat bereits `### Neu` (drei Zeilen, davon zwei `Desktop-App:`), `### Geändert`, `### Behoben`; Anfuehrungszeichen „…“ (U+201E/U+201C), Praefix `Desktop-App:`, kein Punkt am Ende
- Recherche Abschnitte 4 (Windows-Ablauf, Tempordner bleibt liegen), 5 (AppImage muss beschreibbar sein, Tempordner auf demselben Dateisystem), 8 (SmartScreen vermutlich ohne Dialog — nicht versprechen), „Open Questions“ 3 (erster Rollout)
</read_first>
<action>
1. **docs/anleitung-anwender.md**:
- Tray-Liste: `- **Update installieren** — wird aktiv, sobald eine neue Version vorliegt, und heißt dann „Auf Version X.Y.Z aktualisieren" (auf dem Beta-Kanal: „Auf Beta-Stand … aktualisieren")`.
- Abschnitt `### Neue Version` komplett neu: Absatz 1 — beim Start (und nach einem Serverwechsel) prueft die App, ob der Server eine neuere Version hat; dann Meldung „Neue Version X.Y.Z verfügbar" und der Menueeintrag heisst „Auf Version X.Y.Z aktualisieren". Absatz 2 — ein Klick genuegt: die App laedt das Paket im Hintergrund (Fortschritt im Menue als „Lädt … 42 %"), prueft, dass es unveraendert von Ihrem Tessera-Server stammt, und installiert es. Unter Windows erscheint kurz das Installationsfenster mit Fortschrittsbalken, danach startet Tessera von selbst neu; die Server-Adresse und die Fensterposition bleiben erhalten (die Position kann nach einem Update einmal auf den Standard zurueckfallen). Unter Linux wird die AppImage-Datei an ihrem Speicherort ersetzt, die App startet neu — die Datei muss dafuer an einem Ort liegen, an dem Sie schreiben duerfen (z. B. Ihr Home-Ordner). Absatz 3 — schlaegt das Update fehl, meldet die App den Grund und oeffnet die Seite Einstellungen → Desktop-App im Browser; dort laden und installieren Sie die neue Version wie oben beschrieben. Absatz 4 — Voraussetzung: die Server-Adresse beginnt mit `https`; bei `http` steht im Menue „Update nur über https möglich", der Weg ueber den Browser bleibt. Absatz 5 — **Einmaliger Wechsel:** Wer die Desktop-App 1.2.0 oder aelter installiert hat, muss die naechste Version ein letztes Mal ueber den Browser herunterladen und installieren; ab dann laeuft das Update ueber den Menueeintrag.
- „Wenn etwas nicht klappt“: zwei Zeilen — **„Update fehlgeschlagen"** (Netz kurz weg, Paket unvollstaendig oder nicht vom eigenen Server: Browser-Weg, wie in „Neue Version" beschrieben) und **Linux: Update meldet fehlende Schreibrechte** (AppImage in den Home-Ordner legen und von dort starten).
2. **docs/anleitung-betrieb.md** Kap. 10:
- Neuer Unterabschnitt `### Updates in der App und der Signierschlüssel` VOR „Umgebungsvariablen“: (a) Seit September 2026 aktualisiert sich die Desktop-App per Klick; sie fragt `GET /api-proxy/desktop/update` und installiert nur Pakete, deren Signatur zum im Client hinterlegten oeffentlichen Schluessel passt — ein manipuliertes oder fremdes Paket wird abgelehnt. (b) Der private Schluessel liegt NICHT im Repository: als Gitea-Secrets `TAURI_SIGNING_PRIVATE_KEY` und `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` (Repository → Einstellungen → Actions → Secrets) und als Sicherung auf dem Entwicklungsrechner unter `~/.tessera/desktop-updater/` (`tessera-updater.key`, `password.txt`, `tessera-updater.key.pub`). (c) Sicherung: die beiden Dateien an einem zweiten sicheren Ort ablegen. Geht der private Schluessel verloren, koennen bereits installierte Clients kein Update mehr annehmen — es muss ein neues Schluesselpaar erzeugt, der oeffentliche Teil in `apps/desktop/src-tauri/tauri.conf.json` eingetragen und jeder Client einmal von Hand neu installiert werden. (d) Ohne die Secrets bricht der CI-Bau ab („no private key"); `tauri build --no-sign` ist nur fuer lokale Proben gedacht, im CI nicht vorgesehen. (e) Windows legt je Update einen Ordner `%TEMP%\Tessera-<Version>-updater-…` (~100 MB) ab und raeumt ihn nicht auf — kein Handlungsbedarf, kann geloescht werden. (f) `http`-Adressen: der Client erlaubt Updates nur ueber `https`; Nutzer mit `http`-Adresse sehen im Menue den Hinweis und nutzen den Browser-Weg.
- „Wo die Pakete im Abbild liegen“: `manifest.json`-Beschreibung um `updateVersion` und je Plattform `signature` ergaenzen; nach der `curl … /desktop/latest`-Zeile eine zweite Kontrollzeile `curl -si "https://{ihre-adresse}/api-proxy/desktop/update?target=windows&arch=x86_64&current=0.0.0&base=https://{ihre-adresse}"` mit Erklaerung: `200` mit `version`/`url`/`signature` = Update-faehig; `204` = kein signiertes Paket fuer diese Plattform (z. B. Stand vor September 2026 oder Bau ohne Schluessel).
- Fehlerbilder-Tabelle, zwei Zeilen: „Client meldet ‚Update fehlgeschlagen'" (Ursachen: Download ueber den Proxy abgebrochen — Groessengrenze/Timeout wie in der Zeile darueber; Signatur passt nicht — Pakete stammen nicht aus dem CI-Bau mit dem aktuellen Schluessel; Pruefen: `curl`-Kontrollzeile, Pipeline-Lauf, Proxy) und „`/desktop/update` antwortet dauerhaft 204, obwohl Pakete da sind" (Manifest ohne `signature`/`updateVersion`: Pakete aus einem Bau vor der Update-Funktion oder mit `--no-sign`; Beheben: Aenderung unter `apps/desktop/` pushen oder Tag neu bauen; im CI die Secrets pruefen).
3. **docs/anleitung-entwicklung.md** „Desktop-App lokal bauen“: den Bau-Befehl auf `pnpm --filter @tessera/desktop exec tauri build --bundles appimage --no-sign` aendern und einen Absatz ergaenzen: Seit der Update-Funktion verlangt `tauri build` den Signierschluessel (`TAURI_SIGNING_PRIVATE_KEY`), weil `createUpdaterArtifacts` und der oeffentliche Schluessel in `tauri.conf.json` gesetzt sind; lokal baut man mit `--no-sign` (dann entsteht keine `.sig`, `desktop-collect.sh` warnt und die API antwortet auf `/desktop/update` mit 204 — der In-App-Update-Weg laesst sich lokal nur mit dem echten Schluessel durchspielen). Hinweis: `tauri dev` laeuft ohne AppImage — dort `download_and_install` nie ausloesen (es wuerde die Binary in `target/` ueberschreiben); die Versionspruefung selbst ist im Dev-Bau auch gegen `http://localhost` erlaubt.
4. **docs/ci-cd-setup.md**: Secrets-Tabelle: Satz „Benoetigt wird genau eines“ → „Benoetigt werden drei“; zwei Zeilen `TAURI_SIGNING_PRIVATE_KEY` (Inhalt der privaten Schluesseldatei des Tauri-Updaters, eine Base64-Zeile; nur an den zwei `tauri build`-Schritten des Jobs `desktop` als `env`) und `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` (Passwort dazu). Absatz: der Schluessel wird nie ausgegeben; die Skripte kennen ihn nicht (`desktop-collect.sh` prueft nur, ob die Variable gesetzt ist). Job-`desktop`-Beschreibung Punkt 5: Bau-Schritte signieren die Bundles (`.sig` neben `-setup.exe`/`.AppImage`, host-unabhaengig auch im Cross-Bau); Punkt 6: `desktop-collect.sh` traegt `signature` und `updateVersion` ins Manifest und bricht auf `main`/Tags ohne `.sig` ab. Fehlerbehebung: neuer Unterabschnitt `### Job desktop: "A public key has been found, but no private key"` (Secrets fehlen/umbenannt → in Gitea pruefen; nie `--no-sign` in ci.yml eintragen). ASCII beibehalten (keine Umlaute).
5. **CHANGELOG.md** `## Unveröffentlicht` → `### Neu`: am ENDE der Liste eine Zeile `- Desktop-App: Update mit einem Klick – „Auf Version X.Y.Z aktualisieren“ im Menü des Infobereich-Symbols lädt das signierte Paket, installiert es und startet die App neu (Windows und Linux); Voraussetzung ist eine https-Adresse, bereits installierte Versionen bis 1.2.0 wechseln einmal noch über den Browser`. Fremde Zeilen unveraendert; `git diff CHANGELOG.md` vor dem Commit gegenpruefen (nur diese eine Zeile neu).
6. Commit nur der fuenf Task-Dateien: `git add docs/anleitung-anwender.md docs/anleitung-betrieb.md docs/anleitung-entwicklung.md docs/ci-cd-setup.md CHANGELOG.md`.
</action>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl && command grep -q '\*\*Update installieren\*\*' docs/anleitung-anwender.md && ! command grep -q '\*\*Update herunterladen\*\*' docs/anleitung-anwender.md && command grep -q 'Auf Version X.Y.Z aktualisieren' docs/anleitung-anwender.md && command grep -q 'Update nur über https möglich' docs/anleitung-anwender.md && command grep -q 'Einmaliger Wechsel' docs/anleitung-anwender.md && ! command grep -q 'Ein automatisches Aktualisieren gibt es nicht' docs/anleitung-anwender.md && command grep -q '^### Updates in der App und der Signierschlüssel' docs/anleitung-betrieb.md && command grep -q 'TAURI_SIGNING_PRIVATE_KEY_PASSWORD' docs/anleitung-betrieb.md && command grep -q '~/.tessera/desktop-updater/' docs/anleitung-betrieb.md && command grep -q 'api-proxy/desktop/update?target=windows' docs/anleitung-betrieb.md && command grep -q 'updateVersion' docs/anleitung-betrieb.md && command grep -q 'tauri build --bundles appimage --no-sign' docs/anleitung-entwicklung.md && command grep -q 'TAURI_SIGNING_PRIVATE_KEY' docs/anleitung-entwicklung.md && command grep -q '`TAURI_SIGNING_PRIVATE_KEY`' docs/ci-cd-setup.md && command grep -q '`TAURI_SIGNING_PRIVATE_KEY_PASSWORD`' docs/ci-cd-setup.md && command grep -q 'no private key' docs/ci-cd-setup.md && ! command grep -q -E '[äöüÄÖÜß]' docs/ci-cd-setup.md && awk '/^## Unveröffentlicht/{u=1;next} /^## /{u=0} u' CHANGELOG.md | command grep -q '^- Desktop-App: Update mit einem Klick' && ! awk '/^## 1\.2\.0/{u=1} u' CHANGELOG.md | command grep -q 'Update mit einem Klick' && test "$(awk '/^## Unveröffentlicht/{u=1;next} /^## /{u=0} u' CHANGELOG.md | command grep -c '^- Desktop-App: ')" -ge 4 && pnpm --filter @tessera/web exec vitest run src/lib/changelog.test.ts && echo DOCS-OK</automated>
</verify>
<done>Anwenderhandbuch beschreibt das Update per Klick (Ablauf Windows/Linux, Fehlerfall, https-Bedingung, einmaliger Wechsel); Betriebshandbuch erklaert Schluessel, Secrets, Sicherung/Verlust, Kontrollzeile und zwei Fehlerbilder; Entwicklungshandbuch nennt `--no-sign`; ci-cd-setup.md fuehrt die zwei Secrets (ASCII); CHANGELOG eine neue Zeile; changelog.test.ts gruen; Commit `docs: Desktop-App — Update in der App (Handbücher, CI-Secrets, CHANGELOG)`.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Tessera-Server (API/Proxy) → Desktop-Client | Update-Antwort und Paket-Bytes kommen ueber das Netz; der Server (oder ein Angreifer dazwischen) ist fuer den Client nicht vertrauenswuerdig — die Signatur ist die einzige Vertrauensbasis |
| Anfragender (Query `base`, `target`, `arch`) → API | Freie Eingabe ohne Anmeldung (`@Public()`); wird nur reflektiert bzw. per Whitelist gefiltert |
| CI-Runner (Secrets) → Bundle-Signatur | Privater Schluessel existiert nur als Gitea-Secret und auf dem Dev-Rechner; der Bau-Schritt ist die einzige Stelle, die ihn sieht |
| manifest.json (aus dem CI-Bau) → API-Antwort | Manifest-Felder `signature`/`updateVersion` werden ungeprueft weitergereicht — Manipulation kann keine gueltige Signatur erzeugen |
| Client-Prozess → Installer (Windows) / Dateisystem (Linux) | Installation laeuft im Nutzerkontext (`currentUser`), ohne Erhoehung |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-KGC-01 | Tampering | Paket auf dem Transportweg / auf dem Server, `Update::download` | high | mitigate | minisign-Signaturpruefung durch das Plugin gegen `plugins.updater.pubkey` VOR jeder Installation (updater.rs Z. 740); privater Schluessel offline (Gitea-Secret + `~/.tessera/desktop-updater/`). Falsche/fehlende Signatur → `Error::Minisign`, keine Installation, Rueckfall Browser-Seite. Kein Manifest-Hash-Vergleich als Ersatz — die Signatur ist die Vertrauensbasis. |
| T-KGC-02 | Spoofing / SSRF | `GET /desktop/update?base=` | medium | mitigate | `safeOrigin`: nur `http:`/`https:`, keine Userinfo, Pfad genau `/`, keine Query/Fragment, Rueckgabe `url.origin`; sonst 400. `base` wird ausschliesslich zum Bauen der Rueckgabe-URL fuer den Anfragenden genutzt, nie serverseitig abgerufen (Gate: kein `fetch`/`http.get` im Service). Ein Angreifer lenkt hoechstens seinen eigenen Client auf einen fremden Download, den T-KGC-01 ablehnt. |
| T-KGC-03 | Information Disclosure | Privater Signierschluessel (Secrets, Dev-Rechner) | high | mitigate | Secrets nur als `env` an den zwei `tauri build`-Schritten (nicht Job-Ebene; `pnpm install`/`apt`/`cargo install`/Cache sehen ihn nicht); Skripte kennen ihn nicht (`desktop-collect.sh` prueft nur Gesetztsein); Gitea maskiert Secret-Werte; `*.key` in `.gitignore`; der Executor liest `~/.tessera/desktop-updater/` nie. js-yaml-Gate verhindert Secrets an fremden Schritten. |
| T-KGC-04 | Tampering (Downgrade) | `is_update_newer`, Server-Antwort mit aelterer Version | medium | mitigate | Comparator installiert nur eine hoehere Basisversion oder einen anderen Beta-Stempel bei gleicher Basis; niedrigere Basis nie; gleiche Basis Live nie. Ein Angreifer braeuchte ohnehin den privaten Schluessel (T-KGC-01). Rest-Risiko: Beta-Stempel-Wechsel innerhalb derselben Basis kann zeitlich rueckwaerts sein — hingenommen, weil nur signierte CI-Baue in Frage kommen. |
| T-KGC-05 | Tampering | manifest.json (`signature`, `updateVersion`) | low | mitigate | Manifest entsteht nur im CI (`desktop-collect.sh`, `jq --arg`); API prueft Form (`UPDATE_VERSION_RE`, `signature` String, `pub_date` nur bei RFC-3339-Form) und liefert sonst 204; T-18-01/02 (Dateiname nur aus dem Manifest, Pfadpruefung) bleiben. Manipulierte Felder koennen keine gueltige Signatur erzeugen. |
| T-KGC-06 | Tampering | Transport `http://` | medium | mitigate | Plugin verweigert im Release-Bau Nicht-https-Endpunkte (`Error::InsecureTransportProtocol`); keine `dangerousInsecureTransportProtocol`/`dangerousAcceptInvalidCerts`/`dangerousAcceptInvalidHostnames`-Schalter (jq-Gate in Task 1). Zertifikatspruefung ueber den Betriebssystem-Speicher (rustls-platform-verifier). |
| T-KGC-07 | Elevation of Privilege | NSIS-Installer per `ShellExecuteW`, AppImage-Ersetzung | low | accept | Installer laeuft im Nutzerkontext (`installMode: currentUser`), Argumente (`/P /UPDATE /R /ARGS`) stammen fest aus dem Plugin, keine Nutzereingabe; Linux ersetzt nur die eigene AppImage (`APPIMAGE`-Pfad), Rechte werden uebernommen, bei Fehler Ruecksicherung. |
| T-KGC-08 | Denial of Service | Download (~100 MB im RAM), Doppelklick, Timeout | low | accept | `take()` aus `PendingUpdate` + gesperrter Eintrag verhindern parallele Downloads; Pruefung 15 s, Download 600 s Gesamt-Timeout; ein fehlgeschlagener Download endet in Benachrichtigung + Browser-Rueckfall, nie in einem haengenden Zustand. RAM-Bedarf einmalig, hingenommen. |
| T-KGC-09 | Information Disclosure | `GET /desktop/update` (`@Public()`) | low | accept | Antwort enthaelt nur Version, Datum, Download-URL und die (oeffentliche) Signatur — dieselben Daten wie `/desktop/latest`; keine Zugangsdaten, kein Server-Interna. |
| T-KGC-SC | Tampering | cargo installs (`tauri-plugin-updater`, `semver`) | high | mitigate | Beide Crates gegen den Quelltext im lokalen Registry-Spiegel geprueft: `tauri-plugin-updater 2.11.0` (tauri-apps/plugins-workspace, offizielles Tauri-Plugin, crates.io seit 2023), `semver 1.0.28` (dtolnay, bereits transitiv in Cargo.lock) — Einstufung `[OK]`, kein `[ASSUMED]`/`[SUS]`, darum kein Human-Checkpoint. Executor-Gate: Cargo.lock loest `tauri-plugin-updater` auf `2.x` auf (kein `3.0.0-alpha`), Projekt-`reqwest` bleibt 0.12. Kein `pnpm add`. |
</threat_model>
<verification>
- Rust (Task 1): `cargo fmt --check && cargo check && cargo clippy && cargo test --lib` in apps/desktop/src-tauri mit `CARGO_BUILD_JOBS=4` gruen (≥ 30 Tests); Plugin registriert; Comparator/Endpunkt/Labels rein und getestet; `Duration::from_secs(600)` fuer den Download; `InsecureTransportProtocol` behandelt; `/desktop/latest` im Client nicht mehr referenziert; Run-Handler unveraendert (`code: None, api`); capabilities/default.json und setup.html unveraendert; Cargo.lock auf 2.11.x.
- API (Task 2): `pnpm --filter @tessera/api exec vitest run src/desktop` (≥ 21 Tests) gruen, api- und shared-type-check gruen; `update` vor `download/:platform`; `passthrough` + `res.status(204)`; kein serverseitiger Abruf im Service.
- Skripte/CI (Task 3): `sh -n` beider Skripte; Mini-Fixture-Proben fuer collect (beta/live/dev, fehlende `.sig` bei main/Tag/gesetztem Schluessel → Abbruch, dev → Warnung, zweizeilige `.sig` → Abbruch) und stamp (`reuse=true` nur mit `updateVersion` + beiden Signaturen); js-yaml-Tiefenvergleich: nur `env` an den zwei Bau-Schritten neu, sonst strukturgleich, kein `--no-sign`; `*.key` ignoriert.
- Doku (Task 4): Grep-Gates + `changelog.test.ts`.
- Offen (Nachweis durch Orchestrator, Vorgabe 9): (1) CI-Lauf auf main baut beide Pakete mit `.sig`, Manifest traegt `signature`/`updateVersion`, `publish` gruen; (2) `curl -si …/api-proxy/desktop/update?target=windows&arch=x86_64&current=0.0.0&base=https://alpha.…` liefert 200 mit absoluter `url`; (3) Windows-VM: Client alten Standes zeigt nach Start „Auf Beta-Stand … aktualisieren“, Klick → Fortschritt im Menue → passiver Installer → App startet neu, Tray zeigt neuen Stand, Server-Adresse erhalten; SmartScreen-Verhalten des vom Updater gestarteten Installers notieren (Recherche A2); (4) Cross-Bau von `ring`/`rustls` in der Pipeline gruen (Recherche A1 — Fallback `native-tls`-Feature laut Recherche Abschnitt 8, falls rot).
</verification>
<success_criteria>
- Alle `must_haves.truths` erfuellt; vier Commits (`feat(desktop)`, `feat(api)`, `ci`, `docs`) ohne Push, ohne `tauri build`, ohne Docker, ohne `.planning/`-Commits.
- Keine Datei ausserhalb von `files_modified` veraendert (vor jedem Commit `git status` gegenpruefen; parallele Arbeiten im selben Baum — nur eigene Dateien per `git add` nennen, nie `git add -A`).
- `~/.tessera/desktop-updater/` wurde nie gelesen, kopiert oder ausgegeben; kein Secret-Wert erscheint in Code, Doku, Logs oder SUMMARY.
- SUMMARY nennt: die gewaehlte Vergleichsregel (gleiche Basis + Live → kein Update) samt Begruendung, die Timeout-Trennung (15 s Pruefung / 600 s Download), die Entscheidung zur `http`-Anzeige (Menuetext statt Benachrichtigung), die Secrets auf Schritt-Ebene, und die vier offenen Nachweise fuer den Orchestrator.
</success_criteria>
<output>
Create `/home/vicolab/projects/tessera-ctl/.planning/quick/260917-kgc-desktop-client-update-in-der-app-herunte/260917-kgc-SUMMARY.md` when done
</output>