756 lines
39 KiB
Markdown
756 lines
39 KiB
Markdown
# 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
|
|
# <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):
|
|
```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<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):
|
|
```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<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)
|
|
```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<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)
|
|
```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<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):
|
|
```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 `<form>`, following the existing `Link`+`useTranslations` conventions already used for `forgotPassword` (lines 143-150):
|
|
```tsx
|
|
<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)
|
|
```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 (
|
|
<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)
|
|
```tsx
|
|
{/* 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
|
|
```json
|
|
"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)
|
|
```tsx
|
|
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):
|
|
```rust
|
|
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"):
|
|
```rust
|
|
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):
|
|
```rust
|
|
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)
|
|
```json
|
|
{
|
|
"$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:
|
|
```json
|
|
{ "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)
|
|
```toml
|
|
[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)
|
|
```markdown
|
|
## 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):
|
|
```markdown
|
|
- 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`)
|
|
```typescript
|
|
@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
|
|
```typescript
|
|
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
|
|
```sh
|
|
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)
|
|
```typescript
|
|
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.
|