Files
tessera-ctl/.planning/quick/260917-h2s-desktop-client-web-erkennt-den-client-do/260917-h2s-PLAN.md
T

241 lines
34 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
phase: quick-260917-h2s
plan: 01
type: execute
wave: 1
depends_on: []
autonomous: true
requirements: [QUICK-260917-H2S]
files_modified:
- apps/desktop/src-tauri/src/lib.rs
- apps/desktop/src-tauri/tauri.conf.json
- apps/desktop/src-tauri/icons/nsis-header.bmp
- apps/desktop/src-tauri/icons/nsis-sidebar.bmp
- apps/web/src/middleware.ts
- apps/web/src/middleware.test.ts
- apps/web/src/lib/desktop-client.ts
- apps/web/src/lib/desktop-client.test.ts
- apps/web/src/components/desktop/desktop-download-links.tsx
- apps/web/src/components/desktop/desktop-download-links.test.tsx
- apps/web/src/components/desktop/desktop-context-menu-guard.tsx
- apps/web/src/components/desktop/desktop-context-menu-guard.test.tsx
- apps/web/src/app/layout.tsx
- CHANGELOG.md
- docs/anleitung-anwender.md
- docs/anleitung-entwicklung.md
estimate:
tokens: 48000
raw_tokens: 48000
tasks: 3
confidence: low
must_haves:
truths:
- "lib.rs: eine reine Funktion `with_desktop_marker(url: &tauri::Url) -> tauri::Url` haengt `desktop=1` als Query-Paar an einen Klon an (via `query_pairs_mut().append_pair`); BEIDE Navigationen zur Server-Adresse (`save_server_url` und die Startnavigation im `setup`) uebergeben `with_desktop_marker(&parsed)` an `window.navigate`. Der Wert `server_url` im Store bleibt ohne Parameter (weiterhin `parsed.as_str()`)."
- "lib.rs: eine reine Funktion `update_labels(version_changed: bool, version: &str, commit: &str) -> (String, String)` liefert (Menuetext, Benachrichtigungstext): bei `version_changed` `Version {v} herunterladen` / `Neue Version {v} verfügbar – Download über das Symbol im Infobereich.`; sonst `Neuen Beta-Stand herunterladen` / `Neuer Beta-Stand {commit} verfügbar – Download über das Symbol im Infobereich.` (echte Umlaute wie im Bestand). Die Versionspruefung im `setup` nutzt genau diese Funktion; `is_newer` bleibt wie bisher (Versionsvergleich ODER Beta-Commit-Vergleich)."
- "lib.rs traegt ein `#[cfg(test)] mod tests` mit Tests fuer `with_desktop_marker` (Adresse ohne Pfad, Adresse mit vorhandenem Query, Original unveraendert) und `update_labels` (beide Zweige); `cargo fmt --check`, `cargo check`, `cargo clippy` und `cargo test --lib` in apps/desktop/src-tauri sind gruen."
- "middleware.ts: Helfer `withDesktopCookie(req: NextRequest, res: NextResponse): NextResponse` setzt bei `req.nextUrl.searchParams.get('desktop') === '1'` das Cookie `tessera_desktop=1` (path `/`, maxAge 31536000, sameSite `lax`, httpOnly `false`, secure nur wenn `req.nextUrl.protocol === 'https:'`) auf die uebergebene Antwort und gibt sie zurueck; ohne Parameter Rueckgabe unveraendert. JEDE `return`-Anweisung der `middleware`-Funktion (Fruehausstieg oeffentliche Routen, Statics, Redirect ohne Session, Redirect Passwortwechsel, `NextResponse.next()` nach gueltigem JWT, Redirect bei ungueltigem JWT — und alle, die Plan 260917-gyd zwischenzeitlich ergaenzt hat) laeuft durch `withDesktopCookie(req, …)`."
- "middleware.test.ts (`// @vitest-environment node`) belegt: `/login?desktop=1` → Set-Cookie mit `tessera_desktop=1`, `Path=/`, `Max-Age=31536000`, `SameSite=lax`, ohne `Secure`, ohne `HttpOnly`; `/login` ohne Parameter → kein Set-Cookie; `/dashboard?desktop=1` ohne Session → Status 307, `location` enthaelt `/login`, Set-Cookie vorhanden; `https://…/login?desktop=1` → `Secure` gesetzt; `/dashboard?desktop=1` mit gueltigem HS256-JWT (jose `SignJWT`, `vi.stubEnv('JWT_SECRET', …)`) → Set-Cookie vorhanden und Header `x-middleware-next` = `1`."
- "apps/web/src/lib/desktop-client.ts exportiert `DESKTOP_COOKIE_NAME = 'tessera_desktop'`, `isDesktopClient(): boolean` (liest `document.cookie`, `typeof document === 'undefined'` → false, true genau wenn ein Eintrag `tessera_desktop=1` existiert) und den Hook `useIsDesktopClient(): boolean` (useState(false) + useEffect, damit SSR und erster Client-Render uebereinstimmen). desktop-client.test.ts prueft: ohne Cookie false, mit `tessera_desktop=1` true, mit `tessera_desktop=0` false, `document` undefiniert (vi.stubGlobal) → false, Hook via `renderHook` liefert nach dem Effekt true."
- "DesktopDownloadLinks rendert im Desktop-Client nichts: `useIsDesktopClient()` → `null`, und der Ladeeffekt bricht bei `isDesktopClient()` vor dem Aufruf von `loadDesktopLatest` ab. Neuer Test 4 (Cookie gesetzt, `loadDesktopLatest` haette beide Pakete geliefert): `loadDesktopLatest` wird NICHT aufgerufen, `container.firstChild` ist null. Cookie wird in `afterEach` geloescht; Tests 1-3 bleiben unveraendert gruen."
- "DesktopContextMenuGuard (`'use client'`, rendert `null`): bei `useIsDesktopClient()` true registriert ein Effekt einen `contextmenu`-Listener auf `document`, der `preventDefault()` ruft — AUSSER das Ziel liegt in `input`, `textarea`, `select` oder einem contenteditable-Bereich (`closest('input, textarea, select, [contenteditable=\"\"], [contenteditable=\"true\"], [contenteditable=\"plaintext-only\"]')` oder `isContentEditable === true`). Aufraeumfunktion entfernt den Listener. Im RootLayout (`apps/web/src/app/layout.tsx`) eingebunden. Test (jsdom, MouseEvent `contextmenu` bubbles+cancelable): mit Cookie auf `div` → `dispatchEvent` false, auf `input`/`textarea`/Kind eines `[contenteditable=\"true\"]` → true; ohne Cookie auf `div` → true; nach `unmount()` auf `div` → true."
- "tauri.conf.json enthaelt `bundle.windows.nsis` mit exakt: `languages: [\"German\"]`, `displayLanguageSelector: false`, `installerIcon: \"icons/icon.ico\"`, `headerImage: \"icons/nsis-header.bmp\"`, `sidebarImage: \"icons/nsis-sidebar.bmp\"`, `installMode: \"currentUser\"`; alle Schluessel sind in `NsisConfig.properties` des lokalen CLI-Schemas (apps/desktop/node_modules/@tauri-apps/cli/config.schema.json) enthalten; die beiden BMP-Dateien liegen in apps/desktop/src-tauri/icons/ (BMP3, 150x57 bzw. 164x314); `cargo check` in src-tauri bleibt gruen (tauri-codegen parst die Konfiguration mit `deny_unknown_fields`)."
- "CHANGELOG.md `## Unveröffentlicht`: zwei Stichpunkte unter `### Geändert` (Beta-Hinweis nennt den Stand; Installer auf Deutsch mit Tessera-Grafik und -Symbol) und zwei unter `### Behoben` (Download-Links in der App ausgeblendet; Browser-Kontextmenue in der App ausgeblendet, in Eingabefeldern erhalten), Stil wie Bestand (Praefix `Desktop-App:`, typografische Anfuehrungszeichen, kein Punkt am Ende). Nur zusaetzliche Zeilen — vorhandene Zeilen (auch neue aus 260917-gyd) bleiben stehen."
- "`pnpm --filter @tessera/web exec vitest run` und `pnpm --filter @tessera/web type-check` enden gruen. Kein Docker-Build, kein `tauri build`, kein `git push`, keine Dateien ausserhalb von files_modified + .planning/."
- "Drei Commits: `feat(desktop): …` (Task 1), `feat(web): …` (Task 2), `feat(desktop): …`/`docs: …` (Task 3)."
artifacts:
- "apps/desktop/src-tauri/src/lib.rs — `with_desktop_marker`, `update_labels`, `mod tests`"
- "apps/desktop/src-tauri/tauri.conf.json — Block `bundle.windows.nsis`"
- "apps/desktop/src-tauri/icons/nsis-header.bmp, nsis-sidebar.bmp — neu (aus dem Scratchpad kopiert)"
- "apps/web/src/middleware.ts — `withDesktopCookie`"
- "apps/web/src/middleware.test.ts — neu (oder erweitert, falls 260917-gyd sie angelegt hat)"
- "apps/web/src/lib/desktop-client.ts + .test.ts — neu"
- "apps/web/src/components/desktop/desktop-download-links.tsx + .test.tsx — Client-Waechter, Test 4"
- "apps/web/src/components/desktop/desktop-context-menu-guard.tsx + .test.tsx — neu"
- "apps/web/src/app/layout.tsx — Guard eingebunden"
- "CHANGELOG.md, docs/anleitung-anwender.md, docs/anleitung-entwicklung.md — Stichpunkte/Saetze"
key_links:
- "Die Kette Client → Web ist: Rust haengt `desktop=1` an die ERSTE Navigation → Middleware setzt das Cookie auf die Antwort dieser Anfrage (auch wenn sie ein 307 nach /login ist — WebView2 uebernimmt Set-Cookie auf Redirects) → alle Folgeseiten sehen `tessera_desktop=1` in `document.cookie`. Faellt eines der drei Glieder aus, greift nichts; deshalb setzt die Middleware das Cookie auf JEDER Rueckgabe, nicht nur auf `next()`."
- "Der Tray-Eintrag „Update herunterladen“ oeffnet die Einstellungsseite im SYSTEM-Browser (opener), nicht im Client — dort muessen die Download-Links sichtbar bleiben. Deshalb bekommt diese URL KEIN `desktop=1`, und der Browser des Nutzers bekommt das Cookie nie."
- "`isDesktopClient()` liest das Cookie synchron; wuerde die Komponente es beim ersten Render nutzen, unterschieden sich Server-HTML (kein document) und Client-HTML → Hydration-Fehler. Darum der Hook mit useEffect; der Ladeeffekt darf `isDesktopClient()` dagegen direkt aufrufen (Effekte laufen nur im Client)."
- "Middleware-Datei wird VOR diesem Plan durch 260917-gyd geaendert (`next`-Rueckkehrparameter). Die Helferfunktion ist additiv: sie umschliesst Rueckgabewerte, aendert keine Redirect-Ziele. Der Test prueft `location` nur auf `enthaelt /login`, nicht auf exakte Gleichheit."
- "jsdom implementiert `HTMLElement.isContentEditable` nicht — die Ausnahme fuer contenteditable MUSS ueber `closest('[contenteditable…]')` laufen, sonst ist sie im Test unsichtbar und im Browser trotzdem aktiv (oder umgekehrt)."
- "tauri-codegen (`tauri::generate_context!()` in `run()`) parst tauri.conf.json beim `cargo check` mit `deny_unknown_fields`; ein Tippfehler im nsis-Block faellt lokal auf, obwohl kein Installer gebaut wird. Ob die BMPs korrekt eingebunden sind, zeigt erst der CI-Bau — Nachweis durch den Orchestrator auf der Windows-VM."
---
<objective>
Vier Befunde aus der Bedienprobe des Desktop-Clients auf der Windows-VM schliessen:
1. **Client-Erkennung.** Der Rust-Client haengt bei beiden Navigationen zur Server-Adresse `desktop=1` an; die Next.js-Middleware setzt daraufhin das Cookie `tessera_desktop=1` (ein Jahr, lax, nicht httpOnly). Web-Helfer `isDesktopClient()` + Hook `useIsDesktopClient()`.
2. **Download-Links** auf der Anmeldeseite erscheinen im Client nicht mehr (und der Client fragt `/desktop/latest` gar nicht erst an).
3. **Kontextmenue.** Ein kleiner Client-Waechter im RootLayout unterdrueckt das WebView2-Browser-Kontextmenue, laesst es in Eingabefeldern (input/textarea/select/contenteditable) aber zu.
4. **Beta-Label.** Gleiche Versionsnummer, anderer Commit → Tray „Neuen Beta-Stand herunterladen“ und Benachrichtigung „Neuer Beta-Stand {commit} verfügbar …“ statt der verwirrenden gleichen Version.
5. **Installer.** `bundle.windows.nsis` in tauri.conf.json: Deutsch ohne Sprachauswahl, Tessera-Symbol, Kopf- und Seitenbild (BMPs fertig im Scratchpad), currentUser.
Die Reihenfolge der Tasks folgt der Vorgabe des Orchestrators (Rust → Web → Installer/Doku). Die einzige lokal Ende-zu-Ende pruefbare Kette (Anfrage mit `desktop=1` → Cookie auf der Antwort → Hook → Komponente rendert nichts) liegt komplett in Task 2 — deshalb traegt Task 2 die Tracer-Rolle; Task 1 liefert den Einstiegspunkt (Rust) mit Unit-Test. Der Beweis ueber die WebView2-Grenze (Cookie im echten Client, deutscher Installer, Grafik, Symbol) erfolgt durch den Orchestrator nach dem CI-Bau auf der Windows-VM.
Purpose: Der Client soll sich wie eine App anfuehlen (keine Browser-Reste, keine sinnlosen Download-Angebote), der Beta-Update-Hinweis soll verstaendlich sein, und der Installer soll zur Marke und zur Sprache der Anwender passen.
Output: lib.rs mit zwei reinen Helfern + Tests; Middleware-Cookie + Test; desktop-client.ts + Test; angepasste DesktopDownloadLinks + Test; DesktopContextMenuGuard + Test; layout.tsx; nsis-Block + zwei BMPs; CHANGELOG + zwei Handbuchsaetze; drei Commits.
</objective>
<execution_context>
@~/.claude/gsd-core/workflows/execute-plan.md
@~/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@/home/vicolab/projects/tessera-ctl/CLAUDE.md
@/home/vicolab/projects/tessera-ctl/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/web/src/middleware.ts
@/home/vicolab/projects/tessera-ctl/apps/web/src/components/desktop/desktop-download-links.tsx
@/home/vicolab/projects/tessera-ctl/apps/web/src/components/desktop/desktop-download-links.test.tsx
@/home/vicolab/projects/tessera-ctl/apps/web/src/app/layout.tsx
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Rust — `desktop=1` an beide Navigationen, Beta-Label per `update_labels`, Unit-Tests</name>
<files>apps/desktop/src-tauri/src/lib.rs</files>
<read_first>
- apps/desktop/src-tauri/src/lib.rs Z. 24-31 (Stil eines dokumentierten reinen Helfers: `api_url`), Z. 66-80 (`save_server_url`: `parsed` → `normalized` in den Store, danach Navigation), Z. 96-107 (Startnavigation im `setup`), Z. 197-231 (Versionspruefung: `is_newer`, Benachrichtigung, `update_item.set_text`)
- apps/desktop/src-tauri/build.rs Z. 1-16 (warum `APP_COMMIT` existiert — Beta-Kanal vergibt jedem Commit dieselbe Version)
</read_first>
<behavior>
`#[cfg(test)] mod tests` in lib.rs:
- `with_desktop_marker` auf `https://tessera.example.com` → `https://tessera.example.com/?desktop=1`
- `with_desktop_marker` auf `https://host/app?x=1` → `https://host/app?x=1&desktop=1`
- Das uebergebene Original hat nach dem Aufruf weiterhin `query() == None` (Klon, kein In-Place)
- `update_labels(true, "1.2.0", "abc1234")` → `("Version 1.2.0 herunterladen", "Neue Version 1.2.0 verfügbar – Download über das Symbol im Infobereich.")`
- `update_labels(false, "1.1.0", "abc1234")` → `("Neuen Beta-Stand herunterladen", "Neuer Beta-Stand abc1234 verfügbar – Download über das Symbol im Infobereich.")`
</behavior>
<action>
Zuerst die Tests aus `<behavior>` als `mod tests` ans Dateiende schreiben und `cargo test --lib` rot sehen (Funktionen existieren noch nicht). Dann:
1. Neben `api_url` eine reine Funktion `with_desktop_marker(url: &tauri::Url) -> tauri::Url` anlegen: Klon des Urls, `query_pairs_mut().append_pair("desktop", "1")`, Klon zurueckgeben. Deutscher Doc-Kommentar mit ae/oe/ue (Stil wie bei `api_url`): Warum der Parameter nur an die Navigation geht und nie in den Store (`server_url` bleibt die reine Adresse; die Middleware setzt daraus das Cookie `tessera_desktop`, siehe apps/web/src/middleware.ts), und dass die Tray-URL „Update herunterladen“ ihn bewusst NICHT bekommt (oeffnet im System-Browser, dort muessen die Download-Links sichtbar bleiben).
2. In `save_server_url` und in der Startnavigation des `setup` das Argument beider `window.navigate`-Aufrufe auf `with_desktop_marker(&parsed)` aendern. `normalized` (Store-Wert) bleibt `parsed.as_str()` — vor dem Anhaengen gebildet, also ohne Parameter.
3. Reine Funktion `update_labels(version_changed: bool, version: &str, commit: &str) -> (String, String)` (Rueckgabe: Menuetext, Benachrichtigungstext) mit den beiden Zweigen aus `<behavior>`; echte Umlaute und Gedankenstrich exakt wie der bestehende Benachrichtigungstext. Doc-Kommentar: Beta-Kanal vergibt jedem Commit dieselbe X.Y.Z (D-07), darum nennt der zweite Zweig den Commit-Stempel statt der Version.
4. In der Versionspruefung: `let version_changed = info.version != app_version;` und `is_newer` daraus plus dem bestehenden Beta-Commit-Vergleich bilden (Logik unveraendert). Innerhalb von `if is_newer` ein `let (menu_text, body) = update_labels(version_changed, &info.version, &info.commit);` und beide bisherigen `format!`-Aufrufe (Benachrichtigungs-`body` und `update_item.set_text`) durch `body` bzw. `menu_text` ersetzen. Der Kommentarblock ueber `is_newer` bleibt, ein Satz ergaenzt, dass die Texte aus `update_labels` kommen.
5. `cargo fmt` anwenden (Bestand ist rustfmt-konform).
Nicht anfassen: Tray-Menue, Autostart, Fensterverhalten, `check_server`, build.rs.
</action>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl/apps/desktop/src-tauri && cargo fmt --check && cargo check && cargo clippy && cargo test --lib && test "$(grep -c 'with_desktop_marker(&parsed)' src/lib.rs)" = "2" && grep -q 'update_labels(version_changed' src/lib.rs</automated>
</verify>
<done>Beide Navigationen tragen `desktop=1`, der Store-Wert nicht; Beta-Bau mit gleicher Version zeigt „Neuen Beta-Stand herunterladen“ / „Neuer Beta-Stand {commit} verfügbar …“, Versionswechsel weiterhin „Version {v} herunterladen“; fmt/check/clippy/test gruen; Commit `feat(desktop): Client meldet sich per desktop=1, Beta-Hinweis nennt den Stand`.</done>
</task>
<task type="tracer" tdd="true">
<name>Task 2: Web — Middleware-Cookie, `desktop-client.ts`, DesktopDownloadLinks im Client aus, Kontextmenue-Waechter, Tests</name>
<files>apps/web/src/middleware.ts, apps/web/src/middleware.test.ts, apps/web/src/lib/desktop-client.ts, apps/web/src/lib/desktop-client.test.ts, apps/web/src/components/desktop/desktop-download-links.tsx, apps/web/src/components/desktop/desktop-download-links.test.tsx, apps/web/src/components/desktop/desktop-context-menu-guard.tsx, apps/web/src/components/desktop/desktop-context-menu-guard.test.tsx, apps/web/src/app/layout.tsx</files>
<read_first>
- apps/web/src/middleware.ts — FRISCH lesen (Plan 260917-gyd hat sie vor diesem Plan geaendert: `next`-Rueckkehrparameter). Alle `return`-Anweisungen der `middleware`-Funktion zaehlen, wie sie JETZT sind.
- `ls apps/web/src/middleware.test.ts` — existiert sie bereits (aus 260917-gyd), wird sie um einen eigenen `describe`-Block erweitert statt neu angelegt.
- apps/web/src/components/desktop/desktop-download-links.test.tsx Z. 1-45 (next-intl-Mock, `vi.hoisted`-Mock fuer `@/lib/desktop`, `afterEach` mit cleanup)
- apps/web/src/lib/desktop.test.ts Z. 1-15 (Kopfkommentar-Stil fuer lib-Tests)
- apps/web/src/app/layout.tsx (Server Component; Client-Komponente darf importiert und gerendert werden)
</read_first>
<behavior>
middleware.test.ts (`// @vitest-environment node` als erste Zeile; `NextRequest` aus `next/server`, `SignJWT` aus `jose`; `vi.stubEnv('JWT_SECRET', 'test-secret')` in `beforeEach`, `vi.unstubAllEnvs()` in `afterEach`):
- Test 1: `new NextRequest('http://localhost:3000/login?desktop=1')` → `res.headers.get('set-cookie')` enthaelt `tessera_desktop=1`, `Path=/`, `Max-Age=31536000`, `SameSite=lax`; enthaelt NICHT `Secure`, NICHT `HttpOnly`.
- Test 2: `http://localhost:3000/login` ohne Parameter → `set-cookie` ist null.
- Test 3: `http://localhost:3000/dashboard?desktop=1` ohne Session-Cookie → `res.status` 307, `res.headers.get('location')` enthaelt `/login`, `set-cookie` enthaelt `tessera_desktop=1`.
- Test 4: `https://tessera.example.com/login?desktop=1` → `set-cookie` enthaelt `Secure`.
- Test 5: gueltiges JWT (`new SignJWT({ sub: 'u1' }).setProtectedHeader({ alg: 'HS256' }).setIssuedAt().setExpirationTime('5m').sign(new TextEncoder().encode('test-secret'))`) als Header `cookie: session=<token>` auf `http://localhost:3000/dashboard?desktop=1` → `set-cookie` enthaelt `tessera_desktop=1` und `res.headers.get('x-middleware-next')` ist `'1'`.
desktop-client.test.ts (jsdom; Cookie in `afterEach` per `document.cookie = 'tessera_desktop=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/'` loeschen):
- ohne Cookie → `isDesktopClient()` false
- `document.cookie = 'tessera_desktop=1; path=/'` → true
- `tessera_desktop=0` → false
- `vi.stubGlobal('document', undefined)` → false (danach `vi.unstubAllGlobals()`)
- Hook: Cookie gesetzt, `renderHook(() => useIsDesktopClient())` → `await waitFor(() => expect(result.current).toBe(true))`; ohne Cookie bleibt `result.current` false
desktop-download-links.test.tsx, neuer Test 4 („im Desktop-Client“): Cookie gesetzt, `loadDesktopLatest.mockResolvedValue({... files: { windows, linux } })`, `render`, `await act(async () => {})` → `expect(loadDesktopLatest).not.toHaveBeenCalled()`, `expect(container.firstChild).toBeNull()`. `afterEach` loescht das Cookie (Tests 1-3 laufen ohne Cookie weiter).
desktop-context-menu-guard.test.tsx (jsdom; Helfer `fire(el) = el.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))` — Rueckgabe false bedeutet preventDefault):
- mit Cookie, `render(<DesktopContextMenuGuard />)`, `await act(async () => {})`: `fire(div)` → false; `fire(input)` → true; `fire(textarea)` → true; `fire(select)` → true; `fire(span in div[contenteditable="true"])` → true
- ohne Cookie: `fire(div)` → true
- mit Cookie, nach `unmount()`: `fire(div)` → true
</behavior>
<action>
Tests aus `<behavior>` zuerst schreiben, rot sehen, dann implementieren:
1. **middleware.ts** — Konstante `DESKTOP_COOKIE = 'tessera_desktop'` und Helfer `withDesktopCookie(req: NextRequest, res: NextResponse): NextResponse` oberhalb von `middleware` anlegen: wenn `req.nextUrl.searchParams.get('desktop') === '1'`, `res.cookies.set(DESKTOP_COOKIE, '1', { path: '/', maxAge: 60 * 60 * 24 * 365, sameSite: 'lax', httpOnly: false, secure: req.nextUrl.protocol === 'https:' })`; immer `res` zurueckgeben. Kurzer Kommentar: Der Desktop-Client (apps/desktop/src-tauri/src/lib.rs, `with_desktop_marker`) haengt den Parameter nur an seine erste Navigation; das Cookie muss deshalb auf JEDER Antwort landen, auch auf dem Fruehausstieg fuer oeffentliche Routen und auf Redirects — sonst geht die Kennung beim 307 nach /login verloren. `httpOnly: false` ist Absicht (wird von `isDesktopClient()` in apps/web/src/lib/desktop-client.ts gelesen); der Wert ist kein Geheimnis. Danach JEDE `return`-Anweisung innerhalb von `middleware` (einschliesslich solcher, die 260917-gyd ergaenzt hat) in `withDesktopCookie(req, …)` einhuellen; beim Zweig mit `response.cookies.delete('session')` die bestehende Variable durchreichen. Keine Redirect-Ziele, keine Reihenfolge aendern, kein React-Import in der Middleware. Die Middleware-Datei bleibt ansonsten unberuehrt.
2. **apps/web/src/lib/desktop-client.ts** — exportiert `DESKTOP_COOKIE_NAME = 'tessera_desktop'`, `isDesktopClient()` (bei `typeof document === 'undefined'` false; sonst `document.cookie.split(';').some((c) => c.trim() === `${DESKTOP_COOKIE_NAME}=1`)`) und `useIsDesktopClient()` (`useState(false)`, `useEffect(() => { setIsDesktop(isDesktopClient()); }, [])`, Rueckgabe des Zustands). Kopfkommentar wie in `desktop.ts`: Gegenstueck zur Middleware; Hook statt Direktaufruf beim Render, weil Server-HTML und erster Client-Render sonst auseinanderlaufen (Hydration).
3. **desktop-download-links.tsx** — `const isDesktop = useIsDesktopClient();` nach `useState`; im Ladeeffekt als erste Zeile `if (isDesktopClient()) return;` (kein Request aus dem Client); nach allen Hooks `if (isDesktop) return null;` vor der bestehenden `!info`-Pruefung. Doc-Kommentar der Komponente um einen Satz ergaenzen (im Desktop-Client entfaellt der Block, Kennung ueber Cookie).
4. **desktop-context-menu-guard.tsx** — `'use client'`; `export function DesktopContextMenuGuard()`: `const isDesktop = useIsDesktopClient();` `useEffect` mit Abhaengigkeit `[isDesktop]`: wenn false, nichts; sonst Handler `(event: MouseEvent) => { const target = event.target; if (!(target instanceof Element)) return; if (target.closest(EDITABLE_SELECTOR) || (target as HTMLElement).isContentEditable === true) return; event.preventDefault(); }` mit `EDITABLE_SELECTOR = 'input, textarea, select, [contenteditable=""], [contenteditable="true"], [contenteditable="plaintext-only"]'`; `document.addEventListener('contextmenu', handler)`; Aufraeumfunktion entfernt ihn. Rueckgabe `null`. Kommentar: WebView2 zeigt sonst Zurueck/Aktualisieren/Speichern unter/Drucken; in Eingabefeldern bleibt Kopieren/Einfuegen erreichbar; jsdom kennt `isContentEditable` nicht, daher zusaetzlich der Selektor.
5. **layout.tsx** — `import { DesktopContextMenuGuard } from '@/components/desktop/desktop-context-menu-guard';` und `<DesktopContextMenuGuard />` unmittelbar vor `{children}` innerhalb von `NextIntlClientProvider` rendern.
6. Keine neuen i18n-Schluessel (nichts wird angezeigt). de.json/en.json nicht anfassen.
</action>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web exec vitest run src/middleware.test.ts src/lib/desktop-client.test.ts src/components/desktop && pnpm --filter @tessera/web exec vitest run && pnpm --filter @tessera/web type-check && grep -q "withDesktopCookie(req" apps/web/src/middleware.ts && grep -q "DesktopContextMenuGuard" apps/web/src/app/layout.tsx</automated>
</verify>
<done>Anfrage mit `?desktop=1` bekommt auf jeder Antwortart das Cookie; `isDesktopClient()`/`useIsDesktopClient()` lesen es; DesktopDownloadLinks rendert im Client nichts und laedt nichts; Rechtsklick ausserhalb von Eingabefeldern ist im Client unterdrueckt; alle Web-Tests (bisher 381 + neue) und type-check gruen; Commit `feat(web): Desktop-Client per Cookie erkennen — Download-Links und Browser-Kontextmenü in der App aus`.</done>
</task>
<task type="auto">
<name>Task 3: NSIS-Installer auf Deutsch mit Tessera-Grafik, CHANGELOG, Handbuchsaetze</name>
<files>apps/desktop/src-tauri/tauri.conf.json, apps/desktop/src-tauri/icons/nsis-header.bmp, apps/desktop/src-tauri/icons/nsis-sidebar.bmp, CHANGELOG.md, docs/anleitung-anwender.md, docs/anleitung-entwicklung.md</files>
<read_first>
- apps/desktop/src-tauri/tauri.conf.json (Block `bundle`, es gibt noch keinen `windows`-Schluessel)
- CHANGELOG.md Z. 1-35 (`## Unveröffentlicht`, Stil der Stichpunkte mit Praefix `Desktop-App:`) — FRISCH lesen, 260917-gyd hat Zeilen ergaenzt
- docs/anleitung-anwender.md Z. 176-197 („Installation unter Windows“, Tray-Menue-Liste)
- docs/anleitung-entwicklung.md Z. 162-166 (Absatz „Der Windows-Installer wird nur im CI gebaut“)
</read_first>
<action>
1. Die zwei fertigen Bilder kopieren: `cp /tmp/claude-1000/-home-vicolab-projects-tessera-ctl/36238f40-3162-4b4a-9c11-56905a933eef/scratchpad/nsis-img/nsis-header.bmp /tmp/claude-1000/-home-vicolab-projects-tessera-ctl/36238f40-3162-4b4a-9c11-56905a933eef/scratchpad/nsis-img/nsis-sidebar.bmp apps/desktop/src-tauri/icons/` (nur die beiden .bmp, nicht die -preview.png). Sollte der Scratchpad-Ordner fehlen, STOPP und an den Orchestrator melden — nicht selbst neue Bilder erzeugen.
2. In tauri.conf.json unter `bundle` (nach `icon`) den Block `"windows": { "nsis": { "languages": ["German"], "displayLanguageSelector": false, "installerIcon": "icons/icon.ico", "headerImage": "icons/nsis-header.bmp", "sidebarImage": "icons/nsis-sidebar.bmp", "installMode": "currentUser" } }` ergaenzen (Pfade relativ zu src-tauri wie die `icon`-Liste; 2-Leerzeichen-Einrueckung wie im Bestand). Keine weiteren Schluessel (kein `template`, kein `installerHooks`).
3. CHANGELOG.md, `## Unveröffentlicht`, jeweils als NEUE Zeilen am Ende der Liste: unter `### Geändert` „Desktop-App: Hinweis auf einen neuen Beta-Stand nennt den Stand (Commit-Kürzel) statt der unveränderten Versionsnummer“ und „Desktop-App: Windows-Installer auf Deutsch mit Tessera-Grafik und -Symbol“; unter `### Behoben` „Desktop-App: Download-Links auf der Anmeldeseite werden in der App nicht mehr angeboten“ und „Desktop-App: Rechtsklick zeigte das Browser-Kontextmenü (Zurück, Aktualisieren, Drucken …) – in der App ausgeblendet, in Eingabefeldern bleibt es erhalten“. Bestehende Zeilen (auch neue aus 260917-gyd) bleiben unveraendert.
4. docs/anleitung-anwender.md: Im Absatz „Installation unter Windows“ (Z. 178) nach dem Satz zu „Trotzdem ausführen“ ergaenzen: „Der Installationsassistent führt auf Deutsch durch die Installation; sie erfolgt für den angemeldeten Benutzer und benötigt keine Administratorrechte.“ In der Tray-Menue-Liste (Z. 193) hinter **Update herunterladen** ergaenzen: „ — wird aktiv, sobald eine neue Version vorliegt (auf dem Beta-Kanal: „Neuen Beta-Stand herunterladen“)“. Sie-Form, typografische Anfuehrungszeichen wie im Bestand.
5. docs/anleitung-entwicklung.md: Im Absatz Z. 162-166 einen Satz anfuegen: Sprache, Symbol und Bilder des Installers stehen in `apps/desktop/src-tauri/tauri.conf.json` unter `bundle.windows.nsis` (Deutsch ohne Sprachauswahl, `icons/nsis-header.bmp` 150×57 und `icons/nsis-sidebar.bmp` 164×314 als 24-Bit-BMP); Aenderungen daran lassen sich nur ueber den CI-Bau auf einem Windows-Rechner pruefen, lokal validiert `cargo check` lediglich die Schluessel.
6. Gate laut `<verify>`: Schema-Pruefung per node gegen `NsisConfig.properties` und `NSISInstallerMode` des lokalen CLI-Schemas, Existenz und Format der BMPs, `cargo check` (tauri-codegen parst die Konfiguration mit `deny_unknown_fields`). Kein `tauri build`, kein Docker.
</action>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl && node -e 'const fs=require("fs"),p=require("path");const s=require("./apps/desktop/node_modules/@tauri-apps/cli/config.schema.json");const c=JSON.parse(fs.readFileSync("apps/desktop/src-tauri/tauri.conf.json","utf8"));const n=c.bundle.windows.nsis;const allowed=Object.keys(s.definitions.NsisConfig.properties);const bad=Object.keys(n).filter(k=>!allowed.includes(k));if(bad.length){console.error("unbekannte Schluessel:",bad);process.exit(1)}const modes=s.definitions.NSISInstallerMode.oneOf.map(o=>o.enum[0]);if(!modes.includes(n.installMode)){console.error("installMode ungueltig");process.exit(1)}if(JSON.stringify(n.languages)!==JSON.stringify(["German"])||n.displayLanguageSelector!==false){console.error("languages/displayLanguageSelector");process.exit(1)}for(const k of["installerIcon","headerImage","sidebarImage"]){const f=p.join("apps/desktop/src-tauri",n[k]);if(!fs.existsSync(f)){console.error("fehlt:",f);process.exit(1)}}console.log("nsis-Block OK")' && magick identify -format '%f %m %wx%h\n' apps/desktop/src-tauri/icons/nsis-header.bmp apps/desktop/src-tauri/icons/nsis-sidebar.bmp | grep -q 'nsis-header.bmp BMP3 150x57' && magick identify -format '%f %m %wx%h\n' apps/desktop/src-tauri/icons/nsis-sidebar.bmp | grep -q 'BMP3 164x314' && (cd apps/desktop/src-tauri && cargo check) && test "$(grep -c '^- Desktop-App: ' CHANGELOG.md)" -ge 7 && grep -q 'bundle.windows.nsis' docs/anleitung-entwicklung.md && grep -q 'Installationsassistent' docs/anleitung-anwender.md</automated>
</verify>
<done>nsis-Block mit den sechs Schluesseln steht in tauri.conf.json und besteht Schema- und codegen-Pruefung; beide BMPs liegen in icons/; CHANGELOG traegt vier neue Stichpunkte; beide Handbuecher nennen den deutschen Installer bzw. den Ort der Konfiguration; Commit `feat(desktop): Windows-Installer auf Deutsch mit Tessera-Grafik und -Symbol; CHANGELOG, Handbuch`. Der eigentliche Nachweis (deutscher Dialog, Kopf-/Seitenbild, Symbol im Downloads-Fenster, Cookie im echten Client) folgt durch den Orchestrator nach dem CI-Bau auf der Windows-VM — im SUMMARY als offen fuehren.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Browser/WebView → Middleware | Query-Parameter `desktop=1` und Cookie `tessera_desktop` sind frei setzbar (jeder Browser kann sie senden) |
| Rust-Client → gespeicherte Server-Adresse | Nutzer-Eingabe wird als URL geparst und um ein Query-Paar erweitert |
| Installer-Konfiguration → NSIS-Bundler im CI | BMP/ICO-Dateien aus dem Repo werden in den Installer eingebettet |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-H2S-01 | Spoofing | `withDesktopCookie` / `isDesktopClient` | low | accept | Das Cookie steuert ausschliesslich Kosmetik (Download-Links, Kontextmenue). Kein Auth-, Rechte- oder Datenpfad haengt daran; ein manuell gesetztes Cookie im Browser blendet nur Links aus. Middleware-Reihenfolge (Session-Pruefung, Redirects) bleibt unveraendert. |
| T-H2S-02 | Information Disclosure | Cookie `tessera_desktop` (httpOnly false) | low | accept | Wert ist die Konstante `1`, kein Geheimnis; `sameSite: lax`, `secure` bei https. Bewusst per JavaScript lesbar, weil der Web-Helfer es braucht. |
| T-H2S-03 | Tampering | `with_desktop_marker` (Rust) | low | mitigate | `query_pairs_mut().append_pair` kodiert korrekt; die Nutzer-URL wurde zuvor von `tauri::Url::parse` validiert (Schema http/https in `check_server`). Der Store haelt weiterhin die unveraenderte Adresse. Unit-Test belegt Klon statt In-Place. |
| T-H2S-04 | Denial of Service | `DesktopContextMenuGuard` | low | mitigate | Listener nur im Desktop-Client aktiv; Ausnahme fuer Eingabefelder/contenteditable per Selektor UND `isContentEditable`, damit Kopieren/Einfuegen erreichbar bleibt; Aufraeumfunktion entfernt den Listener (Test). |
| T-H2S-05 | Tampering | `icons/nsis-*.bmp`, `bundle.windows.nsis` | low | accept | Bilder stammen aus dem eigenen, per resvg gerenderten Repo-Icon; Format per `magick identify` belegt; Schluessel gegen das lokale CLI-Schema und per `cargo check` (deny_unknown_fields) geprueft. |
| T-H2S-SC | Tampering | npm/pip/cargo installs | low | accept | Keine neue Abhaengigkeit in diesem Plan (kein `pnpm add`, kein `cargo add`); package-legitimacy gate entfaellt. |
</threat_model>
<verification>
- Rust: `cargo fmt --check && cargo check && cargo clippy && cargo test --lib` in apps/desktop/src-tauri gruen; beide `window.navigate`-Aufrufe nutzen `with_desktop_marker(&parsed)`.
- Web: `pnpm --filter @tessera/web exec vitest run` (alle Dateien, bisher 57/381 plus die neuen) und `pnpm --filter @tessera/web type-check` gruen.
- Middleware-Test belegt das Cookie auf Fruehausstieg, Redirect ohne Session, `next()` nach gueltigem JWT, `Secure` nur bei https.
- tauri.conf.json: nsis-Block besteht Schema-Pruefung (node) und `cargo check`; BMPs vorhanden mit BMP3 150x57 / 164x314.
- CHANGELOG: vier neue `Desktop-App:`-Stichpunkte; Handbuecher ergaenzt.
- Offen (nicht lokal pruefbar, Orchestrator auf der Windows-VM nach CI-Bau): Cookie im echten Client gesetzt, keine Download-Links auf der Anmeldeseite im Client, kein Browser-Kontextmenue, Beta-Label mit Commit, deutscher Installer mit Tessera-Grafik und -Symbol.
</verification>
<success_criteria>
- Alle `must_haves.truths` erfuellt; drei Commits ohne Push, ohne Docker-Build, ohne `tauri build`.
- Keine Datei ausserhalb von `files_modified` + `.planning/` veraendert (`git status` vor dem letzten Commit gegenpruefen).
- SUMMARY nennt die offenen VM-Nachweise ausdruecklich.
</success_criteria>
<output>
Create `/home/vicolab/projects/tessera-ctl/.planning/quick/260917-h2s-desktop-client-web-erkennt-den-client-do/260917-h2s-SUMMARY.md` when done
</output>