Files
tessera-ctl/.planning/phases/18-desktop-client-fertigstellen/18-RESEARCH.md
T
schalli 01ec4d3d4e docs(phase-18): Forschung — Cross-Bau Windows-NSIS auf Linux, act_runner-Cache statt Artifact-Actions, API-Downloadmodul
Cargo-xwin/NSIS-Toolchain gegen Cargo.lock und offizielle Tauri-Doku geprueft, act_runner-Cache-Server live bestaetigt (Docker-Inspektion), Paket-Legitimitaet fuer tauri-plugin-opener/cargo-xwin gecheckt, bestehende Codebase-Muster (DkvService-Pfadschutz, app-version.ts, @Public()) als Vorlage fuer das neue Desktop-Modul dokumentiert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 15:02:02 +02:00

65 KiB
Raw Blame History

Phase 18: Desktop-Client fertigstellen - Research

Researched: 2026-09-16 Domain: Tauri 2 cross-compilation (Windows NSIS on Linux), Gitea Actions CI/CD (self-hosted act_runner), NestJS 11 public file distribution, Next.js 15 desktop-download UI Confidence: MEDIUM (cross-compile toolchain and act_runner caching verified against the live runner and official docs; the Windows-installer end-to-end run itself can only be proven inside the pipeline, per D-16)

<user_constraints>

User Constraints (from CONTEXT.md)

Locked Decisions

Produkt (User)

  • D-01: Der Installer ist in Tessera herunterladbar (Anwender ohne Gitea-Zugang) und liegt als Datei am Gitea-Release des Freigabe-Tags.
  • D-02: Server-Adresse wird weiterhin beim ersten Start abgefragt (ein Paket fuer alle Umgebungen/Kunden). Kein fest eingebauter Server.
  • D-03: Updates: Hinweis + Download-Link, kein automatisches Aktualisieren.

Plattformen & Bau (Claude)

  • D-04: Windows-Installer (NSIS, Tessera-Setup-X.Y.Z.exe) ist das Hauptziel; Linux-AppImage (Tessera-X.Y.Z.AppImage) wird mitgebaut, weil der Runner ohnehin Linux ist.
  • D-05: Der Gitea-Runner ist Linux (gitea/runner-images:ubuntu-latest, Docker, 8 Kerne/15 GB). Der Windows-Bau laeuft als Cross-Bau auf Linux (Tauri: cargo tauri build --runner cargo-xwin --target x86_64-pc-windows-msvc, NSIS via makensis aus dem Ubuntu-Paket nsis, llvm/lld/clang). Kein Windows-Rechner in der Pipeline.
  • D-06: Neuer CI-Job desktop nach test, laeuft bei Push auf main und bei Tags v* (Beta bekommt die Pakete auch, sonst ist nichts testbar). Cargo-Registry, target/ und das xwin-SDK werden per actions/cache zwischengespeichert; Forschung klaert, ob der lokale act_runner den Cache-Server anbietet — wenn nicht, laeuft der Bau ohne Cache (langsamer, aber korrekt).
  • D-07: Versionsquelle ist der Freigabe-Tag: Ein Skript (.gitea/scripts/desktop-version.sh) schreibt vor dem Bau die Version (X.Y.Z aus dem letzten Tag) in apps/desktop/src-tauri/tauri.conf.json und Cargo.toml. Beta-Builds tragen dieselbe X.Y.Z wie der letzte Tag plus den Commit-Stempel in einem separaten Feld/Dateinamen-Suffix (Forschung: welche Versionsformen NSIS/Tauri auf Windows akzeptieren; Regel: keine Form waehlen, die den Windows-Installer scheitern laesst).
  • D-08: Verteilung ohne Netzabhaengigkeit: Die gebauten Pakete werden im publish-Job in das API-Abbild kopiert (/app/desktop-dist/ mit manifest.json: Version, Dateinamen, Groessen, SHA-256). Die API liefert sie selbst aus — Live-Server brauchen keinen Zugang zu Gitea. Zusaetzlich haengt publish-release.sh (nur bei Tags) beide Dateien als Release-Assets an das Gitea-Release (D-01).
  • D-09: Keine Code-Signierung (intern; SmartScreen-Hinweis wird im Anwenderhandbuch erklaert).

API (Claude)

  • D-10: Neues Modul apps/api/src/desktop/: GET /desktop/latest (oeffentlich, ohne Anmeldung — die Anmeldeseite zeigt den Link) liefert { version, files: { windows: { name, size, sha256, url }, linux: {...} } } aus manifest.json; GET /desktop/download/:platform (windows | linux, oeffentlich) streamt die Datei mit Content-Disposition: attachment. Fehlt das Verzeichnis/Manifest: 404 mit klarer Meldung; die Web-Oberflaeche blendet den Link dann aus. Nur Dateinamen aus dem Manifest werden geoeffnet (kein Pfad aus der Anfrage), Plattform per Whitelist.
  • D-11: /health/version bleibt unveraendert; der Client vergleicht seine Version kuenftig mit /desktop/latest.

Web (Claude)

  • D-12: Anmeldeseite: unauffaelliger Link unterhalb des Formulars "Desktop-App herunterladen (Windows)" + kleiner Linux-Link, nur wenn /desktop/latest antwortet. Einstellungen: neuer Eintrag Einstellungen → Allgemein → Desktop-App mit Version, beiden Download-Knoepfen, Dateigroesse und 3-4 Saetzen (Was ist das, Erststart, Tray). Texte de/en, Sie-Form.

Client (Claude)

  • D-13: lib.rs: Versionspruefung gegen {server}/desktop/latest; bei abweichender Version Benachrichtigung "Neue Version X.Y.Z verfuegbar" und Tray-Menuepunkt "Update herunterladen", der {server}/settings/general/desktop im Systembrowser oeffnet (tauri-plugin-opener oder open-Crate — Forschung waehlt). Erststart-Seite (setup.html): Adresse pruefen ueber /health/version (bleibt), Texte in Sie-Form, Tessera-Farben; Tray-Texte mit Umlauten ("Öffnen", "Beenden").
  • D-14: Bestehende Phase-6-Funktionen (Tray, Schliessen-ins-Tray, Autostart, Fensterzustand) bleiben unveraendert; Autostart-Schalter kommt ins Tray-Menue ("Mit Windows starten", Haken), weil es keine Client-Einstellungsseite gibt.

Doku & Tests (Claude)

  • D-15: docs/anleitung-anwender.md: Kapitel "Desktop-App" (Download in Tessera, Installation, SmartScreen-Hinweis, Erststart mit Server-Adresse, Tray/Schliessen/Beenden, Autostart, Update-Hinweis). docs/anleitung-betrieb.md: Pipeline-Job, Cross-Bau, wo die Pakete im Abbild liegen, Release-Dateien, Fehlerbilder. docs/anleitung-entwicklung.md: apps/desktop ist kein Grundgeruest mehr; lokaler Bau (pnpm --filter @tessera/desktop build), Voraussetzungen.
  • D-16: Tests: API-Modul (Manifest lesen, 404 ohne Manifest, Plattform-Whitelist, Pfad-Traversal abgewiesen), Web (Link erscheint/verschwindet je nach API-Antwort, Einstellungsseite), Rust: cargo check/cargo clippy im CI-Job; ein lokaler Linux-Bau (tauri build AppImage) als Beweis vor dem Push. Der Windows-Cross-Bau wird erst in der Pipeline bewiesen — der Plan sieht eine Iterationsschleife vor (Fehler lesen, Job anpassen, erneut pushen), bis ein gruener Lauf mit beiden Dateien vorliegt.
  • D-17: CHANGELOG Unveröffentlicht → ### Neu: "Desktop-App für Windows und Linux: Download auf der Anmeldeseite und unter Einstellungen → Desktop-App" (Stichpunkt-Stil).

