Files
tessera-ctl/.planning/phases/18-desktop-client-fertigstellen/18-PATTERNS.md
T

39 KiB

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):

#!/bin/sh
# <script-name>.sh -- <one-line purpose> (phase-18)
#
# <what it decides and why, in German, matching the existing header style>
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):

  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):

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):

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):

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)

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)

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)

@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):

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)

async getExportFile(tenantId: string, filename: string): Promise<Buffer> {
  // 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):

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):

const PLATFORMS = ['windows', 'linux'] as const;
type Platform = (typeof PLATFORMS)[number];

async getManifest(): Promise<DesktopManifest | null> {
  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)

// `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<typeof import('fs')>();
  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)

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):

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)

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):

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)

let apiVersionPromise: Promise<ApiVersionInfo | null> | null = null;

export function loadApiVersion(): Promise<ApiVersionInfo | null> {
  if (!apiVersionPromise) {
    apiVersionPromise = fetch(`${API_URL}/health/version`, { credentials: 'include' })
      .then((res) => (res.ok ? (res.json() as Promise<ApiVersionInfo>) : 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):

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)

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).


Analog: itself (existing file, 'use client', useTranslations('auth'), structure lines 1-38 + submit button area ~150-165)

Insertion pattern — new block below the <form>, following the existing Link+useTranslations conventions already used for forgotPassword (lines 143-150):

<div className="flex justify-end">
  <Link
    href="/reset-password"
    className="text-sm text-muted-foreground hover:text-foreground transition-colors"
  >
    {t('forgotPassword')}
  </Link>
</div>

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)

'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 (
    <div>
      <h1 className="mb-6 text-lg font-semibold text-foreground">
        {t('account.title')}
      </h1>
      <AccountSettingsForm />
    </div>
  );
}

Copy this exact page-shell shape: 'use client', useTranslations('settings'), <h1> 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)

{/* Allgemein category — above Dashboard (Surface C, 07-06) */}
<div className="p-4 pb-2">
  <h2 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
    {t('categoryGeneral')}
  </h2>
</div>
<nav className="mb-2 flex flex-col gap-1 px-3">
  <Link
    href="/settings/general/account"
    className={`flex items-center rounded-md px-2 py-1.5 text-sm transition-colors ${
      isActive('/settings/general/account')
        ? 'bg-sidebar-accent text-sidebar-accent-foreground font-medium'
        : 'text-sidebar-foreground hover:bg-muted'
    }`}
    aria-current={isActive('/settings/general/account') ? 'page' : undefined}
  >
    {t('categoryAccount')}
  </Link>
</nav>

Add a second <Link href="/settings/general/desktop"> inside the same <nav> under "Allgemein", using t('categoryDesktopApp') (new i18n key) — same isActive()/aria-current pattern, since isActive() (lines 27-33) already does a generic pathname.startsWith(href) fallback that works unmodified for the new route.


apps/web/src/messages/de.json / en.json (i18n)

Analog: itself — existing settings.account.* nested block

"account": {
  "title": "Konto",
  "avatarLabel": "Profilbild",
  ...
}

Add settings.desktop.* (title, version label, download buttons, file-size format, 3-4 explanatory sentences, all in Sie-Form per D-12/D-13) and settings.categoryDesktopApp (nav label) plus auth.desktopDownload.* (login-page link labels) following the identical flat-nested-object convention. Mirror every German key 1:1 into en.json (confirmed both files share identical key structure across all existing namespaces).


apps/web/src/app/(portal)/settings/general/desktop/desktop-settings.test.tsx (test)

Analog: apps/web/src/components/settings/widget-settings-panel.test.tsx (next-intl mock, lines 1-30, and de.json-driven text assertions)

vi.mock('next-intl', async () => {
  const messages = (await import('@/messages/de.json')).default as Record<string, unknown>;
  const lookup = (path: string): string | undefined =>
    path.split('.').reduce<unknown>((o, k) => (o && typeof o === 'object' ? (o as any)[k] : undefined), messages) as
      | string
      | undefined;
  return {
    useTranslations:
      (ns?: string) =>
      (key: string, values?: Record<string, unknown>) => {
        const raw = lookup(ns ? `${ns}.${key}` : key) ?? key;
        return values ? raw.replace(/\{(\w+)\}/g, (_: string, n: string) => String(values[n] ?? '')) : raw;
      },
  };
});

