# Phase 18: Desktop-Client fertigstellen - Pattern Map **Mapped:** 2026-09-16 **Files analyzed:** 24 (new/modified) **Analogs found:** 22 / 24 (2 have no direct in-repo analog — see "No Analog Found") ## File Classification | New/Modified File | Role | Data Flow | Closest Analog | Match Quality | |-------------------|------|-----------|-----------------|---------------| | `.gitea/scripts/desktop-version.sh` | utility (CI script) | transform (write version into files) | `.gitea/scripts/publish-images.sh` | role-match (same POSIX-sh CI-script family) | | `.gitea/workflows/ci.yml` (new `desktop` job) | config (CI pipeline) | batch | same file, `publish`/`test` jobs | exact (extend existing job list) | | `.gitea/scripts/publish-images.sh` (modify: copy `desktop-dist/` into API build context) | utility (CI script) | file-I/O | itself (existing) | exact | | `.gitea/scripts/publish-release.sh` (modify: upload 2 release assets) | utility (CI script) | request-response (Gitea API) | itself (existing, idempotent GET→PATCH/POST shape) | exact | | `apps/api/src/desktop/desktop.module.ts` | module | — | `apps/api/src/health/health.module.ts` | exact | | `apps/api/src/desktop/desktop.controller.ts` | controller | request-response + streaming | `apps/api/src/health/health.controller.ts` (public-route shape) + `apps/api/src/dkv/dkv.controller.ts` (file-download route) | exact (composite of two analogs) | | `apps/api/src/desktop/desktop.service.ts` | service | file-I/O | `apps/api/src/dkv/dkv.service.ts` (`getExportFile`, lines 703-732) | exact | | `apps/api/src/desktop/desktop.service.spec.ts` | test | — | `apps/api/src/dkv/dkv.service.spec.ts` (fs-mocking pattern) + `apps/api/src/health/health.controller.spec.ts` (`@Public()` assertion pattern) | role-match (composite) | | `apps/api/Dockerfile` (modify: `COPY desktop-dist/`) | config | file-I/O | itself (existing multi-stage Dockerfile) | exact | | `packages/shared/src/index.ts` (add `DesktopManifest`/`DesktopManifestFile`) | model (shared types) | — | itself (existing `VersionResponse`/`HealthResponse` interfaces) | exact | | `apps/web/src/lib/desktop.ts` | service (client-side fetch helper) | request-response | `apps/web/src/lib/app-version.ts` (`loadApiVersion`, lines 50-63) | exact | | `apps/web/src/lib/desktop.test.ts` | test | — | `apps/web/src/lib/app-version.test.ts` | exact | | `apps/web/src/app/(auth)/login/page.tsx` (add download link block) | component | request-response | itself (existing login page) | exact | | `apps/web/src/app/(portal)/settings/general/desktop/page.tsx` | component (page) | request-response | `apps/web/src/app/(portal)/settings/general/account/page.tsx` | exact | | `apps/web/src/components/settings/settings-sidebar.tsx` (add "Desktop-App" nav item) | component | — | itself (existing sidebar, "Konto" item lines 48-60) | exact | | `apps/web/src/messages/de.json` / `en.json` (add `settings.desktop.*`, `auth.desktopDownload.*` keys) | config (i18n) | — | itself (existing `settings.account.*` block) | exact | | `apps/web/src/app/(portal)/settings/general/desktop/desktop-settings.test.tsx` | test | — | `apps/web/src/components/settings/widget-settings-panel.test.tsx` (next-intl mock + de.json import pattern) | role-match | | `apps/desktop/src-tauri/src/lib.rs` (modify: `/desktop/latest` check, opener call, autostart tray item, umlaut texts) | provider (Tauri app setup) | event-driven | itself (existing version-check block, lines 82-101; tray menu, lines 41-66) | exact | | `apps/desktop/src/setup.html` (polish: Sie-Form, Tessera-Farben) | component (static HTML) | — | itself (existing setup.html, already Tessera-oklch-themed) | exact | | `apps/desktop/src-tauri/capabilities/default.json` (add `opener:allow-open-url`, `autostart` toggle perms already present) | config | — | itself (existing permissions list) | exact | | `apps/desktop/src-tauri/Cargo.toml` (add `tauri-plugin-opener`) | config | — | itself | exact | | `docs/anleitung-anwender.md` (new "Desktop-App" chapter) | doc | — | itself (existing "Die Module" chapter pattern, e.g. "DKV-Rechnung" §120) | role-match | | `docs/anleitung-betrieb.md` (pipeline/desktop-dist/release section) | doc | — | itself (existing §9 "Zwei Kanäle: Live und Beta") | role-match | | `docs/anleitung-entwicklung.md` (update `apps/desktop` description, §39) | doc | — | itself (existing paragraph at line 39) | exact | | `CHANGELOG.md` (Unveröffentlicht → ### Neu bullet) | doc | — | itself (existing `### Neu` bullet style) | exact | ## Pattern Assignments ### `.gitea/scripts/desktop-version.sh` (utility, transform) **Analog:** `.gitea/scripts/publish-images.sh` **Style pattern to copy** (whole file is the model — POSIX `sh`, `set -eu`, German header comment explaining the "why", decision driven only by git state so it's testable locally): ```sh #!/bin/sh # .sh -- (phase-18) # # set -eu TAG_VERSION="$(git describe --tags --abbrev=0 2>/dev/null || echo v0.0.0)" VERSION="${TAG_VERSION#v}" # plain X.Y.Z only — NSIS numeric-version constraint (Pitfall 2) CONF="apps/desktop/src-tauri/tauri.conf.json" CARGO="apps/desktop/src-tauri/Cargo.toml" jq --arg v "$VERSION" '.version = $v' "$CONF" > "$CONF.tmp" && mv "$CONF.tmp" "$CONF" sed -i "s/^version = \".*\"/version = \"$VERSION\"/" "$CARGO" echo "Desktop version set to $VERSION (from tag $TAG_VERSION)" ``` **Reusable conventions from `publish-images.sh`** (lines 22-46 of that file): `set -eu` at top; `REF="${GITHUB_REF:-}"`-style env-var-with-default reads; a `case` statement deciding behavior from `$REF` alone (never from a runtime API call) so the script is offline-testable; every echoed status line prefixed with what happened, not just a bare value. This script never touches secrets, matching `publish-images.sh`'s own closing comment ("Dieses Skript kennt kein Secret"). --- ### `.gitea/workflows/ci.yml` (config, batch — new `desktop` job) **Analog:** same file, existing `test`/`publish` job shape (lines 35-74) **Job skeleton pattern** (copy the `needs`/`runs-on`/step-naming convention): ```yaml test: name: Tests runs-on: ubuntu-latest needs: quality steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 24 - name: Enable pnpm via corepack run: corepack enable && corepack prepare pnpm@9.15.0 --activate - name: Install dependencies run: pnpm install --frozen-lockfile - name: Run tests run: pnpm test ``` New `desktop` job: `needs: test`, add `if: gitea.ref == 'refs/heads/main' || startsWith(gitea.ref, 'refs/tags/v')` (same conditional shape reasoning as the `case "$REF"` branches in `publish-images.sh`). `publish` job gains `needs: desktop` (currently `needs: test`, line 58) and a cache-restore step before its existing `docker build` invocation inside `publish-images.sh`. Step names stay in German, matching every existing step name in this file ("Enable pnpm via corepack" is the one English exception already present — follow whichever is already there per step, don't invent a third style). --- ### `.gitea/scripts/publish-images.sh` (utility, file-I/O — modify to copy `desktop-dist/`) **Analog:** itself **Insertion point** (before the existing build loop, lines 57-68): ```sh for IMG in web api; do docker build -t "$REGISTRY/$IMG:$APP_CHANNEL" \ --build-arg APP_VERSION="$APP_VERSION" \ --build-arg APP_CHANNEL="$APP_CHANNEL" \ --build-arg APP_COMMIT="$APP_COMMIT" \ --build-arg APP_BUILD_TIME="$APP_BUILD_TIME" \ -f "apps/$IMG/Dockerfile" . for TAG in $TAGS; do docker tag "$REGISTRY/$IMG:$APP_CHANNEL" "$REGISTRY/$IMG:$TAG" docker push "$REGISTRY/$IMG:$TAG" done done ``` `desktop-dist/manifest.json` (sha256/size/commit per D-08) must be generated and `desktop-dist/` must exist in the build context (project root `.`) before this loop runs, since the `docker build ... -f apps/api/Dockerfile .` context is the repo root — the API Dockerfile's new `COPY desktop-dist/ /app/desktop-dist/` step reads from there. Keep the "no secrets in this script" invariant (top-of-file comment, line 21) — manifest generation needs no secret. --- ### `.gitea/scripts/publish-release.sh` (utility, request-response — modify for asset upload) **Analog:** itself (idempotent GET→PATCH/POST pattern, lines 125-155) **Idempotency pattern to extend** (verbatim, this is the shape new asset-upload logic must match): ```sh CODE=$(curl -sS --header @"$HDR" -o "$RESP" -w '%{http_code}' "$TAG_URL") case "$CODE" in 200) ID=$(jq -r .id "$RESP") printf '%s' "$UPDATE_JSON" > "$JSONFILE" CODE=$(curl -sS --header @"$HDR" -X PATCH --data @"$JSONFILE" -o "$RESP" -w '%{http_code}' "$RELEASES_URL/$ID") if [ "$CODE" = "200" ]; then echo "Release $TAG aktualisiert (id $ID)" else echo "PATCH $RELEASES_URL/$ID antwortete mit $CODE:" >&2 cat "$RESP" >&2 exit 1 fi ;; 404) ... ;; *) echo "GET $TAG_URL antwortete mit $CODE:" >&2 cat "$RESP" >&2 exit 1 ;; esac ``` **Secret-handling pattern to reuse exactly** (lines 117-123 — cited directly in RESEARCH.md's Security Domain section): ```sh umask 077 TMPDIR_REL=$(mktemp -d) trap 'rm -rf "$TMPDIR_REL"' EXIT INT TERM HDR="$TMPDIR_REL/headers" RESP="$TMPDIR_REL/response.json" JSONFILE="$TMPDIR_REL/payload.json" printf 'Authorization: token %s\nContent-Type: application/json\n' "$GITEA_TOKEN" > "$HDR" ``` New `upload_asset()` function (per RESEARCH.md Code Example #6) should follow the same "GET, decide by HTTP code via `case`, act" shape — for assets: `GET .../assets`, find existing by `name` via `jq`, `DELETE` if found, then `POST` multipart. This keeps one idiom in the file instead of introducing a second (per RESEARCH.md's "Don't Hand-Roll" table). --- ### `apps/api/src/desktop/desktop.module.ts` (module) **Analog:** `apps/api/src/health/health.module.ts` (entire file, 7 lines) ```typescript import { Module } from '@nestjs/common'; import { HealthController } from './health.controller'; @Module({ controllers: [HealthController], }) export class HealthModule {} ``` Copy verbatim, swap names. Since `DesktopController` needs `DesktopService` (unlike the dependency-free `HealthController`), add `providers: [DesktopService]` — no other analog needed, this is the standard NestJS module shape used throughout `apps/api/src/*` (confirmed by `DkvModule`'s equivalent `controllers`+`providers` shape). --- ### `apps/api/src/desktop/desktop.controller.ts` (controller, request-response + streaming) **Analog A — public-route shape:** `apps/api/src/health/health.controller.ts` (whole file, 25 lines) ```typescript import { Controller, Get } from '@nestjs/common'; import type { HealthResponse, VersionResponse } from '@tessera/shared'; import { Public } from '../auth/decorators/public.decorator'; import { getAppVersion } from './app-version'; @Controller('health') export class HealthController { @Public() @Get() check(): HealthResponse { return { status: 'ok', timestamp: new Date().toISOString() }; } // Bewusst oeffentlich (T-KU1-03): Betreiber-Kontrolle per `curl` auf dem // Server ohne Anmeldung. ... @Public() @Get('version') getVersion(): VersionResponse { return getAppVersion(); } } ``` `DesktopController` follows the identical `@Public() @Get(...)` shape for `GET /desktop/latest`, with the same style of a comment explaining *why* it's public (D-10: login page shows the link before auth exists). **Analog B — file-download route + error mapping:** `apps/api/src/dkv/dkv.controller.ts` (lines 133-160) ```typescript @Get('exports/:filename') @Roles(Role.ADMIN, Role.SUPER_ADMIN) async downloadExport( @Req() req: any, @Param('filename') filename: string, @Res() res: any, ) { const tenantId = this._requireTenant(req); try { const buffer = await this.dkvService.getExportFile(tenantId, filename); res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); res.send(buffer); } catch (error) { if (error instanceof NotFoundException || error instanceof BadRequestException) throw error; throw error; } } ``` **Difference to apply deliberately:** `dkv.controller.ts` buffers the whole file in memory (`fs.readFileSync` inside the service, `res.send(buffer)`). Installer files are much larger than xlsx exports, so `desktop.controller.ts` should stream instead — use NestJS's `StreamableFile` (no in-repo precedent; follow RESEARCH.md Code Example #2 / NestJS official docs verbatim: `fs.createReadStream`, `res.set({...})`, `return new StreamableFile(stream)`). Keep `@Public()` (no `@Roles()`!) on both new routes — this is the one deliberate deviation from the `dkv.controller.ts` analog, which is `@Roles(Role.ADMIN, Role.SUPER_ADMIN)`-gated. **Auth pattern (what NOT to add):** confirm via `apps/api/src/auth/decorators/public.decorator.ts` (whole file): ```typescript import { SetMetadata } from '@nestjs/common'; export const IS_PUBLIC_KEY = 'isPublic'; export const Public = () => SetMetadata(IS_PUBLIC_KEY, true); ``` The global `JwtAuthGuard` checks this metadata to skip auth — both new routes need `@Public()`, matching `HealthController`. --- ### `apps/api/src/desktop/desktop.service.ts` (service, file-I/O) **Analog:** `apps/api/src/dkv/dkv.service.ts`, `getExportFile()` (lines 703-732, verbatim) ```typescript async getExportFile(tenantId: string, filename: string): Promise { // Stage 1 (unchanged, T-07-09): traversal guard, whitelist-validate the // filename before doing anything else with it. if ( filename.includes('/') || filename.includes('\\') || filename.includes('..') || !/^(RG-DKV-|DKV_)[\w\-]+\.xlsx$/.test(filename) ) { throw new BadRequestException('Invalid export filename'); } // Stage 2: ownership/whitelist gate ... const filePath = path.join(this.userFilesDir, filename); if (!fs.existsSync(filePath)) { throw new NotFoundException(`Export file not found: ${filename}`); } return fs.readFileSync(filePath); } ``` **Direct application (per RESEARCH.md Code Example #2 and D-10):** whitelist `platform` against a fixed `const PLATFORMS = ['windows', 'linux'] as const` enum (equivalent to the regex-whitelist stage above, just simpler since there's no dynamic filename from the request at all), resolve the filename **exclusively** from `manifest.json` (never from `:platform` directly — stronger than the DKV pattern, which at least regex-validates a request-supplied filename; here the request never supplies a filename at all), then `fs.existsSync`/stream. Imports pattern to copy (`dkv.service.ts` lines 1-9): ```typescript import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; import * as fs from 'fs'; import * as path from 'path'; ``` **Manifest-reading + platform-whitelist shape** (already fully worked out in RESEARCH.md Code Examples §5, cite as-is): ```typescript const PLATFORMS = ['windows', 'linux'] as const; type Platform = (typeof PLATFORMS)[number]; async getManifest(): Promise { const manifestPath = path.join(this.desktopDistDir, 'manifest.json'); if (!fs.existsSync(manifestPath)) return null; return JSON.parse(fs.readFileSync(manifestPath, 'utf-8')); } async getPackageStream(platform: string): Promise<{ stream: fs.ReadStream; entry: ManifestFileEntry }> { if (!PLATFORMS.includes(platform as Platform)) { throw new BadRequestException(`Unknown platform: ${platform}`); } const manifest = await this.getManifest(); if (!manifest) throw new NotFoundException('Desktop packages not available'); const entry = manifest.files[platform as Platform]; if (!entry) throw new NotFoundException(`No package for platform: ${platform}`); const filePath = path.join(this.desktopDistDir, entry.name); if (!fs.existsSync(filePath)) throw new NotFoundException(`Package file missing: ${entry.name}`); return { stream: fs.createReadStream(filePath), entry }; } ``` --- ### `apps/api/src/desktop/desktop.service.spec.ts` (test) **Analog A — fs mocking under ESM:** `apps/api/src/dkv/dkv.service.spec.ts` (lines 27-31, verbatim — this exact technique is required, `vi.spyOn(fs, ...)` does not work under this project's ESM setup) ```typescript // `import * as fs from 'fs'` under ESM has a non-configurable module // namespace — vi.spyOn(fs, 'existsSync') fails with "Cannot redefine // property". vi.mock() replaces the module at import time instead, which // works regardless of namespace configurability (Tests 8-10, Aufgabe 3). vi.mock('fs', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, existsSync: vi.fn(), readFileSync: vi.fn() }; }); ``` **Analog B — `@Public()` metadata assertion + header-comment style + numbered `it()` naming:** `apps/api/src/health/health.controller.spec.ts` (whole file, especially Test 6, lines 94-97) ```typescript it('Test 6 (bewusst oeffentlich, T-KU1-03): getVersion und check tragen @Public()', () => { expect(Reflect.getMetadata(IS_PUBLIC_KEY, HealthController.prototype.getVersion)).toBe(true); expect(Reflect.getMetadata(IS_PUBLIC_KEY, HealthController.prototype.check)).toBe(true); }); ``` Required test cases per D-16/RESEARCH.md Test Map: manifest present → 200 JSON; manifest/dir missing → 404; unknown platform → 400 (BadRequestException); traversal-style input (`../../etc/passwd` as `:platform` value) rejected by the whitelist before any `fs` call — assert `fs.existsSync`/`readFileSync` mocks were never called with a traversal string, same spirit as the DKV spec's bound-vs-unbound-client double-mock technique for proving isolation. --- ### `apps/api/Dockerfile` (config, file-I/O — modify) **Analog:** itself (existing multi-stage `runner` stage, lines 28-52) **Insertion pattern** — follow the existing `COPY --from=builder ... ./`-then-chown convention (lines 36-49): ```dockerfile RUN addgroup --system --gid 1001 nestjs && \ adduser --system --uid 1001 nestjs && \ mkdir -p /app/user-files && \ chown nestjs:nestjs /app/user-files ... COPY --from=builder /app/packages/shared/src ./packages/shared/src COPY apps/api/scripts ./apps/api/scripts USER nestjs ``` Add `COPY desktop-dist ./desktop-dist` (build context is repo root, matching `publish-images.sh`'s `docker build ... -f "apps/$IMG/Dockerfile" .`) before `USER nestjs`, and extend the `mkdir`/`chown` line if the runtime reads need write-free but readable-by-`nestjs` permissions (it's read-only at runtime, so a plain `COPY` — which defaults to root-owned, world-readable — is sufficient; no `chown` needed unless the file server needs to write, which D-08 says it doesn't). --- ### `packages/shared/src/index.ts` (model — add types) **Analog:** itself (existing `HealthResponse`/`VersionResponse` interfaces, lines 3-20ish) ```typescript export interface HealthResponse { status: string; timestamp: string; } export interface VersionResponse { name: string; version: string; channel: string; commit: string; buildTime: string; } ``` Add `DesktopManifestFile`/`DesktopManifest` in the same file, same flat-interface style (per RESEARCH.md Code Example #7): ```typescript export interface DesktopManifestFile { name: string; size: number; sha256: string; } export interface DesktopManifest { version: string; commit: string; buildTime: string; files: { windows: DesktopManifestFile; linux: DesktopManifestFile; }; } ``` --- ### `apps/web/src/lib/desktop.ts` (service — client fetch helper) **Analog:** `apps/web/src/lib/app-version.ts`, `loadApiVersion()` (lines 50-63, verbatim) ```typescript let apiVersionPromise: Promise | null = null; export function loadApiVersion(): Promise { if (!apiVersionPromise) { apiVersionPromise = fetch(`${API_URL}/health/version`, { credentials: 'include' }) .then((res) => (res.ok ? (res.json() as Promise) : null)) .catch(() => null); } return apiVersionPromise; } ``` Copy the memoized-single-promise, fail-silent-to-`null` shape exactly for `loadDesktopLatest()`. Note: the login page renders unauthenticated, so **omit** `credentials: 'include'` (or keep it — the file's own doc-comment at lines 8-14 explains it's harmless either way since `/desktop/latest` is `@Public()`). `API_URL` constant pattern to reuse (line 37): ```typescript const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; ``` --- ### `apps/web/src/lib/desktop.test.ts` (test) **Analog:** `apps/web/src/lib/app-version.test.ts` (whole file, 84 lines) ```typescript async function importFresh() { vi.resetModules(); return import('./app-version'); } ... it('Test 4 (Laden, memoisiert): zwei Aufrufe liefern das Objekt, fetch laeuft genau einmal mit Cookie', async () => { const fetchMock = vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve(payload) })); vi.stubGlobal('fetch', fetchMock); const mod = await importFresh(); const first = await mod.loadApiVersion(); const second = await mod.loadApiVersion(); expect(first).toEqual(payload); expect(second).toEqual(payload); expect(fetchMock).toHaveBeenCalledTimes(1); }); it('Test 5 (still bei Fehler): Netzfehler und ok=false liefern null, nichts wird geworfen', async () => { vi.stubGlobal('fetch', vi.fn(() => Promise.reject(new Error('netz')))); const rejected = await importFresh(); await expect(rejected.loadApiVersion()).resolves.toBeNull(); }); ``` Same `vi.resetModules()` + dynamic re-import pattern is required because the module-level promise is memoized — reuse verbatim for `loadDesktopLatest()` (module-reset-per-test, fetch mocked once/twice/error cases). --- ### `apps/web/src/app/(auth)/login/page.tsx` (component — add download link block) **Analog:** itself (existing file, `'use client'`, `useTranslations('auth')`, structure lines 1-38 + submit button area ~150-165) Insertion pattern — new block below the `
`, following the existing `Link`+`useTranslations` conventions already used for `forgotPassword` (lines 143-150): ```tsx
{t('forgotPassword')}
``` The new desktop-download block needs a client-side `useEffect`+`useState` pair calling `loadDesktopLatest()` (unlike the rest of the page, which is a synchronous form) — mirror the `AppVersionBadge` component's consumption of `loadApiVersion()` for that async-render-then-hide-if-null pattern (`apps/web/src/components/layout/app-version-badge.tsx`, cited in RESEARCH.md Sources, not independently re-read this session since the shape is identical to the `lib/desktop.ts` mirror above — read it before writing this component if the exact hook shape is needed). --- ### `apps/web/src/app/(portal)/settings/general/desktop/page.tsx` (component — page) **Analog:** `apps/web/src/app/(portal)/settings/general/account/page.tsx` (whole file, 21 lines) ```tsx 'use client'; import { useTranslations } from 'next-intl'; import { AccountSettingsForm } from '@/components/settings/account-settings-form'; /** * Account settings page — /settings/general/account. * Shows avatar upload and (for local users only) password change form. */ export default function AccountSettingsPage() { const t = useTranslations('settings'); return (

{t('account.title')}

); } ``` Copy this exact page-shell shape: `'use client'`, `useTranslations('settings')`, `

` title, then delegate the real content to a dedicated component (`DesktopAppSettings` or similar, under `apps/web/src/components/settings/`, matching the codebase's page-vs-component split already used for `account`/`calendar`/`widget` settings). --- ### `apps/web/src/components/settings/settings-sidebar.tsx` (component — add nav item) **Analog:** itself (existing "Konto" nav item under "Allgemein" category, lines 41-61) ```tsx {/* Allgemein category — above Dashboard (Surface C, 07-06) */}

{t('categoryGeneral')}

``` Add a second `` inside the same `