Claude's Discretion

  • Aufteilung in Plaene (Vorschlag: 18-01 CI/Cross-Bau + Versionsskript + Release-Assets; 18-02 API-Modul + Abbild-Einbau; 18-03 Web-Oberflaeche + Client-Anpassungen + Handbuecher)
  • Tray-Menue-Reihenfolge, Icon-Pruefung, Dateinamen-Details

Deferred Ideas (OUT OF SCOPE)

  • Auto-Update (Tauri Updater, Signaturschluessel) — spaeter, wenn extern verkauft wird
  • Code-Signierung — spaeter
  • Native Kalender-Erinnerungen ueber den Client — nicht Teil dieser Phase
  • macOS-Paket — kein Bedarf </user_constraints>

<phase_requirements>

Phase Requirements

ID Description Research Support
DESK-01 Tauri-basierter Desktop-Wrapper fuer Windows und Linux (Fortfuehrung aus Phase 6) Cross-Build toolchain (§ Standard Stack, § Code Examples §1–2), current NSIS+AppImage bundle targets already configured in tauri.conf.json:29
DESK-02 Desktop-App verbindet sich mit dem Web-Backend, Server-Adresse beim Erststart (Fortfuehrung) Unchanged setup.html flow; only umlaut/branding polish (D-13) — no new research needed, confirmed unchanged in lib.rs/setup.html reads
DESK-03 Download in Tessera (Login-Seite + Einstellungen) GET /desktop/latest + GET /desktop/download/:platform design (§ Architecture Patterns, § Code Examples §5–6), loadApiVersion() precedent in apps/web/src/lib/app-version.ts
DESK-04 Release-Dateien in Gitea publish-release.sh extension for multipart asset upload (§ Code Examples §7), idempotent re-upload
DESK-05 Client-Versionierung + Update-Hinweis desktop-version.sh version-injection script (§ Code Examples §3), NSIS version-format pitfall (§ Common Pitfalls #3), tauri-plugin-opener for the update link (§ Code Examples §8)
</phase_requirements>

Summary

Phase 18 turns the Phase-6 Tauri scaffold into a distributable product without adding new client behavior. The hard technical edge is cross-compiling the Windows NSIS installer on the existing Linux act_runner (gitea/runner-images:ubuntu-latest, confirmed present on the Docker host, Ubuntu 24.04, no Rust, no nsis, no webkit2gtk/appindicator dev headers pre-installed — every dependency must be installed in the job). cargo-xwin is the correct, currently-maintained tool for this (cargo tauri build --runner cargo-xwin --target x86_64-pc-windows-msvc); its Windows SDK download is cached via XWIN_CACHE_DIR. reqwest's default native-tls backend resolves to Windows' built-in schannel crate for the Windows target (not OpenSSL), so no extra TLS wrangling is needed — the existing Cargo.toml reqwest = { version = "0.12", features = ["json"] } cross-compiles as-is.

The second edge is version-string safety: NSIS's VIProductVersion requires numeric-only X.X.X.X. Tauri's bundler (shipped since tauri-bundler 2.2.3, well below the installed 2.11.3) now coerces non-numeric build metadata to .0 with a warning instead of hard-failing, but the safer, deterministic choice per D-07 is to never put non-numeric data in the version field at all — always write the plain X.Y.Z of the latest tag into tauri.conf.json/Cargo.toml, and carry the beta/commit distinction only in the output filename and in manifest.json (which already needs a sha256/size/commit per D-08).

The third edge is cross-job artifact handoff on this specific Gitea instance: actions/upload-artifact@v4/download-artifact@v4 are documented to abort on Gitea (GHES-detection check), and v3 has open reports of 500/400 errors on act_runner. The runner's cache server, by contrast, is confirmed enabled and reachable (cache: {enabled: true, host: "172.18.0.1", port: 42641} read directly from the running gitea-runner container's /data/config.yaml) — the recommended pattern is to reuse actions/cache@v4, keyed on the exact commit SHA, as the transfer mechanism between the desktop and publish jobs instead of the artifact actions.

Everything downstream of the built files (GET /desktop/latest, GET /desktop/download/:platform, the login-page link, the settings page, the tray "Update herunterladen" item) has a direct precedent already in this codebase (DkvService.getExportFile for path-safety, apps/web/src/lib/app-version.ts for the memoized public-fetch pattern, @Public() + global JwtAuthGuard for making two new routes unauthenticated).

Primary recommendation: Keep tauri.conf.json/Cargo.toml version as a plain X.Y.Z always (never pre-release/build metadata); do the beta-vs-release distinction entirely in the CI script layer (filename suffix + manifest.json fields) and pass the built Windows/Linux artifacts from the desktop job to the publish job via actions/cache@v4 keyed on gitea.sha, not via the artifact-upload actions.

Architectural Responsibility Map

Capability Primary Tier Secondary Tier Rationale
Windows/Linux package build CI / Build (Gitea Actions, self-hosted act_runner) — Cross-compilation only makes sense at build time; no runtime tier owns it
Package storage & serving API / Backend (apps/api/src/desktop/) CDN/Static (Gitea Release assets, D-01 secondary path) D-08 explicitly makes the API the primary distribution path so live servers need no Gitea reachability; Gitea Release is the secondary/no-Tessera-account path
Download link visibility Frontend Server (SSR/CSR mix, Next.js client components) API (provides the data the link renders from) Login page and Settings page are 'use client' components fetching /desktop/latest; the API is the source of truth, the frontend only renders/hides
Version comparison & update notice Client / Desktop (Tauri lib.rs, Rust) API (/desktop/latest as the oracle) The comparison logic runs inside the installed desktop binary; the API only serves the current truth
Release asset publication CI / Build (publish-release.sh) — Gitea Release API call, same job that already creates the release text from CHANGELOG.md
Autostart toggle Client / Desktop (Tauri tray, tauri-plugin-autostart) OS (Windows registry / Linux desktop autostart entry, via the plugin) No client settings page exists (D-14); the tray is the only UI surface, but the actual OS registration is done by the plugin, not by Tessera code

Standard Stack

Core (already installed — Phase 6, confirmed by reading Cargo.lock/package.json this session)

Library Version Purpose Why Standard
tauri 2.11.3 [VERIFIED: apps/desktop/src-tauri/Cargo.lock:3660-3662 — name = "tauri" / version = "2.11.3"] Desktop shell Already the project's chosen wrapper (Phase 6); NSIS bundler fix for build-metadata (tauri-bundler 2.2.3+) is included
reqwest 0.12.28 [VERIFIED: apps/desktop/src-tauri/Cargo.lock:2907-2911] HTTP calls to /desktop/latest and /health/version Already used for the existing version check; default native-tls feature resolves to schannel (pure Rust FFI, no OpenSSL) when the compile target is x86_64-pc-windows-msvc, so cross-compiling needs no extra TLS configuration
tauri-plugin-autostart 2.5.1 [VERIFIED: apps/desktop/src-tauri/Cargo.lock:3789-3791] Autostart toggle in tray (D-14) Already installed; ManagerExt trait exposes app.autolaunch().enable()/disable()/is_enabled() [CITED: v2.tauri.app/plugin/autostart/]
tauri-plugin-notification 2.3.3 [VERIFIED: apps/desktop/src-tauri/Cargo.lock:3803-3805] Update-available toast Already installed and used in lib.rs:82-101
tauri-plugin-store 2.4.3 [VERIFIED: apps/desktop/src-tauri/Cargo.lock:3822-3824] Persisted server_url Already installed (Phase 6)
tauri-plugin-window-state 2.4.1 [VERIFIED: apps/desktop/src-tauri/Cargo.lock:3838-3840] Window size/position Already installed (Phase 6)