Combine with apps/web/src/lib/app-version.test.ts's vi.stubGlobal('fetch', ...) pattern to mock /desktop/latest responses for the two required cases (DESK-03 test map): link/section renders with version+size+buttons when the API responds 200; link/section is absent when the API 404s. Same combination applies to the login-page test (new or extended file — none found for login in this research pass per RESEARCH.md Wave 0 Gaps).


apps/desktop/src-tauri/src/lib.rs (provider, event-driven — modify)

Analog: itself, existing version-check block (lines 82-101) and tray menu (lines 41-66)

Existing version-check block to redirect (verbatim, current state):

if let Some(server_url) = url_for_check {
    let app_handle = app.handle().clone();
    let app_version = env!("CARGO_PKG_VERSION").to_string();
    tauri::async_runtime::spawn(async move {
        let url = format!("{}/health/version", server_url.trim_end_matches('/'));
        if let Ok(resp) = reqwest::get(&url).await {
            if let Ok(info) = resp.json::<VersionResponse>().await {
                if info.version != app_version {
                    let _ = app_handle
                        .notification()
                        .builder()
                        .title("Tessera Update")
                        .body("Eine neue Version ist verfuegbar.")
                        .show();
                }
            }
        }
    });
}

Change target URL to /desktop/latest, update the notification body per D-13 ("Neue Version X.Y.Z verfuegbar" — interpolate info.version), and enable the tray "Update herunterladen" item on version mismatch (needs holding a MenuItem handle created during .setup(), same builder family as open/quit below).

Existing tray-menu pattern to extend (verbatim, lines 41-66 — note current "Oeffnen"/"Beenden" lack umlauts, D-13 requires fixing to "Öffnen"/"Beenden"):

let open = MenuItemBuilder::with_id("open", "Oeffnen").build(app)?;
let quit = MenuItemBuilder::with_id("quit", "Beenden").build(app)?;
let menu = MenuBuilder::new(app)
    .item(&open)
    .separator()
    .item(&quit)
    .build()?;
...
.on_menu_event(|app, event| match event.id().as_ref() {
    "open" => { ... }
    "quit" => { app.exit(0); }
    _ => {}
})

Add update (opener call, RESEARCH.md Code Example #4) and autostart (CheckMenuItemBuilder, RESEARCH.md Code Example #5) items into this same MenuBuilder chain and match arm list — same builder/match idiom, no new pattern needed.

Imports to add at the top (alongside existing use tauri_plugin_... lines 7-9):

use tauri_plugin_opener::OpenerExt;
use tauri_plugin_autostart::ManagerExt;

apps/desktop/src/setup.html (component — polish)

Analog: itself — already Tessera-themed (oklch brand colors, e.g. oklch(0.91 0.19 102) for the <h1>, oklch(0.17 0.01 260) background, lines 1-60). D-13's "Tessera-Farben" requirement is largely already satisfied; the remaining work is auditing body-text strings for Du-form and converting to Sie-form (per project convention: app texts always use Sie-form, per user's global memory feedback_anrede_du.md). No structural analog change needed — read the full 254-line file directly when executing, since it's small enough for one Read call, and grep for du/dein/dich/deine occurrences to fix.


apps/desktop/src-tauri/capabilities/default.json (config)

Analog: itself (existing permissions array, whole file)

{
  "$schema": "../gen/schemas/desktop-schema.json",
  "identifier": "default",
  "description": "Tessera desktop capabilities",
  "windows": ["main"],
  "permissions": [
    "core:default",
    "store:default",
    "notification:default",
    "notification:allow-is-permission-granted",
    "notification:allow-request-permission",
    "notification:allow-notify",
    "autostart:allow-enable",
    "autostart:allow-disable",
    "autostart:allow-is-enabled",
    "window-state:default"
  ]
}

Append a scoped opener permission object (not a bare string, since it needs a URL scope) per RESEARCH.md Code Example #4:

{ "identifier": "opener:allow-open-url", "allow": [{ "url": "https://*" }, { "url": "http://*" }] }

http://* is required because D-02 permits non-HTTPS server addresses for internal LAN use (same reasoning already present in setup.html's existing HTTP warning). Autostart permissions (allow-enable/allow-disable/allow-is-enabled) are already present — no change needed there.


