Files

23 KiB
Raw Permalink Blame History

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
18-01
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/app/(portal)/settings/general/desktop/page.tsx
apps/web/src/components/settings/desktop-app-settings.tsx
apps/web/src/components/settings/desktop-app-settings.test.tsx
apps/web/src/components/settings/settings-sidebar.tsx
apps/web/src/messages/de.json
apps/web/src/messages/en.json
true
DESK-03
tokens raw_tokens tasks confidence
80000 80000 2 low
truths artifacts key_links
Auf der Anmeldeseite steht unterhalb des Formulars ein unauffaelliger Link 'Desktop-App herunterladen (Windows)' mit kleinem Linux-Link und Versionsangabe — nur wenn /desktop/latest antwortet (D-12).
Unter Einstellungen -> Allgemein -> Desktop-App gibt es eine Seite mit Version, zwei Download-Knoepfen in Primaerfarbe mit Plattform-Symbol, Dateiname und Dateigroesse sowie vier Saetzen in Sie-Form; antwortet die API mit 404, erscheint statt der Knoepfe ein Hinweis (D-12).
Jeder Download laeuft ueber die Tessera-API (API_URL + url aus /desktop/latest); Anwender brauchen keinen Gitea-Zugang (D-01, D-10).
Alle neuen Texte liegen 1:1 in de.json und en.json vor, deutsche Texte mit echten Umlauten (Projektkonvention).
path provides exports
apps/web/src/lib/desktop.ts loadDesktopLatest (memoisiert, still bei Fehler), desktopDownloadUrl, formatFileSize
loadDesktopLatest
desktopDownloadUrl
formatFileSize
path provides exports
apps/web/src/components/desktop/desktop-download-links.tsx Link-Block der Anmeldeseite, rendert nichts ohne Daten
DesktopDownloadLinks
path provides exports
apps/web/src/components/settings/desktop-app-settings.tsx Inhalt der Einstellungsseite: Version, Knoepfe, Groesse, Saetze, Hinweis
DesktopAppSettings
path provides contains
apps/web/src/app/(portal)/settings/general/desktop/page.tsx Route /settings/general/desktop DesktopAppSettings
path provides contains
apps/web/src/messages/de.json auth.desktopDownload.*, settings.categoryDesktopApp, settings.desktop.* desktopDownload
from to via pattern
apps/web/src/lib/desktop.ts apps/api/src/desktop/desktop.controller.ts fetch(`${API_URL}/desktop/latest`) — im Betrieb ueber den Rewrite /api-proxy desktop/latest
from to via pattern
apps/web/src/components/desktop/desktop-download-links.tsx apps/web/src/lib/desktop.ts loadDesktopLatest() in useEffect; null blendet den Block aus loadDesktopLatest
from to via pattern
apps/web/src/components/settings/settings-sidebar.tsx apps/web/src/app/(portal)/settings/general/desktop/page.tsx Link href=/settings/general/desktop unter 'Allgemein' settings/general/desktop
Anwender sehen die Desktop-App in Tessera selbst: ein Link auf der Anmeldeseite und eine eigene Einstellungsseite "Desktop-App" mit Version, Download-Knoepfen fuer Windows und Linux, Dateigroesse und einer kurzen Erklaerung. Beides liest `GET /desktop/latest` aus 18-01 und blendet sich aus, wenn der Server keine Pakete traegt.

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.

Task 2: Einstellungsseite "Desktop-App" mit Knoepfen, Groesse und Erklaerung apps/web/src/app/(portal)/settings/general/desktop/page.tsx, apps/web/src/components/settings/desktop-app-settings.tsx, apps/web/src/components/settings/desktop-app-settings.test.tsx, apps/web/src/components/settings/settings-sidebar.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json apps/web/src/app/(portal)/settings/general/account/page.tsx (Seitenhuelle), apps/web/src/components/settings/settings-sidebar.tsx (Eintrag "Konto" unter "Allgemein"), apps/web/src/components/settings/widget-settings-panel.test.tsx (Zeilen 1-30), apps/web/src/app/(auth)/login/page.tsx (Klassen des Primaerknopfs: `rounded-md bg-primary px-4 py-2.5 text-sm font-medium text-primary-foreground hover:opacity-90`), apps/web/src/lib/desktop.ts (aus Task 1), apps/web/src/messages/de.json (Namensraum `settings`, Block `account`) - Route `/settings/general/desktop` rendert die Ueberschrift "Desktop-App" und die Komponente `DesktopAppSettings`. - Mit Daten fuer beide Plattformen zeigt die Seite "Aktuelle Version: 1.1.0", zwei Knoepfe "Für Windows herunterladen" und "Für Linux herunterladen" (Primaerfarbe, jeweils mit Plattform-Symbol als inline-SVG, `href` aus `desktopDownloadUrl`, Attribut `download`) und darunter je Knopf die Zeile "{Dateiname} · {Groesse}", z. B. "Tessera-Setup-1.1.0.exe · 5,8 MB". - Auf dem Beta-Kanal steht zusaetzlich "Beta-Ausgabe, Stand {commit}". - Vier Saetze in Sie-Form erklaeren, was die App ist, den Erststart mit Server-Adresse, das Verhalten im Infobereich und den Update-Hinweis. - Antwortet die API mit null, erscheinen statt der Knoepfe der Satz "Auf diesem Server sind derzeit keine Desktop-Pakete hinterlegt." und die vier Saetze bleiben stehen. - Die Seitenleiste zeigt unter "Allgemein" den Eintrag "Desktop-App" mit `aria-current="page"` auf der Route. **Seite `app/(portal)/settings/general/desktop/page.tsx`**: exakt die Huelle von `account/page.tsx` (`'use client'`, `useTranslations('settings')`, `

` 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>
1. `pnpm --filter @tessera/web exec vitest run` — gesamte Web-Suite gruen. 2. `pnpm --filter @tessera/web type-check` — fehlerfrei. 3. i18n-Paritaets- und Umlautpruefung gibt `i18n OK` aus. 4. Browser-Gegenprobe am Phasenende (18-06): Link auf der Anmeldeseite, Seite unter Einstellungen -> Allgemein -> Desktop-App, Download startet.

<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>
Create `.planning/phases/18-desktop-client-fertigstellen/18-03-SUMMARY.md` when done.