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

78 KiB
Raw Blame History

phase, plan, type, wave, depends_on, autonomous, requirements, files_modified, estimate, must_haves
phase plan type wave depends_on autonomous requirements files_modified estimate must_haves
quick-260917-kgc 01 execute 1
true
QUICK-260917-KGC
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
tokens raw_tokens tasks confidence
95000 95000 4 low
truths artifacts key_links
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.
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
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.
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.

<execution_context> @/.claude/gsd-core/workflows/execute-plan.md @/.claude/gsd-core/templates/summary.md </execution_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 Task 1: Client — Updater-Plugin, Config, Vergleichsregel, Endpunkt-URL, Tray-Fluss Pruefen → Herunterladen → Installieren → Neustart, Tests 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 - 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 bool + Send + Sync + 'static>`, Z. 224-231 `endpoints(Vec) -> Result` (https-Zwang im Release), Z. 264 `timeout`, Z. 365-388 `build()`, Z. 432 `check() -> Result>`, 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`, `download_url`), Z. 680 `download), 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“ 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¤t=%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. Zuerst die Tests aus `` 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).
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 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. Task 2: API — `GET /desktop/update` (dynamisches Updater-Format, `base`-Origin-Validierung, 204-Faelle), Manifest-Felder `signature`/`updateVersion` in `@tessera/shared`, Spec-Tests 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 - 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 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¤t=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). 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`.
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 `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`. 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 .gitea/scripts/desktop-collect.sh, .gitea/scripts/desktop-stamp.sh, .gitea/workflows/ci.yml, .gitignore - .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 `.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-`` (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` 1. **desktop-collect.sh** (Vorgabe 5): - Kopfkommentar: Absatz „Signatur (quick-260917-kgc)“: die Tauri-CLI legt beim Bau mit `createUpdaterArtifacts` neben jedem Bundle eine `.sig` ab (minisign, eine Base64-Zeile); dieses Skript traegt deren INHALT als `files..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 `.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 `.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 `` 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`. 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 `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`. Task 4: Doku — Anwender-, Betriebs- und Entwicklungshandbuch, ci-cd-setup.md, CHANGELOG docs/anleitung-anwender.md, docs/anleitung-betrieb.md, docs/anleitung-entwicklung.md, docs/ci-cd-setup.md, CHANGELOG.md - 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) 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--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¤t=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`. 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 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)`.

<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>
- 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¤t=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).

<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>
Create `/home/vicolab/projects/tessera-ctl/.planning/quick/260917-kgc-desktop-client-update-in-der-app-herunte/260917-kgc-SUMMARY.md` when done