apps/desktop/src-tauri/Cargo.toml (config)

Analog: itself (existing [dependencies] block, lines 13-21)

[dependencies]
tauri = { version = "2", features = ["tray-icon"] }
tauri-plugin-store = "2"
tauri-plugin-notification = "2"
tauri-plugin-autostart = "2"
tauri-plugin-window-state = "2"
reqwest = { version = "0.12", features = ["json"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"

Add tauri-plugin-opener = "2" in the same unpinned-major style as every other tauri-plugin-* line (no lockfile hand-editing — cargo add tauri-plugin-opener regenerates Cargo.lock, matching how the other four plugins were presumably added in Phase 6).


Docs (docs/anleitung-anwender.md, docs/anleitung-betrieb.md, docs/anleitung-entwicklung.md)

Analog for anwender.md: existing ### DKV-Rechnung module sub-chapter (line 120) under ## Die Module (line 95) — same H2/H3 nesting and "what it is / how to use it" narrative tone in Sie-Form. New "Desktop-App" content per D-15 fits better as its own ## chapter (parallel to ## Dashboard, ## Marktplatz) since it's not a module in the marketplace sense — insert after ## Persönliche Einstellungen (line 143) and before ## Einen Fehler melden (line 160), and add it to the ## Inhaltsverzeichnis (line 6) in the same list style as every other chapter entry there.

Analog for betrieb.md: existing ## 9. Zwei Kanäle: Live und Beta (line 357), specifically its ### Die eine Zeile je Server (line 385) and ### Eine Version freigeben (line 430) sub-sections — same numbered-##-chapter, ###-subsection, imperative-instruction style. New pipeline/desktop-dist/release content fits as a new numbered section (e.g. ## 10.) or a new ### under an existing pipeline-adjacent section (## 8. Abgrenzung zur CI/CD-Pipeline, line 343) — follow whichever the phase's plan decides, but match this file's existing numbered-heading + Inhaltsverzeichnis-list convention (line 12).

Analog for entwicklung.md: existing paragraph at line 39 (exact text to replace):

`apps/desktop` besteht bislang nur aus dem Tauri-Grundgerüst (`src-tauri/`) und einer einzelnen

Replace this sentence to reflect the finished state (no longer "nur ... Grundgerüst") and add the local-build instructions (pnpm --filter @tessera/desktop build) per D-15, matching this file's existing code-block + prose style used elsewhere in ## Lokale Entwicklungsumgebung (line 58, ### Stack starten, line 82).


CHANGELOG.md (doc)

Analog: itself — existing ## Unveröffentlicht → ### Neu bullet list (lines 5-9)

## Unveröffentlicht

### Neu

- Kalender-Widget: Monatsübersicht mit Terminanzahl je Tag, Termine beim Überfahren, darunter „Nächste Termine“
- Kalender-Widget: Einstellungen für Monatsansicht, Anzahl und Zeitraum der Termine
- Favoriten-Widget: optionaler Titel (ohne Titel keine Kopfzeile)

Add per D-17, same bullet style (bold-free, colon-separated feature:description shape):

- Desktop-App für Windows und Linux: Download auf der Anmeldeseite und unter Einstellungen → Desktop-App

Shared Patterns

Public, unauthenticated route (@Public())

Source: apps/api/src/auth/decorators/public.decorator.ts (whole file) + apps/api/src/health/health.controller.ts (lines 8-9, 20-21) Apply to: Both apps/api/src/desktop/desktop.controller.ts routes (GET /desktop/latest, GET /desktop/download/:platform)

@Public()
@Get('version')
getVersion(): VersionResponse {
  return getAppVersion();
}

Pin with a spec test asserting Reflect.getMetadata(IS_PUBLIC_KEY, DesktopController.prototype.getLatest) (and .download) === true, matching health.controller.spec.ts Test 6 — this is explicitly called out in RESEARCH.md's V4 Access Control row as the negative case to guard (routes must NOT accidentally inherit tenant/role checks).

Whitelist-then-lookup file access (never trust request input for a filesystem path)

Source: apps/api/src/dkv/dkv.service.ts:703-729 (getExportFile) Apply to: apps/api/src/desktop/desktop.service.ts (getPackageStream) Two-stage gate: (1) reject the identifier via a fixed whitelist before any filesystem touch (regex for DKV filenames; a 2-item const PLATFORMS array for desktop platforms — stricter, since desktop never even accepts a filename from the request), (2) resolve the actual file path only from a trusted, non-request-derived source (DKV: an ownership row in the DB; desktop: manifest.json, written only by CI). Both throw BadRequestException for the whitelist failure and NotFoundException for the missing-file case — reuse these same two exception types.

Memoized public fetch, fail-silent-to-null

Source: apps/web/src/lib/app-version.ts:50-63 (loadApiVersion) Apply to: apps/web/src/lib/desktop.ts (loadDesktopLatest), and by extension every component consuming it (login page, settings page) which should treat null as "hide this UI", never as an error to surface

let apiVersionPromise: Promise<ApiVersionInfo | null> | null = null;
export function loadApiVersion(): Promise<ApiVersionInfo | null> {
  if (!apiVersionPromise) {
    apiVersionPromise = fetch(`${API_URL}/health/version`, { credentials: 'include' })
      .then((res) => (res.ok ? (res.json() as Promise<ApiVersionInfo>) : null))
      .catch(() => null);
  }
  return apiVersionPromise;
}

CI script idempotency (GET → decide by HTTP code → PATCH-or-POST)

Source: .gitea/scripts/publish-release.sh:125-155 Apply to: New asset-upload logic in the same script (D-08); any future CI script touching the Gitea API

CODE=$(curl -sS --header @"$HDR" -o "$RESP" -w '%{http_code}' "$TAG_URL")
case "$CODE" in
  200) ... PATCH ... ;;
  404) ... POST ... ;;
  *) echo "... antwortete mit $CODE:" >&2; cat "$RESP" >&2; exit 1 ;;
