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