+
+
{t('desktopDownload.version', { version: info.version })}
+
+ );
+}
diff --git a/apps/web/src/lib/desktop.test.ts b/apps/web/src/lib/desktop.test.ts
new file mode 100644
index 0000000..f68af9f
--- /dev/null
+++ b/apps/web/src/lib/desktop.test.ts
@@ -0,0 +1,72 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+/**
+ * desktop.test — Desktop-Paket-Info fuer die Web-Oberflaeche (Phase 18-03).
+ *
+ * `loadDesktopLatest()` memoisiert die Antwort von `GET /desktop/latest` —
+ * deshalb setzt jeder Test die Module zurueck und importiert dynamisch,
+ * nachdem `fetch` gestubbt ist (Muster: app-version.test.ts).
+ */
+
+afterEach(() => {
+ vi.unstubAllEnvs();
+ vi.unstubAllGlobals();
+});
+
+async function importFresh() {
+ vi.resetModules();
+ return import('./desktop');
+}
+
+describe('desktop (18-03)', () => {
+ it('Test 1 (Laden, memoisiert): zwei Aufrufe liefern dasselbe Objekt, fetch laeuft genau einmal ohne credentials', async () => {
+ const payload = {
+ version: '1.1.0',
+ channel: 'beta',
+ commit: 'abc1234',
+ buildTime: 'x',
+ files: {
+ windows: { name: 'Tessera-Setup-1.1.0.exe', size: 6123456, sha256: 'x', url: '/desktop/download/windows' },
+ },
+ };
+ const fetchMock = vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve(payload) }));
+ vi.stubGlobal('fetch', fetchMock);
+ const mod = await importFresh();
+
+ const first = await mod.loadDesktopLatest();
+ const second = await mod.loadDesktopLatest();
+
+ expect(first).toEqual(payload);
+ expect(second).toEqual(payload);
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ const [url, options] = fetchMock.mock.calls[0] as unknown as [string, RequestInit | undefined];
+ expect(url.endsWith('/desktop/latest')).toBe(true);
+ expect(options?.credentials).toBeUndefined();
+ });
+
+ it('Test 2 (still bei ok=false): eine Nicht-2xx-Antwort liefert null, nichts wird geworfen', async () => {
+ vi.stubGlobal('fetch', vi.fn(() => Promise.resolve({ ok: false, json: () => Promise.resolve({}) })));
+ const mod = await importFresh();
+ await expect(mod.loadDesktopLatest()).resolves.toBeNull();
+ });
+
+ it('Test 3 (still bei Netzfehler): ein abgelehntes fetch-Promise liefert null, nichts wird geworfen', async () => {
+ vi.stubGlobal('fetch', vi.fn(() => Promise.reject(new Error('netz'))));
+ const mod = await importFresh();
+ await expect(mod.loadDesktopLatest()).resolves.toBeNull();
+ });
+
+ it('Test 4 (desktopDownloadUrl): API_URL plus relatives url-Feld', async () => {
+ const mod = await importFresh();
+ expect(
+ mod.desktopDownloadUrl({ name: 'Tessera-Setup-1.1.0.exe', size: 1, sha256: 'x', url: '/desktop/download/windows' }),
+ ).toBe('http://localhost:3001/desktop/download/windows');
+ });
+
+ it('Test 5 (formatFileSize): lokalisierte MB-Werte', async () => {
+ const mod = await importFresh();
+ expect(mod.formatFileSize(6123456, 'de')).toBe('5,8 MB');
+ expect(mod.formatFileSize(6123456, 'en')).toBe('5.8 MB');
+ expect(mod.formatFileSize(106461688, 'de')).toBe('101,5 MB');
+ });
+});
diff --git a/apps/web/src/lib/desktop.ts b/apps/web/src/lib/desktop.ts
new file mode 100644
index 0000000..d388381
--- /dev/null
+++ b/apps/web/src/lib/desktop.ts
@@ -0,0 +1,67 @@
+/**
+ * Desktop-Paket-Info fuer die Web-Oberflaeche (Phase 18, D-10/D-12).
+ *
+ * Quelle fuer den unauffaelligen Download-Link auf der Anmeldeseite
+ * (`DesktopDownloadLinks`) UND fuer die Einstellungsseite
+ * "Desktop-App" (`DesktopAppSettings`): beide lesen `GET /desktop/latest`
+ * (oeffentlich, `@Public()` in `apps/api/src/desktop/desktop.controller.ts`)
+ * und blenden sich aus, wenn der Server keine Pakete traegt (404).
+ *
+ * Wichtig: Next.js ersetzt `process.env.NEXT_PUBLIC_*` nur dann zur Bauzeit
+ * im Browser-Bundle, wenn der Ausdruck woertlich mit vollem Namen im Code
+ * steht — kein Destructuring, kein `process.env[name]` (siehe `app-version.ts`).
+ * Im Betrieb laeuft die Anfrage ueber den Rewrite `/api-proxy` auf die API.
+ */
+
+export type DesktopPlatform = 'windows' | 'linux';
+
+/**
+ * Spiegel von `DesktopLatestFile`/`DesktopLatestResponse` aus
+ * `packages/shared`: `apps/web` haengt nicht von `@tessera/shared` ab
+ * (gleiche Begruendung wie in `app-version.ts`). Die API-Wahrheit bleibt in
+ * `apps/api/src/desktop/desktop.service.ts`.
+ */
+export interface DesktopFileInfo {
+ name: string;
+ size: number;
+ sha256: string;
+ url: string;
+}
+
+export interface DesktopLatestInfo {
+ version: string;
+ channel: string;
+ commit: string;
+ buildTime: string;
+ files: Partial