esac

fs mocking under ESM (Vitest)

Source: apps/api/src/dkv/dkv.service.spec.ts:27-31 Apply to: apps/api/src/desktop/desktop.service.spec.ts (manifest read + platform whitelist + traversal tests all need fs.existsSync/readFileSync mocked)

vi.mock('fs', async (importOriginal) => {
  const actual = await importOriginal<typeof import('fs')>();
  return { ...actual, existsSync: vi.fn(), readFileSync: vi.fn() };
});

vi.spyOn(fs, 'existsSync') fails under this project's ESM setup ("Cannot redefine property") — vi.mock() is mandatory, not optional style.

German-first documentation and UI copy in Sie-Form

Source: every file in docs/, every apps/web/src/messages/de.json string, every CI script's German header comments Apply to: all new docs chapters, all new i18n keys, all new lib.rs/setup.html user-facing strings (tray texts, notifications, setup-page copy) — matches the user's standing global instruction (Sie-Form for app texts, Du-form only in conversation) and this repo's own established convention.

No Analog Found

File Role Data Flow Reason
apps/api/src/desktop/desktop.controller.ts (streaming half only — StreamableFile usage) controller streaming No route in this codebase currently streams a file via StreamableFile; dkv.controller.ts's equivalent buffers the whole file with res.send(buffer) instead. Use RESEARCH.md Code Example #2 (cites docs.nestjs.com Techniques > Streaming Files directly) rather than an in-repo precedent.
apps/desktop/src-tauri/src/lib.rs (CheckMenuItemBuilder for the autostart tray toggle) provider event-driven No existing CheckMenuItem (checkbox-style tray item) exists in lib.rs today — only plain MenuItemBuilder items (open, quit). RESEARCH.md Code Example #5 (cites v2.tauri.app/plugin/autostart/) is the reference; the builder/match-arm shape to slot it into is still the existing tray-menu pattern above.

Metadata

Analog search scope: apps/api/src/health/, apps/api/src/dkv/, apps/api/src/auth/decorators/, apps/api/Dockerfile, packages/shared/src/, apps/web/src/lib/, apps/web/src/app/(auth)/login/, apps/web/src/app/(portal)/settings/, apps/web/src/components/settings/, apps/web/src/messages/, .gitea/workflows/, .gitea/scripts/, apps/desktop/src-tauri/, apps/desktop/src/, docs/, CHANGELOG.md Files scanned: ~30 (all read fully or via targeted sed -n/grep -n ranges; no re-reads of the same line range) Pattern extraction date: 2026-09-16 Tracked-source gate: all 27 analog paths verified via git ls-files — all tracked, none are gitignored mirrors.