Quick 260917-kgc (Plan/Recherche/Bericht/Verifikation) und Schnellfixa6d1a64in der Quick-Task-Tabelle; Nachweise in allen sechs Berichten nachgetragen (Playwright lokal, CI-Laeufe 382-384, Windows-Test-VM: In-App-Update7479cb4->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
78 KiB
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 |
|
|
|
|
- Client (Vorgaben 1, 2, 3, 6, 7). Plugin einbauen,
pubkey/installMode/createUpdaterArtifactsin tauri.conf.json, eigenerversion_comparator(is_update_newer), Endpunkt zur Laufzeit ausstored_server_url, Tray-Eintragupdatewird 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 → MenuetextUpdate nur über https möglich, gesperrt, kein Absturz. - API (Vorgabe 4).
GET /desktop/updatemitbase-Origin-Validierung (400), 204-Faellen und dynamischem Updater-Format;signature/updateVersionals optionale Manifest-Felder in@tessera/shared. - Manifest/Bau/CI (Vorgabe 5).
desktop-collect.shsammelt die.sigalssignature, schreibtupdateVersion;desktop-stamp.sh checkverlangt beides; Secrets nur an den zweitauri build-Schritten;*.keyin.gitignore. - 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>
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(¤t, 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(¤t, &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> |
<success_criteria>
- Alle
must_haves.truthserfuellt; vier Commits (feat(desktop),feat(api),ci,docs) ohne Push, ohnetauri build, ohne Docker, ohne.planning/-Commits. - Keine Datei ausserhalb von
files_modifiedveraendert (vor jedem Commitgit statusgegenpruefen; parallele Arbeiten im selben Baum — nur eigene Dateien pergit addnennen, niegit 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>