New for this phase

Library Version Purpose Why Standard
tauri-plugin-opener 2.5.5 stable [VERIFIED: crates.io registry API max_stable_version field, and cargo metadata repoUrl: github.com/tauri-apps/plugins-workspace, weeklyDownloads: 374325, package-legitimacy verdict OK] Opens {server}/settings/general/desktop in the system browser from the tray "Update herunterladen" item (D-13) Official Tauri plugin, purpose-built for exactly this (app.opener().open_url(url, None::<&str>)); the alternative named in D-13 ("open-crate") is a third-party general-purpose crate with no Tauri capability-system integration — tauri-plugin-opener is the maintained, capability-scoped choice
cargo-xwin 0.23.1 stable [VERIFIED: crates.io registry API max_stable_version, repoUrl: github.com/rust-cross/cargo-xwin, weeklyDownloads: 63889, package-legitimacy verdict OK] Cross-compile runner for cargo tauri build --runner cargo-xwin --target x86_64-pc-windows-msvc Official Tauri-documented cross-compile path [CITED: v2.tauri.app "Cross-Platform Compilation" — Ubuntu install steps: apt install lld llvm nsis, rustup target add x86_64-pc-windows-msvc, cargo install --locked cargo-xwin]
nsis, lld, llvm (apt packages) Ubuntu 24.04 repo versions (not independently pinned; apt-get install resolves current) NSIS installer generation + linker/toolchain for the MSVC cross-target Same official doc as above

Alternatives Considered

