23 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, user_setup, estimate, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | user_setup | estimate | must_haves | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 18-desktop-client-fertigstellen | 03 | execute | 2 |
|
|
true |
|
|
|
Purpose: D-12 aus 18-CONTEXT.md (Web-Oberflaeche) und Erfolgskriterium 2. Output: Fetch-Helfer, zwei Komponenten mit Tests, neue Einstellungsroute, Seitenleisteneintrag, Uebersetzungen de/en.
Alle Adressen werden aus API_URL gebildet (NEXT_PUBLIC_API_URL, im
Betrieb /api-proxy); es wird nirgends eine feste Server- oder
Firmenadresse eingetragen.
Artifacts this phase produces
Dieser Plan: apps/web/src/lib/desktop.ts (DesktopPlatform,
DesktopFileInfo, DesktopLatestInfo, loadDesktopLatest,
desktopDownloadUrl, formatFileSize), desktop.test.ts,
components/desktop/desktop-download-links.tsx (DesktopDownloadLinks),
desktop-download-links.test.tsx, app/(auth)/login/page.tsx (Einbau),
app/(portal)/settings/general/desktop/page.tsx (DesktopSettingsPage),
components/settings/desktop-app-settings.tsx (DesktopAppSettings),
desktop-app-settings.test.tsx, components/settings/settings-sidebar.tsx
(Eintrag), messages/de.json und messages/en.json (auth.desktopDownload.*,
settings.categoryDesktopApp, settings.desktop.*). Gesamtliste der Phase:
siehe 18-01-PLAN.md.
<execution_context> @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md </execution_context>
@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/18-desktop-client-fertigstellen/18-CONTEXT.md @.planning/phases/18-desktop-client-fertigstellen/18-PATTERNS.md @.planning/phases/18-desktop-client-fertigstellen/18-01-SUMMARY.md@apps/web/src/lib/app-version.ts @apps/web/src/lib/app-version.test.ts @apps/web/src/components/layout/app-version-badge.tsx @apps/web/src/app/(auth)/login/page.tsx @apps/web/src/app/(portal)/settings/general/account/page.tsx @apps/web/src/components/settings/settings-sidebar.tsx @apps/web/src/components/settings/widget-settings-panel.test.tsx
Task 1: Fetch-Helfer und der Download-Link auf der Anmeldeseite apps/web/src/lib/desktop.ts, apps/web/src/lib/desktop.test.ts, apps/web/src/components/desktop/desktop-download-links.tsx, apps/web/src/components/desktop/desktop-download-links.test.tsx, apps/web/src/app/(auth)/login/page.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json apps/web/src/lib/app-version.ts (gesamt — Muster fuer API_URL und memoisiertes Laden), apps/web/src/lib/app-version.test.ts (gesamt — vi.resetModules + dynamischer Import), apps/web/src/components/layout/app-version-badge.tsx (useEffect/useState-Konsum), apps/web/src/app/(auth)/login/page.tsx (Einbaustelle nach dem Formular), apps/web/src/components/settings/widget-settings-panel.test.tsx (Zeilen 1-30, next-intl-Mock mit de.json), apps/web/src/messages/de.json (Namensraum `auth`), .planning/phases/18-desktop-client-fertigstellen/18-RESEARCH.md (Code Example 8) - `loadDesktopLatest()` ruft `${API_URL}/desktop/latest` genau einmal je Modulinstanz auf (zweiter Aufruf liefert dasselbe Promise); `ok=false` und Netzfehler liefern `null`, nichts wird geworfen. - `desktopDownloadUrl(file)` ergibt `${API_URL}${file.url}` (z. B. `http://localhost:3001/desktop/download/windows` in Tests). - `formatFileSize(6123456, 'de')` ergibt `5,8 MB`; `formatFileSize(6123456, 'en')` ergibt `5.8 MB`; `formatFileSize(106461688, 'de')` ergibt `101,5 MB`. - `DesktopDownloadLinks` rendert nichts, solange nichts geladen ist oder `null` kam; mit Daten fuer beide Plattformen erscheinen ein Link "Desktop-App herunterladen (Windows)" (href = Windows-URL, Attribut `download`) und ein Link "Linux-Version" sowie der Text "Version 1.1.0". - Fehlt `files.windows` (Stand nach 18-01, nur Linux gebaut), erscheint genau ein Link mit dem Text "Desktop-App herunterladen (Linux)" und der Versionstext. **`apps/web/src/lib/desktop.ts`** nach dem Vorbild `app-version.ts` (gleicher `API_URL`-Ausdruck mit woertlichem `process.env.NEXT_PUBLIC_API_URL`, deutscher Kopfkommentar mit Verweis auf D-10/D-12 und auf den Rewrite `/api-proxy`). Typen als Spiegel der API (kein Import aus `@tessera/shared`, gleiche Begruendung wie im Kommentar von `app-version.ts`): `DesktopPlatform = 'windows' | 'linux'`, `DesktopFileInfo { name; size; sha256; url }`, `DesktopLatestInfo { version; channel; commit; buildTime; files: Partial> }`. `loadDesktopLatest()` memoisiert wie `loadApiVersion()`, aber **ohne** `credentials: 'include'` (oeffentlicher Endpunkt, Anmeldeseite hat noch kein Cookie). `desktopDownloadUrl(file)` und `formatFileSize(bytes, locale)` (`Intl.NumberFormat(locale, { maximumFractionDigits: 1 })` auf `bytes / 1048576`, Suffix ` MB`).desktop.test.ts im Stil von app-version.test.ts (importFresh mit
vi.resetModules, vi.stubGlobal('fetch', …)): Test 1 memoisiert (ein
Fetch, zwei gleiche Ergebnisse, Aufruf-URL endet auf /desktop/latest,
kein credentials-Feld in den Optionen); Test 2 still bei ok=false; Test 3
still bei Netzfehler; Test 4 desktopDownloadUrl; Test 5 die drei
formatFileSize-Faelle aus <behavior> — Erwartungen von Hand.
components/desktop/desktop-download-links.tsx ('use client',
useTranslations('auth'), useLocale() aus next-intl): useEffect laedt
loadDesktopLatest() mit active-Schutz wie AppVersionBadge; State
DesktopLatestInfo | null. Rendert null, wenn keine Daten oder keine
Plattform in files. Sonst ein <div className="text-center text-sm text-muted-foreground">
mit: Hauptlink (Windows, falls vorhanden, sonst Linux) als <a href={desktopDownloadUrl(file)} download className="hover:text-foreground underline-offset-4 hover:underline">
mit Text t('desktopDownload.windows') bzw. t('desktopDownload.linux');
ist Windows vorhanden und Linux vorhanden, dahinter · und ein
zweiter Link t('desktopDownload.linuxShort'); darunter in text-xs
t('desktopDownload.version', { version }). Keine Fehlermeldung, kein
Spinner — der Block ist unauffaellig (D-12).
desktop-download-links.test.tsx: next-intl-Mock nach dem Muster in
widget-settings-panel.test.tsx (de.json-gestuetzt, zusaetzlich
useLocale: () => 'de'), vi.mock('@/lib/desktop', …) mit steuerbarem
loadDesktopLatest (echte desktopDownloadUrl/formatFileSize per
importOriginal durchreichen). Faelle: (1) null -> Container leer
(container.firstChild ist null); (2) beide Plattformen -> zwei Links mit
den deutschen Texten aus de.json und hrefs …/desktop/download/windows bzw.
…/desktop/download/linux, Text Version 1.1.0; (3) nur Linux -> genau ein
Link mit dem Linux-Text. findBy… fuer die asynchrone Aufloesung.
Anmeldeseite ((auth)/login/page.tsx): Import der Komponente; direkt
nach dem schliessenden </form> innerhalb des max-w-sm space-y-8-Blocks
<DesktopDownloadLinks /> einfuegen. Sonst nichts aendern.
Uebersetzungen in de.json unter auth neuer Block desktopDownload:
windows = "Desktop-App herunterladen (Windows)", linux = "Desktop-App
herunterladen (Linux)", linuxShort = "Linux-Version", version =
"Version {version}". In en.json 1:1: "Download desktop app (Windows)",
"Download desktop app (Linux)", "Linux version", "Version {version}".
<acceptance_criteria>
- pnpm --filter @tessera/web exec vitest run src/lib/desktop.test.ts src/components/desktop meldet 8 Tests bestanden, 0 fehlgeschlagen.
- grep -v '^\s*//' apps/web/src/lib/desktop.ts | grep -c 'process.env.NEXT_PUBLIC_API_URL' ergibt 1.
- grep -c 'DesktopDownloadLinks' "apps/web/src/app/(auth)/login/page.tsx" ergibt 2 (Import und Einbau).
- node -e "const de=require('./apps/web/src/messages/de.json');if(de.auth.desktopDownload.windows!=='Desktop-App herunterladen (Windows)')process.exit(1)" endet mit 0.
- pnpm --filter @tessera/web type-check fehlerfrei.
</acceptance_criteria>
cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web exec vitest run src/lib/desktop.test.ts src/components/desktop && pnpm --filter @tessera/web type-check
<fails_when>vitest meldet "failed" oder Exit-Code ungleich 0, oder tsc gibt Fehlerzeilen aus.</fails_when>
Acht Tests gruen, Typpruefung fehlerfrei, die Anmeldeseite baut den
Link-Block ein, de/en tragen den Block auth.desktopDownload.
` mit `t('desktop.title')`), Inhalt ``. Kein Anlegen weiterer Layout-Dateien — die Route liegt unter dem bestehenden `settings`-Layout mit Seitenleiste.
Komponente components/settings/desktop-app-settings.tsx
('use client', useTranslations('settings'), useLocale()): laedt
loadDesktopLatest() wie in Task 1 (State undefined = laedt, null =
nicht verfuegbar, Objekt = Daten). Aufbau: Absatz mit den vier Saetzen
t('desktop.intro'), t('desktop.firstStart'), t('desktop.tray'),
t('desktop.update') (ein <p> je Satz, text-sm text-muted-foreground);
dann bei Daten: <p> mit t('desktop.versionLabel', { version }) und, wenn
channel === 'beta', t('desktop.channelBeta', { commit }); dann ein
<div className="flex flex-wrap gap-4"> mit je Plattform (nur vorhandene,
Reihenfolge Windows, Linux) einem Block aus <a href download> im
Primaerknopf-Stil der Anmeldeseite (inline-flex items-center gap-2 rounded-md bg-primary px-4 py-2.5 text-sm font-medium text-primary-foreground hover:opacity-90)
mit inline-SVG-Symbol (Windows: vier abgerundete Felder im 2x2-Raster;
Linux: Terminalfenster mit >_-Prompt — beide 16x16, aria-hidden) und
Text t('desktop.downloadWindows') bzw. t('desktop.downloadLinux'),
darunter <p className="mt-1 text-xs text-muted-foreground"> mit
t('desktop.fileInfo', { name, size: formatFileSize(size, locale) }). Bei
null: <p> mit t('desktop.unavailable') statt Knoepfen. Waehrend des
Ladens nichts unterhalb der Saetze. data-testid="desktop-download-windows"
und desktop-download-linux an den Links.
Seitenleiste settings-sidebar.tsx: im <nav> unter "Allgemein" hinter
dem Konto-Link einen zweiten <Link href="/settings/general/desktop"> mit
identischem Klassen-/aria-current-Muster und t('categoryDesktopApp');
isActive bleibt unveraendert (startsWith deckt die Route ab).
Uebersetzungen de.json settings: categoryDesktopApp = "Desktop-App";
Block desktop: title = "Desktop-App", intro = "Die Desktop-App öffnet
Tessera in einem eigenen Fenster – ohne Browser, mit Symbol im Infobereich der
Taskleiste.", firstStart = "Beim ersten Start fragt die App nach der Adresse
Ihres Tessera-Servers; das ist die Adresse, unter der Sie Tessera auch im
Browser öffnen.", tray = "Schließen Sie das Fenster, läuft Tessera im
Infobereich weiter; über das Symbol dort öffnen Sie das Fenster wieder,
schalten den automatischen Start ein oder beenden die App.", update =
"Erscheint eine neuere Version, weist die App Sie darauf hin und führt Sie auf
diese Seite.", versionLabel = "Aktuelle Version: {version}", channelBeta
= "Beta-Ausgabe, Stand {commit}", downloadWindows = "Für Windows
herunterladen", downloadLinux = "Für Linux herunterladen", fileInfo =
"{name} · {size}", unavailable = "Auf diesem Server sind derzeit keine
Desktop-Pakete hinterlegt.". en.json 1:1 sinngemaess ("Desktop app",
"The desktop app opens Tessera in its own window – no browser, with an icon
in the notification area of the taskbar.", "On first start the app asks for
the address of your Tessera server; it is the address you also use to open
Tessera in the browser.", "If you close the window, Tessera keeps running in
the notification area; use the icon there to reopen the window, enable
automatic start, or quit the app.", "When a newer version is available the
app notifies you and brings you to this page.", "Current version: {version}",
"Beta build, commit {commit}", "Download for Windows", "Download for Linux",
"{name} · {size}", "No desktop packages are available on this server yet.").
Test desktop-app-settings.test.tsx: next-intl-Mock wie in Task 1
(Namensraum settings, useLocale: () => 'de'), @/lib/desktop gemockt.
Faelle: (1) beide Plattformen -> Text "Aktuelle Version: 1.1.0", zwei Links
mit den Testids, hrefs …/desktop/download/windows und …/desktop/download/linux,
Zeile "Tessera-Setup-1.1.0.exe · 5,8 MB" (Groesse 6123456) und
"Tessera-1.1.0.AppImage · 101,5 MB" (Groesse 106461688); (2) Kanal beta,
Commit abc1234 -> "Beta-Ausgabe, Stand abc1234"; (3) null -> Hinweistext
sichtbar, keine Links (queryByTestId beide null), die vier Saetze
weiterhin da (mindestens intro per Text geprueft).
<acceptance_criteria>
- pnpm --filter @tessera/web exec vitest run src/components/settings/desktop-app-settings.test.tsx meldet 3 Tests bestanden, 0 fehlgeschlagen.
- test -f "apps/web/src/app/(portal)/settings/general/desktop/page.tsx" endet mit 0; grep -c 'DesktopAppSettings' "apps/web/src/app/(portal)/settings/general/desktop/page.tsx" ergibt 2.
- grep -c 'href="/settings/general/desktop"' apps/web/src/components/settings/settings-sidebar.tsx ergibt 1.
- Parität und Umlaute: das node-Skript aus <verify> gibt i18n OK aus.
- pnpm --filter @tessera/web exec vitest run — gesamte Web-Suite gruen (Basis am 2026-09-16: 52 Dateien / 354 Tests plus die neuen).
</acceptance_criteria>
cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web exec vitest run src/components/settings/desktop-app-settings.test.tsx src/components/desktop src/lib/desktop.test.ts && pnpm --filter @tessera/web type-check
<fails_when>vitest meldet "failed" oder Exit-Code ungleich 0, oder tsc gibt Fehlerzeilen aus.</fails_when>
cd /home/vicolab/projects/tessera-ctl && node -e "const de=require('./apps/web/src/messages/de.json'),en=require('./apps/web/src/messages/en.json');const walk=(o,p='')=>Object.entries(o).flatMap(([k,v])=>typeof v==='object'&&v?walk(v,p+k+'.'):[p+k]);for(const ns of ['auth','settings']){const d=walk(de[ns]),e=walk(en[ns]);const miss=d.filter(k=>!e.includes(k)).concat(e.filter(k=>!d.includes(k)));if(miss.length){console.error('Fehlende Uebersetzungen in '+ns+':',miss);process.exit(1)}}const vals=o=>Object.values(o).flatMap(v=>typeof v==='object'&&v?vals(v):[String(v)]);const bad=vals({a:de.auth.desktopDownload,b:de.settings.desktop,c:{k:de.settings.categoryDesktopApp}}).filter(s=>/\b(fuer|ueber|koennen|Groesse|verfuegbar|oeffnen|schliessen|Oeffnen|Schliessen|laeuft|fuehrt)\b/i.test(s));if(bad.length){console.error('ASCII-Umschrift statt Umlaut:',bad);process.exit(1)}console.log('i18n OK')"
<fails_when>Ausgabe Fehlende Uebersetzungen (Schluessel nur in einer Sprache) oder ASCII-Umschrift statt Umlaut (deutscher Text mit ae/oe/ue-Umschrift) und Exit 1; i18n OK fehlt.</fails_when>
cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web exec vitest run
<fails_when>Irgendeine Datei der Web-Suite meldet "failed" — dann hat die Aenderung an de.json/en.json oder an der Seitenleiste bestehende Tests gebrochen.</fails_when>
Die Einstellungsseite existiert mit Knoepfen, Groesse, Saetzen und
Hinweisfall, der Seitenleisteneintrag zeigt darauf, drei neue Tests gruen,
die gesamte Web-Suite gruen, de/en vollstaendig und mit Umlauten.
<threat_model>
Trust Boundaries
| Boundary | Description |
|---|---|
Browser -> API (/desktop/latest, /desktop/download/:platform) |
Oeffentliche Endpunkte; die Web-Oberflaeche rendert nur, was die API liefert. |
API-Antwort -> DOM (href, Dateiname, Groesse) |
Werte aus dem Manifest landen als Linkziel und Text in der Seite. |
STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|---|---|---|---|---|---|
| T-18-07 | Tampering | desktopDownloadUrl (Linkziel aus API-Daten) |
low | mitigate | Das Linkziel wird aus API_URL plus dem relativen url-Feld gebaut; die Komponenten uebernehmen nie eine absolute Adresse aus der Antwort, ein manipuliertes Manifest kann den Download also nicht auf einen fremden Host lenken. |
| T-18-08 | Spoofing | Dateiname/Version als Text | low | accept | React rendert Text escaped; die Werte stammen aus dem vom CI geschriebenen Manifest (T-18-03 in 18-01). |
| T-18-09 | Information Disclosure | Anmeldeseite zeigt Version vor der Anmeldung | low | accept | Beabsichtigt (D-12); gleiche Abwaegung wie T-18-05. |
| T-18-SC | Tampering | Paketinstallationen | low | accept | Dieser Plan installiert kein neues Paket. |
| </threat_model> |
<success_criteria>
- Anmeldeseite: Link "Desktop-App herunterladen (Windows)" plus Linux-Link und Version, nur wenn die API antwortet.
- Einstellungen -> Allgemein -> Desktop-App: Version, zwei Primaerknoepfe mit Symbol, Dateiname und Groesse, vier erklaerende Saetze, Hinweis bei fehlenden Paketen.
- Alle Downloads laufen ueber die Tessera-API.
- de/en vollstaendig, deutsche Texte mit Umlauten und in Sie-Form. </success_criteria>