Instead of Could Use Tradeoff
tauri-plugin-opener open crate (named as an option in D-13) open has no Tauri capability/permission integration (any Rust code can call it unscoped) and is not part of the audited plugin workspace; tauri-plugin-opener is the maintained official path with an explicit opener:allow-open-url capability that can be scoped to https://* only
actions/cache@v4 for cross-job artifact transfer actions/upload-artifact / download-artifact (v3 or v4) Documented to fail on Gitea: v4 aborts on a GHES-detection check, v3 has open 500/400 error reports specifically on act_runner [CITED: github.com/go-gitea/gitea issues #28853, #31256, #27314, #25590]; the cache server, by contrast, was read directly from the running gitea-runner container config and confirmed enabled
Plain X.Y.Z version always in tauri.conf.json Semver pre-release/build metadata (X.Y.Z-beta+<sha>) for beta builds Technically survives on tauri-bundler ≥2.2.3 (coerced with a warning) [CITED: github.com/tauri-apps/tauri PR #12136], but D-07 explicitly forbids any form that risks failing the Windows build — plain numeric is the zero-risk choice and keeps Cargo.toml's own semver validation trivially satisfied too
Single combined desktop-build-and-publish job Separate desktop job (as D-06 requires) D-06 is a locked decision; documented here only as the reason the cache-based artifact-transfer pattern above is needed

Installation (CI job, apt + cargo):

# Runner image (ubuntu-latest, confirmed Ubuntu 24.04, ~nothing of this preinstalled)
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
  lld llvm clang nsis \
  libwebkit2gtk-4.1-dev libjavascriptcoregtk-4.1-dev \
  libayatana-appindicator3-dev librsvg2-dev \
  libgtk-3-dev libssl-dev patchelf file xdg-utils

rustup target add x86_64-pc-windows-msvc
cargo install --locked cargo-xwin

Version verification note: nsis/lld/llvm/clang come from Ubuntu 24.04's own apt repos and are not independently version-pinned by this project (consistent with how node:24-alpine and other base images are handled elsewhere in this repo) — the CI log itself is the record of exact resolved versions.

Package Legitimacy Audit

Package Registry Age Downloads Source Repo Verdict Disposition
tauri-plugin-opener crates published 2024-11-11 374,325/wk github.com/tauri-apps/plugins-workspace OK Approved
cargo-xwin crates published 2022-03-06 63,889/wk github.com/rust-cross/cargo-xwin OK Approved

Packages removed due to [SLOP] verdict: none Packages flagged as suspicious [SUS]: none

All other packages used in this phase (tauri, reqwest, tauri-plugin-autostart, tauri-plugin-notification, tauri-plugin-store, tauri-plugin-window-state) are already installed dependencies from Phase 6, read directly from Cargo.lock this session — no new legitimacy check needed for already-vendored, already-audited packages.

Architecture Patterns

System Architecture Diagram

Release tag vX.Y.Z pushed
        │
        ▼
┌─────────────────┐     needs      ┌──────────────────────┐
│  quality / test  │ ─────────────▶ │      desktop (NEW)     │
│  (existing jobs)  │                │ 1. desktop-version.sh: │
└─────────────────┘                │    write X.Y.Z into    │
                                     │    tauri.conf.json +   │
                                     │    Cargo.toml          │
                                     │ 2. apt install nsis/   │
                                     │    lld/llvm/webkit2gtk │
                                     │ 3. cargo tauri build   │
                                     │    (AppImage, Linux)   │
                                     │ 4. cargo tauri build   │
                                     │    --runner cargo-xwin │
                                     │    --target …-msvc     │
                                     │    (NSIS, Windows)     │
                                     │ 5. rename outputs to   │
                                     │    canonical filenames │
                                     │ 6. actions/cache SAVE  │
                                     │    key: desktop-dist-  │
                                     │    ${{ gitea.sha }}    │
                                     └──────────┬────────────┘
                                                 │ needs
                                                 ▼
                                     ┌──────────────────────┐
                                     │   publish (existing)   │
                                     │ 1. actions/cache       │
                                     │    RESTORE same key    │
                                     │    (hard-fail if miss) │
                                     │ 2. build manifest.json │
                                     │    (version/name/size/ │
                                     │    sha256)              │
                                     │ 3. docker build (api)  │
                                     │    COPY desktop-dist/  │
                                     │    → /app/desktop-dist/│
                                     │ 4. docker push api/web │
                                     │ 5. publish-release.sh: │
                                     │    create/update Gitea │
                                     │    Release text (exist)│
                                     │    + upload 2 assets   │
                                     │    (NEW)                │
                                     └──────────┬────────────┘
                                                 │
                          ┌──────────────────────┼──────────────────────┐
                          ▼                                             ▼
              ┌───────────────────────┐                    ┌───────────────────────┐
              │  Gitea Release assets  │                    │   Running API container│
              │  Tessera-Setup-X.Y.Z   │                    │   /app/desktop-dist/   │
              │  .exe, Tessera-X.Y.Z   │                    │   manifest.json + 2    │
              │  .AppImage (D-01)      │                    │   package files (D-08) │
              └───────────────────────┘                    └──────────┬────────────┘
                                                                        │ serves
                                                    ┌───────────────────┼───────────────────┐
                                                    ▼                                       ▼
                                       GET /desktop/latest                    GET /desktop/download/:platform
                                       (public, manifest→JSON)                (public, streams file, Content-Disposition)
                                                    │                                       │
                          ┌─────────────────────────┼───────────────────────────────────────┤
                          ▼                                                                 │
              Login page + Settings→Desktop-App                                             │
              (Next.js client components fetch                                              │
              /desktop/latest, hide link on 404)                                            │
                                                                                              │
              Installed desktop client (lib.rs)                                             │
              fetches /desktop/latest on startup,  ─── opens {server}/settings/… in browser ─┘
              compares CARGO_PKG_VERSION,               via tauri-plugin-opener when user
              shows notification + tray item             clicks "Update herunterladen"
apps/api/src/desktop/
├── desktop.module.ts       # registers controller + service
├── desktop.controller.ts   # GET /desktop/latest, GET /desktop/download/:platform (both @Public())
├── desktop.service.ts      # reads manifest.json, validates platform whitelist, resolves file path
└── desktop.service.spec.ts # manifest missing → 404, platform whitelist, path-traversal rejection

.gitea/scripts/
├── desktop-version.sh      # NEW — writes X.Y.Z into tauri.conf.json + Cargo.toml pre-build
├── publish-images.sh       # MODIFIED — copies desktop-dist/ into API build context before docker build
└── publish-release.sh      # MODIFIED — uploads 2 release assets after creating/updating the release text

apps/web/src/
├── lib/desktop.ts                              # NEW — loadDesktopLatest(), mirrors lib/app-version.ts pattern
├── app/(auth)/login/page.tsx                   # MODIFIED — small download link block
└── app/(portal)/settings/general/desktop/      # NEW — page.tsx, mirrors settings/general/account/
    └── page.tsx

apps/desktop/src-tauri/src/lib.rs               # MODIFIED — /desktop/latest check, opener call, autostart tray item

Pattern 1: Cross-compile Windows NSIS on the Linux runner

What: Use cargo-xwin as the Cargo "runner" so rustc/link.exe calls are transparently redirected to lld-link against a downloaded Windows SDK/MSVC CRT, then Tauri's bundler shells out to makensis (from the nsis apt package) to produce the .exe. When to use: Any CI job building a Windows Tauri installer without a Windows machine. Example:

# Source: v2.tauri.app "Distribute > Windows Installer" (Cross-Compiling section)
sudo apt install lld llvm nsis
rustup target add x86_64-pc-windows-msvc
cargo install --locked cargo-xwin

cd apps/desktop
pnpm tauri build --runner cargo-xwin --target x86_64-pc-windows-msvc
# Output: apps/desktop/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe

Set XWIN_CACHE_DIR to a stable, cacheable path so the Windows SDK (multi-hundred-MB download) is reused across CI runs [CITED: v2.tauri.app cross-compile docs].

Pattern 2: Public, whitelist-guarded file streaming (NestJS)

What: A @Public() controller route that resolves a filename only from a trusted manifest — never from the request path directly — and streams it with Content-Disposition: attachment. When to use: Any unauthenticated download endpoint serving files from disk. Example (adapted from the existing DkvService.getExportFile traversal-guard pattern, read this session — apps/api/src/dkv/dkv.service.ts:703-729):

// apps/api/src/desktop/desktop.service.ts
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}`);
  // entry.name comes ONLY from manifest.json (written by CI, never from the request)
  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.controller.ts
@Public()
@Get('download/:platform')
async download(@Param('platform') platform: string, @Res({ passthrough: true }) res: Response) {
  const { stream, entry } = await this.desktopService.getPackageStream(platform);
  res.set({
    'Content-Disposition': `attachment; filename="${entry.name}"`,
    'Content-Type': 'application/octet-stream',
    'Content-Length': String(entry.size),
  });
  return new StreamableFile(stream);
}

[CITED: docs.nestjs.com Techniques > Streaming Files, for the StreamableFile + passthrough: true requirement]

Pattern 3: Memoized public fetch, fail-silent-to-null (already established in this codebase)

What: A single in-module promise that fetches a public API endpoint once per page load and resolves to null on any error — the caller uses null to hide UI rather than show an error. When to use: Exactly the login-page/settings download-link visibility rule in D-12 ("nur wenn /desktop/latest antwortet"). Example (this is the EXISTING file, read verbatim this session — apps/web/src/lib/app-version.ts:50-63 — the new lib/desktop.ts should follow the identical shape):

// Source: apps/web/src/lib/app-version.ts (existing pattern, 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;
}

Note: the login page renders before authentication, so credentials: 'include' is irrelevant there (no cookie yet) but harmless — /desktop/latest is @Public() so it responds regardless of cookie presence.

Anti-Patterns to Avoid

  • Relying on Tauri's default NSIS/AppImage output filename: The exact default naming convention was not confirmed against an authoritative source this session (see Open Questions). Do not hardcode an assumption about it in the CI script — instead, find the produced .exe/.AppImage in the bundle output directory and explicitly copy/rename it to the canonical Tessera-Setup-X.Y.Z.exe / Tessera-X.Y.Z.AppImage name before it enters manifest.json or gets uploaded anywhere.
  • Using actions/upload-artifact/download-artifact for the desktop→publish handoff: documented failure modes on Gitea (see Standard Stack alternatives table). Use actions/cache instead.
  • Putting build metadata / pre-release identifiers in tauri.conf.json version: even though newer tauri-bundler versions coerce rather than fail, D-07 forbids any risk here — keep it plain X.Y.Z always.
  • Resolving the download filename from the request's :platform param directly: always resolve through manifest.json's files[platform].name, matching D-10's explicit instruction and the DkvService precedent.

Don't Hand-Roll

Problem Don't Build Use Instead Why
Windows cross-compilation toolchain wiring (linker selection, target CRT, SDK download) A custom Docker image or manual lld-link invocation script cargo-xwin It already solves SDK download, caching (XWIN_CACHE_DIR), and Cargo [target.x86_64-pc-windows-msvc] linker/runner wiring; reinventing this is exactly the kind of "weeks of work" the project's own CLAUDE.md warns against for infra
Opening a URL in the user's default browser from Rust Manual std::process::Command::new("xdg-open"/"cmd /C start") platform branching tauri-plugin-opener Official plugin already handles per-OS differences and integrates with Tauri's capability/permission system, so the allowed URL scope (https://*) is declared, not implicit
Cross-job build artifact passing on a fragile CI backend A home-grown "upload to a scratch S3/webdav and curl it back down" script actions/cache@v4 (already confirmed enabled on this runner) keyed on gitea.sha The cache backend was directly verified running and reachable; building a bespoke artifact-transfer mechanism duplicates infrastructure that already exists and works, for no benefit
Release asset upload retry/idempotency logic Custom "check if uploaded, else force-overwrite via unusual heuristics" Gitea's release-assets API: GET the release, if an asset with the same name exists DELETE it first (DELETE /repos/{owner}/{repo}/releases/{id}/assets/{asset_id}), then POST fresh — same idempotent create/update-by-lookup shape publish-release.sh already uses for the release itself Keeps the new logic consistent with the existing script's own idempotency pattern (GET-by-tag → PATCH-or-POST), rather than inventing a second idiom in the same file

Key insight: Every piece of new infrastructure in this phase (cross-compile toolchain, URL-opening, cross-job caching, release-asset upload) already has an official, maintained, or in-repo precedent. The research effort here is almost entirely "find the existing tool/pattern and confirm it actually works on this runner" rather than designing anything new.

Common Pitfalls

Pitfall 1: actions/upload-artifact/download-artifact silently or loudly fail on this Gitea instance

What goes wrong: The desktop job builds packages but the publish job can't see them; CI either errors outright (v4 GHES-detection abort) or the job "succeeds" with an empty artifact. Why it happens: Gitea's Actions artifact backend does not fully match GitHub's; actions/upload-artifact@v4+ explicitly checks for GHES and refuses to run on non-GitHub-recognized servers, and v3 has multiple open upstream issues specific to act_runner (400/500 errors) [CITED: github.com/go-gitea/gitea issues #28853, #31256, #27314, #25590]. How to avoid: Use actions/cache@v4 save/restore keyed on the exact commit SHA (desktop-dist-${{ gitea.sha }}, no restore-keys fallback) as the transfer mechanism instead. The publish job's cache-restore step must hard-fail (e.g. test -f desktop-dist/manifest.json || exit 1) if the cache misses, rather than silently building an API image without desktop packages. Warning signs: publish job succeeds but /app/desktop-dist/ is empty in the built image; /desktop/latest returns 404 in production despite a tag having been pushed.

Pitfall 2: NSIS numeric-only version field

What goes wrong: A tauri.conf.json version containing pre-release/build metadata (e.g. 1.2.0-beta+abc1234) either hard-fails the Windows build (older tauri-bundler) or gets silently coerced with a warning (tauri-bundler ≥2.2.3, which is what 2.11.3 ships). Why it happens: NSIS's VIProductVersion/VIFileVersion map to Windows' VS_FixedFileInfo, which is numeric-only X.X.X.X by OS-level requirement — this is not a Tauri choice, it's inherited from the Windows resource format [CITED: github.com/tauri-apps/tauri issue #8038]. How to avoid: desktop-version.sh always writes plain X.Y.Z (the latest tag, stripped of v) into both tauri.conf.json and Cargo.toml, for every build — tag builds and beta/main builds alike. The beta-vs-tag distinction lives only in: (a) the output filename suffix appended by the CI script after the build (e.g. Tessera-Setup-1.2.0-beta.<7-char-sha>.exe for main-branch builds, Tessera-Setup-1.2.0.exe for the tag build), and (b) manifest.json's commit/buildTime fields (same shape as the existing VersionResponse/app-version.ts API pattern). Warning signs: CI log contains optional build metadata in app version must be numeric-only or a coercion warning; installed .exe's file-properties version differs from what was expected.

Pitfall 3: reqwest's TLS backend resolving differently per target — verify, don't assume

What goes wrong: A naive assumption that cross-compiling any Rust crate with TLS to Windows requires bundling OpenSSL for the build host. Why it happens: reqwest's default-tls feature is target-conditional: Linux/Unix → openssl, Windows → schannel, macOS → security-framework. Cargo resolves dependencies per target triple, so cross-compiling to x86_64-pc-windows-msvc only pulls in schannel (a pure-Rust FFI crate against Windows' built-in Cryptography API), not openssl-sys — confirmed by reading this project's own Cargo.lock, which lists both native-tls/openssl-sys (for the host's linux-gnu default target) and schannel/rustls (present as target-conditional deps in the same lockfile) [VERIFIED: apps/desktop/src-tauri/Cargo.lock — native-tls at line 2114, openssl-sys at line 2445, rustls at line 3024, schannel at line 3078]. How to avoid: No action needed — the existing Cargo.toml reqwest = { version = "0.12", features = ["json"] } (default-tls) should cross-compile to Windows without an OpenSSL cross-build step. If the CI run proves otherwise (D-16's iteration loop), the fallback is adding default-features = false, features = ["json", "rustls-tls"] to force a pure-Rust TLS stack. Warning signs: A build error mentioning openssl-sys failing to find libssl/pkg-config when cross-compiling — this would indicate the assumption above needs revisiting for this specific dependency graph.

Pitfall 4: Assuming the default Tauri bundle output filename

What goes wrong: CI script hardcodes an assumed filename pattern (e.g. tessera-desktop_1.2.0_x64_en-US.msi-style guesses) that doesn't match what the installed tauri-bundler 2.11.3 actually produces, so the find/copy step in the CI script silently finds nothing or the wrong file. Why it happens: The exact default naming convention was not confirmed against an authoritative primary source this session (see Open Questions) — training-data recall of Tauri's naming scheme conflicts across versions and is not reliable enough to hardcode. How to avoid: Never hardcode the exact default filename. Instead: find target/release/bundle/appimage -name '*.AppImage' and find target/x86_64-pc-windows-msvc/release/bundle/nsis -name '*.exe', taking whatever single file matches (the bundle directories are exclusive to their target/format), then explicitly cp/mv to the canonical name. This is format/version-independent by construction. Warning signs: CI script's copy step errors with "no such file" even though the build itself succeeded.

Pitfall 5: apt-get install list incompleteness on the bare ubuntu-latest runner image

What goes wrong: The Linux AppImage build (needed even on the desktop job, not just locally) fails partway through cargo build with missing pkg-config-resolved headers, because the runner image ships none of the GTK/WebKit dev packages the dev machine happens to already have installed. Why it happens: Confirmed by directly running dpkg -l inside a fresh gitea/runner-images:ubuntu-latest container this session — it has librsvg2-dev and file but not libwebkit2gtk-4.1-dev, libayatana-appindicator3-dev, libgtk-3-dev, patchelf, nsis, or a Rust toolchain. The dev machine (where a local build was previously proven per 06-02-SUMMARY.md) is a different, more fully-provisioned environment and is not representative of the CI runner. How to avoid: The desktop job's apt-install step must be complete and explicit (see Standard Stack "Installation" above) — do not assume anything beyond librsvg2-dev and file is present. Warning signs: cargo build fails with The system library 'javascriptcoregtk-4.1' required by crate 'javascriptcore-rs-sys' was not found or similar pkg-config errors.

Code Examples

1. desktop-version.sh (new script, mirrors publish-images.sh's POSIX-sh style)

#!/bin/sh
# Source: pattern adapted from .gitea/scripts/publish-images.sh (read this session,
# same set -eu / GITHUB_REF-only-decision style, same repo).
set -eu

TAG_VERSION="$(git describe --tags --abbrev=0 2>/dev/null || echo v0.0.0)"
VERSION="${TAG_VERSION#v}"   # plain X.Y.Z, per Pitfall 2 — never pre-release/build metadata

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

2. CI workflow job additions (.gitea/workflows/ci.yml)

# Source: pattern follows the existing quality/test/publish job shape in this file (read this session)
  desktop:
    name: Desktop-Pakete bauen
    runs-on: ubuntu-latest
    needs: test
    if: gitea.ref == 'refs/heads/main' || startsWith(gitea.ref, 'refs/tags/v')
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - uses: actions/setup-node@v4
        with:
          node-version: 24

      - name: Cargo/xwin Zwischenspeicher
        uses: actions/cache@v4
        with:
          path: |
            ~/.cargo/registry
            ~/.cargo/git
            ~/.cargo/bin
            apps/desktop/src-tauri/target
            ~/.cache/cargo-xwin
          key: desktop-cargo-${{ hashFiles('apps/desktop/src-tauri/Cargo.lock') }}
          restore-keys: desktop-cargo-

      - name: Systemabhaengigkeiten
        run: |
          sudo apt-get update
          sudo apt-get install -y --no-install-recommends \
            lld llvm clang nsis \
            libwebkit2gtk-4.1-dev libjavascriptcoregtk-4.1-dev \
            libayatana-appindicator3-dev librsvg2-dev \
            libgtk-3-dev libssl-dev patchelf file xdg-utils

      - name: Rust-Ziel + cargo-xwin
        run: |
          rustup target add x86_64-pc-windows-msvc
          command -v cargo-xwin >/dev/null 2>&1 || cargo install --locked cargo-xwin

      - name: Enable pnpm via corepack
        run: corepack enable && corepack prepare pnpm@9.15.0 --activate

      - name: Install dependencies
        run: pnpm install --frozen-lockfile

      - name: Version in tauri.conf.json/Cargo.toml setzen
        run: sh .gitea/scripts/desktop-version.sh

      - name: Linux AppImage bauen
        working-directory: apps/desktop
        run: pnpm tauri build --bundles appimage

      - name: Windows NSIS Cross-Bau
        working-directory: apps/desktop
        env:
          XWIN_CACHE_DIR: ${{ github.workspace }}/.xwin-cache
        run: pnpm tauri build --runner cargo-xwin --target x86_64-pc-windows-msvc --bundles nsis

      - name: Pakete einsammeln und umbenennen
        run: |
          mkdir -p desktop-dist
          APPIMAGE=$(find apps/desktop/src-tauri/target/release/bundle/appimage -name '*.AppImage' | head -1)
          EXE=$(find apps/desktop/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis -name '*.exe' | head -1)
          VERSION=$(jq -r .version apps/desktop/src-tauri/tauri.conf.json)
          cp "$APPIMAGE" "desktop-dist/Tessera-$VERSION.AppImage"
          cp "$EXE" "desktop-dist/Tessera-Setup-$VERSION.exe"

      - name: In Zwischenspeicher ablegen (Uebergabe an publish-Job)
        uses: actions/cache/save@v4
        with:
          path: desktop-dist
          key: desktop-dist-${{ gitea.sha }}

Then in the publish job, before docker build:

      - name: Desktop-Pakete aus dem Zwischenspeicher holen
        uses: actions/cache/restore@v4
        with:
          path: desktop-dist
          key: desktop-dist-${{ gitea.sha }}
          fail-on-cache-miss: true

3. tauri.conf.json version override via CLI (alternative to sed/jq, for reference — not the chosen approach since D-07 wants the file itself updated)

# Source: v2.tauri.app Configuration Files docs (RFC 7396 JSON merge)
tauri build --config '{"version":"1.2.0"}'

Not used here because Cargo.toml's version (read at compile time via env!("CARGO_PKG_VERSION") in lib.rs:85) also needs updating, and --config only patches the Tauri-side config, not Cargo.toml.

4. Rust: version check against /desktop/latest + opener (replaces the current /health/version compare in lib.rs:82-101)

// Adapts the EXISTING async version-check block in lib.rs (read this session), redirected
// to /desktop/latest and adding the opener call + tray menu item.
use tauri_plugin_opener::OpenerExt;

#[derive(serde::Deserialize)]
struct DesktopLatest {
    version: String,
}

// inside the existing async_runtime::spawn block, replace the /health/version call:
let url = format!("{}/desktop/latest", server_url.trim_end_matches('/'));
if let Ok(resp) = reqwest::get(&url).await {
    if let Ok(info) = resp.json::<DesktopLatest>().await {
        if info.version != app_version {
            let _ = app_handle.notification().builder()
                .title("Tessera Update")
                .body(format!("Neue Version {} verfuegbar", info.version))
                .show();
            // enable the tray "Update herunterladen" item here (menu item toggling
            // requires holding a handle to it created during setup, not shown here)
        }
    }
}

// on tray menu event "update":
"update" => {
    let url = format!("{}/settings/general/desktop", server_url);
    let _ = app.opener().open_url(url, None::<&str>);
}

Capability addition needed in apps/desktop/src-tauri/capabilities/default.json:

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

(http://* included because D-02 allows non-HTTPS server addresses for internal LAN use, same reasoning already documented in setup.html's HTTP warning.)

5. Autostart tray checkbox (D-14)

// Source: v2.tauri.app plugin/autostart/ (fetched this session)
use tauri_plugin_autostart::ManagerExt;

let autostart_manager = app.autolaunch();
let is_enabled = autostart_manager.is_enabled().unwrap_or(false);
let autostart_item = CheckMenuItemBuilder::with_id("autostart", "Mit Windows starten")
    .checked(is_enabled)
    .build(app)?;
// on_menu_event "autostart":
"autostart" => {
    let mgr = app.autolaunch();
    if mgr.is_enabled().unwrap_or(false) {
        let _ = mgr.disable();
    } else {
        let _ = mgr.enable();
    }
}

6. publish-release.sh extension — idempotent asset upload

# Source: pattern extends the existing idempotent GET-then-PATCH-or-POST shape
# already in this file (read this session, lines 125-155) to asset upload.
upload_asset() {
  FILE="$1"; NAME="$2"; RELEASE_ID="$3"
  # Idempotency: find + delete any existing asset with the same name first.
  ASSETS=$(curl -sS --header @"$HDR" "$RELEASES_URL/$RELEASE_ID/assets")
  EXISTING_ID=$(echo "$ASSETS" | jq -r --arg n "$NAME" '.[] | select(.name==$n) | .id')
  if [ -n "$EXISTING_ID" ]; then
    curl -sS --header @"$HDR" -X DELETE "$RELEASES_URL/$RELEASE_ID/assets/$EXISTING_ID" >/dev/null
  fi
  curl -sS --header @"$HDR" -X POST \
    -F "attachment=@${FILE};filename=${NAME}" \
    "$RELEASES_URL/$RELEASE_ID/assets?name=${NAME}"
}

[CITED: Gitea forum "Create new Release via API with attachment" — POST /repos/{owner}/{repo}/releases/{id}/assets?name=... with multipart attachment field]

7. Manifest schema (/app/desktop-dist/manifest.json, D-08)

// packages/shared/src/index.ts — new interface, same file/pattern as VersionResponse
export interface DesktopManifestFile {
  name: string;
  size: number;
  sha256: string;
}
export interface DesktopManifest {
  version: string;
  commit: string;
  buildTime: string;
  files: {
    windows: DesktopManifestFile;
    linux: DesktopManifestFile;
  };
}

Generated in the publish job after the cache-restore step:

sha256sum desktop-dist/Tessera-Setup-*.exe | awk '{print $1}'

8. apps/web/src/lib/desktop.ts (mirrors lib/app-version.ts exactly)

// Mirrors the EXISTING apps/web/src/lib/app-version.ts pattern (read this session, verbatim structure)
export interface DesktopLatestInfo {
  version: string;
  files: {
    windows: { name: string; size: number; sha256: string; url: string };
    linux: { name: string; size: number; sha256: string; url: string };
  };
}

const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
let desktopLatestPromise: Promise<DesktopLatestInfo | null> | null = null;

export function loadDesktopLatest(): Promise<DesktopLatestInfo | null> {
  if (!desktopLatestPromise) {
    desktopLatestPromise = fetch(`${API_URL}/desktop/latest`)
      .then((res) => (res.ok ? (res.json() as Promise<DesktopLatestInfo>) : null))
      .catch(() => null);
  }
  return desktopLatestPromise;
}

Assumptions Log

# Claim Section Risk if Wrong
A1 Tauri's exact default bundle output filename pattern for AppImage/NSIS in 2.11.3 Pitfall 4, Code Example #2 Low — the recommended find-then-rename pattern is deliberately filename-agnostic, so this assumption has no load-bearing effect on the plan
A2 actions/cache@v4 (not an older pinned minor) works correctly against this runner's local cache server for both save and restore sub-actions with fail-on-cache-miss Code Example #2, Pitfall 1 Medium — if the exact cache-action version/flag set behaves differently, the publish job could silently build without desktop packages instead of hard-failing; D-16's iteration loop is the designed safety net for exactly this
A3 reqwest cross-compiling to x86_64-pc-windows-msvc will not need OpenSSL, based on Cargo.lock's target-conditional dependency graph rather than an actual cross-build having been run this session Pitfall 3 Low-Medium — if wrong, the fallback (rustls-tls feature) is already documented and simple to apply within D-16's iteration loop
A4 Ubuntu 24.04 apt package names (libwebkit2gtk-4.1-dev, libayatana-appindicator3-dev, etc.) are current/correct for Tauri 2 on this exact runner image, based on the dev machine's already-installed package list plus official Tauri Linux prerequisites docs, not a fresh apt-get install actually run inside the runner container this session Standard Stack Installation, Pitfall 5 Low — apt-get install will error clearly and immediately if a package name is wrong/renamed, easily caught in D-16's iteration loop

If this table is empty: N/A — see above.

Open Questions

  1. Exact default filename Tauri 2.11.3's bundler gives the NSIS .exe and the AppImage

    • What we know: Tauri v1 used ${productName}_${version}_${arch}-setup.exe-style names; v2's exact current default was not confirmed against an authoritative primary source this session.
    • What's unclear: Whether that pattern still holds in 2.11.3, and whether productName: "Tessera" (with no space) changes it.
    • Recommendation: Don't rely on it — the CI script finds the single produced file by extension in the known bundle output directory (target/.../bundle/appimage/*.AppImage, target/.../bundle/nsis/*.exe) and explicitly renames it. Already reflected in Code Example #2 and Pitfall 4.
  2. Whether the Windows cross-build actually succeeds end-to-end on the first pipeline run

    • What we know: Every individual piece (cargo-xwin, apt packages, reqwest TLS target-resolution, NSIS numeric-version handling) is verified/cited individually; nothing here was run as a full end-to-end Windows cross-build in this research session (no Windows target build was executed — only inspected via Cargo.lock and official docs).
    • What's unclear: Whether some interaction between Tauri's own build.rs (icon embedding, resource compilation via llvm-rc) and the cross-toolchain surfaces an issue not visible from documentation alone.
    • Recommendation: This is exactly what D-16's "iteration loop" is designed for (push, read the CI failure, adjust, repeat) — the plan should budget explicit time/tasks for this rather than assuming a first-try green run.
  3. Whether actions/cache@v4's save/restore matches Gitea's cache-server protocol version without any special pinning

    • What we know: The cache server is confirmed enabled and reachable; general Gitea docs describe actions/cache compatibility as generally working, with some version-specific nuance around cache-service v1 vs v2 API detection.
    • What's unclear: Whether this specific Gitea/act_runner version (not independently version-checked this session beyond confirming the container is running) needs a specific actions/cache action version pin.
    • Recommendation: Use actions/cache@v4 as the first attempt (matches actions/checkout@v4/actions/setup-node@v4 versioning already proven working in this repo's CI); if it fails, the fallback is actions/cache@v3 — this should be a fast, cheap thing to discover in the D-16 iteration loop, not something to pre-solve via more research.

Environment Availability

Dependency Required By Available Version Fallback
Rust/Cargo (dev machine) Local cargo check/AppImage proof before push (D-16) ✓ cargo 1.96.0, rustc 1.96.0 —
webkit2gtk-4.1-dev, appindicator3-dev, librsvg2-dev, libgtk-3-dev (dev machine) Local Linux AppImage build ✓ already installed system-wide (libwebkit2gtk-4.1-dev 2.52.6, libayatana-appindicator3-dev 0.5.94, librsvg2-dev 2.60.0, libgtk-3-dev 3.24.49) —
Rust toolchain (CI runner, gitea/runner-images:ubuntu-latest) desktop CI job ✗ — Install via CI step (not preinstalled in the runner image, confirmed by running a fresh container this session)
webkit2gtk/appindicator/gtk dev headers (CI runner) desktop CI job (AppImage step) ✗ (only librsvg2-dev, file present) — apt-get install step, full list in Standard Stack
nsis, lld, llvm, cargo-xwin (CI runner) desktop CI job (NSIS cross-build step) ✗ — apt-get install + cargo install --locked cargo-xwin step
act_runner cache server actions/cache for both the Cargo/xwin cache and the desktop-dist cross-job handoff ✓ enabled, host: 172.18.0.1, port: 42641 (read directly from the running gitea-runner container's /data/config.yaml this session) —
Docker (dev machine, for inspecting the runner image) Research verification only, not part of the shipped pipeline ✓ 29.8.0 —

Missing dependencies with no fallback: none — everything missing on the CI runner is installable within the job itself. Missing dependencies with fallback: none beyond the installable-in-job items above.

Validation Architecture

Test Framework

Property Value
Framework Vitest (apps/api: 3.2.6, apps/web: 4.1.9 — different majors, pre-existing, not this phase's concern)
Config file apps/api/vitest.config.ts (environment: 'node', include: ['src/**/*.spec.ts']), apps/web/vitest.config.ts (environment: 'jsdom')
Quick run command pnpm --filter @tessera/api test -- src/desktop, pnpm --filter @tessera/web test -- desktop
Full suite command pnpm test (Turborepo, all workspaces)

Phase Requirements → Test Map

Req ID Behavior Test Type Automated Command File Exists?
DESK-03 GET /desktop/latest returns manifest JSON when present unit pnpm --filter @tessera/api test -- desktop.service.spec.ts ❌ Wave 0
DESK-03 GET /desktop/latest returns 404 when manifest/directory missing unit same file ❌ Wave 0
DESK-10 (platform whitelist, part of D-10) GET /desktop/download/:platform rejects unknown platform with 400 unit same file ❌ Wave 0
DESK-10 (path safety, part of D-10) Filename never taken from request, only from manifest — traversal attempt (../../etc/passwd) rejected before any filesystem access unit same file ❌ Wave 0
DESK-03 Login page shows/hides download link based on /desktop/latest response component pnpm --filter @tessera/web test -- login ❌ Wave 0 (extends existing login test file if present, else new)
DESK-03 Settings → Desktop-App page renders version/size/buttons component pnpm --filter @tessera/web test -- settings/general/desktop ❌ Wave 0
DESK-01/05 Rust compiles cleanly with new plugin/capability changes manual (cargo check/clippy in CI, per D-16) cd apps/desktop/src-tauri && cargo check && cargo clippy N/A — not a Vitest test, CI step
DESK-01 Local Linux AppImage builds successfully before push (D-16 proof step) manual cd apps/desktop && pnpm tauri build --bundles appimage N/A — manual proof, not automated test
DESK-04/05 Windows NSIS cross-build produces a valid .exe in CI manual (only provable in pipeline, per D-16) pipeline run, inspect desktop job logs + artifact N/A — cannot be proven locally without a Windows toolchain

Sampling Rate

  • Per task commit: pnpm --filter @tessera/api test -- desktop, pnpm --filter @tessera/web test -- desktop
  • Per wave merge: pnpm test (full Turborepo suite)
  • Phase gate: Full suite green before /gsd-verify-work; additionally, per D-16, a green CI pipeline run producing both Tessera-Setup-X.Y.Z.exe and Tessera-X.Y.Z.AppImage is a hard phase-gate requirement, not just a test-suite requirement

Wave 0 Gaps

  • apps/api/src/desktop/desktop.service.spec.ts — covers manifest-present/absent, platform whitelist, path-traversal rejection
  • apps/web/src/app/(portal)/settings/general/desktop/desktop-settings.test.tsx (or co-located, matching calendar-settings.test.tsx naming convention already in this repo) — covers link visibility and rendered fields
  • Login page test extension for the download-link visibility rule (D-12) — check whether an existing login test file exists first; none was found in this research pass, so this may be a new file
  • Framework install: none — Vitest is already configured in both apps

Security Domain

Applicable ASVS Categories

ASVS Category Applies Standard Control
V2 Authentication No The two new routes are deliberately @Public() per D-10 — no auth applies by design, matching the existing /health/version precedent
V3 Session Management No No session state involved in file download
V4 Access Control Yes (negative case) The two new routes must NOT accidentally inherit tenant/role checks that would break the public download — verify @Public() is applied to both, matching HealthController's pattern (apps/api/src/health/health.controller.ts:8,20, read this session)
V5 Input Validation Yes :platform param validated against a hardcoded whitelist (['windows', 'linux']), never used to construct a filesystem path directly; filename comes only from manifest.json, matching the DkvService.getExportFile whitelist-then-lookup pattern (read this session, apps/api/src/dkv/dkv.service.ts:703-729)
V6 Cryptography Partial sha256 checksums in manifest.json are integrity metadata, not a security control on their own (no signature) — this is explicitly acceptable scope per D-09 (no code signing this phase); do not present the sha256 field as a security guarantee in user-facing docs

Known Threat Patterns for this stack

Pattern STRIDE Standard Mitigation
Path traversal via :platform or a crafted filename Tampering / Information Disclosure Whitelist-validate :platform against a fixed enum before any filesystem access; resolve the actual filename exclusively from manifest.json, never from request input — exact precedent already in this codebase (DkvService.getExportFile)
Serving an unexpectedly large/wrong file due to a stale or tampered manifest.json Tampering manifest.json is written only by the CI pipeline (never user-writable, lives inside the built Docker image, not a mounted/writable volume) — no runtime code path writes to /app/desktop-dist/
SmartScreen / unsigned-binary user confusion (not a Tessera vulnerability, but a support-burden risk) — Explicitly out of scope for code-signing (D-09) — mitigated only via documentation (D-15's SmartScreen explanation in the Anwenderhandbuch), not a technical control
CI secret exposure via the new release-asset-upload script Information Disclosure Reuse the existing publish-release.sh pattern of writing the Authorization header to a temp file with umask 077 rather than passing the token as a CLI argument (visible in process listings/logs) — already the established pattern in this file, read this session (apps/api/.gitea/scripts/publish-release.sh:117-123)

Sources

Primary (HIGH confidence)

  • apps/desktop/src-tauri/Cargo.lock (read this session) — exact installed versions of tauri, reqwest, all four plugins, native-tls/openssl-sys/rustls/schannel
  • apps/desktop/src-tauri/lib.rs, tauri.conf.json, Cargo.toml, capabilities/default.json, setup.html (read this session) — current Phase-6 state
  • .gitea/workflows/ci.yml, .gitea/scripts/publish-images.sh, .gitea/scripts/publish-release.sh (read this session) — existing pipeline shape and idempotency patterns to extend
  • apps/api/src/health/*.ts, apps/api/src/auth/decorators/public.decorator.ts, apps/api/src/app.module.ts (read this session) — @Public() + global-guard mechanism
  • apps/api/src/dkv/dkv.service.ts:695-729, dkv.controller.ts:128-155 (read this session) — file-download and path-traversal-guard precedent
  • apps/web/src/lib/app-version.ts, apps/web/src/components/layout/app-version-badge.tsx (read this session) — memoized public-fetch pattern to mirror
  • apps/web/src/app/(auth)/login/page.tsx, apps/web/src/app/(portal)/settings/layout.tsx, settings-sidebar.tsx, settings/general/account/page.tsx (read this session) — UI insertion points
  • packages/shared/src/index.ts (read this session) — existing VersionResponse/HealthResponse shape to mirror for DesktopManifest
  • Live gitea-runner container /data/config.yaml (inspected this session via docker exec) — confirms cache server enabled at 172.18.0.1:42641
  • Live gitea/runner-images:ubuntu-latest container (inspected this session via docker run) — confirms Ubuntu 24.04, absence of Rust/nsis/webkit2gtk-dev/appindicator-dev
  • crates.io registry API responses (fetched this session via WebFetch) — tauri-plugin-opener 2.5.5, cargo-xwin 0.23.1
  • gsd_run query package-legitimacy check (run this session) — OK verdicts for both new crates

Secondary (MEDIUM confidence)

  • v2.tauri.app "Distribute > Windows Installer" cross-compiling section (fetched this session) — apt packages, rustup target add, cargo install cargo-xwin, build command, XWIN_CACHE_DIR, output path
  • v2.tauri.app "Plugin > Opener" (fetched this session) — cargo add tauri-plugin-opener, capability permission shape, OpenerExt/open_url signature
  • v2.tauri.app "Plugin > Autostart" (fetched this session) — ManagerExt, app.autolaunch(), enable/disable/is_enabled
  • v2.tauri.app "Configuration Files" (fetched this session) — --config JSON-merge-patch override semantics
  • github.com/tauri-apps/tauri PR #12136 (fetched this session) — NSIS build-metadata coercion fix, shipped in tauri-bundler 2.2.3
  • github.com/tauri-apps/tauri issue #8038 (web search, title/summary only) — root cause of the NSIS numeric-version requirement
  • Gitea forum "Create new Release via API with attachment" (web search) — multipart asset-upload endpoint shape
  • docs.nestjs.com Techniques > Streaming Files (general training knowledge, common NestJS idiom, not fetched verbatim this session) — StreamableFile + passthrough: true requirement

Tertiary (LOW confidence)

  • github.com/go-gitea/gitea issues #28853, #31256, #27314, #25590 (web search summaries only, not individually read in full) — evidence for the artifact-action fragility claim; treated as directional/corroborating rather than definitive, hence the recommendation to use actions/cache instead rather than attempting to pin a "known good" artifact-action version
  • Exact default Tauri 2.11.3 NSIS/AppImage output filename — not confirmed against a primary source this session (see Open Questions #1); mitigated by filename-agnostic find-then-rename design, not by resolving the question

Metadata

Confidence breakdown:

  • Standard stack (crate versions, cross-compile toolchain): HIGH — read directly from Cargo.lock and official Tauri docs, plus a legitimacy check on the two new crates
  • Cross-job CI artifact handoff strategy: MEDIUM — the cache server was directly confirmed enabled on the live runner, but the specific actions/cache@v4 compatibility with this exact Gitea/act_runner version was not itself executed this session, only reasoned from general Gitea documentation
  • NSIS version-format safety: HIGH — the underlying Windows constraint and the Tauri coercion-fix PR are both directly cited; the recommended mitigation (plain X.Y.Z always) is conservative by construction and doesn't depend on the coercion fix working
  • Windows cross-build actually succeeding end-to-end: MEDIUM-LOW — no Windows cross-build was executed in this research session; this is explicitly flagged as needing D-16's iteration loop, not resolved by research alone
  • API/Web/security patterns: HIGH — every pattern has a direct, freshly-read precedent in this exact codebase

Research date: 2026-09-16 Valid until: 2026-10-16 (30 days — Tauri/cargo-xwin/crates.io versions move fast enough that a re-check is warranted if planning is delayed; the runner-image and act_runner findings are environment-specific and should be re-verified if the CI infrastructure changes)