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>
65 KiB
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 viamakensisaus dem Ubuntu-Paketnsis,llvm/lld/clang). Kein Windows-Rechner in der Pipeline. - D-06: Neuer CI-Job
desktopnachtest, laeuft bei Push aufmainund bei Tagsv*(Beta bekommt die Pakete auch, sonst ist nichts testbar). Cargo-Registry,target/und das xwin-SDK werden peractions/cachezwischengespeichert; 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.Zaus dem letzten Tag) inapps/desktop/src-tauri/tauri.conf.jsonundCargo.toml. Beta-Builds tragen dieselbeX.Y.Zwie 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/mitmanifest.json: Version, Dateinamen, Groessen, SHA-256). Die API liefert sie selbst aus — Live-Server brauchen keinen Zugang zu Gitea. Zusaetzlich haengtpublish-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: {...} } }ausmanifest.json;GET /desktop/download/:platform(windows|linux, oeffentlich) streamt die Datei mitContent-Disposition: attachment. Fehlt das Verzeichnis/Manifest:404mit 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/versionbleibt 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/latestantwortet. 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/desktopim Systembrowser oeffnet (tauri-plugin-openeroderopen-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/desktopist 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 clippyim CI-Job; ein lokaler Linux-Bau (tauri buildAppImage) 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"
Recommended Project Structure
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,
findthe produced.exe/.AppImagein the bundle output directory and explicitly copy/rename it to the canonicalTessera-Setup-X.Y.Z.exe/Tessera-X.Y.Z.AppImagename before it entersmanifest.jsonor gets uploaded anywhere. - Using
actions/upload-artifact/download-artifactfor the desktop→publish handoff: documented failure modes on Gitea (see Standard Stack alternatives table). Useactions/cacheinstead. - Putting build metadata / pre-release identifiers in
tauri.conf.jsonversion: even though newer tauri-bundler versions coerce rather than fail, D-07 forbids any risk here — keep it plainX.Y.Zalways. - Resolving the download filename from the request's
:platformparam directly: always resolve throughmanifest.json'sfiles[platform].name, matching D-10's explicit instruction and theDkvServiceprecedent.
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
-
Exact default filename Tauri 2.11.3's bundler gives the NSIS
.exeand 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.
- What we know: Tauri v1 used
-
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.
-
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/cachecompatibility 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/cacheaction version pin. - Recommendation: Use
actions/cache@v4as the first attempt (matchesactions/checkout@v4/actions/setup-node@v4versioning already proven working in this repo's CI); if it fails, the fallback isactions/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.
- What we know: The cache server is confirmed enabled and reachable; general Gitea docs describe
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 bothTessera-Setup-X.Y.Z.exeandTessera-X.Y.Z.AppImageis 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 rejectionapps/web/src/app/(portal)/settings/general/desktop/desktop-settings.test.tsx(or co-located, matchingcalendar-settings.test.tsxnaming 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
logintest 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 oftauri,reqwest, all four plugins,native-tls/openssl-sys/rustls/schannelapps/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 extendapps/api/src/health/*.ts,apps/api/src/auth/decorators/public.decorator.ts,apps/api/src/app.module.ts(read this session) —@Public()+ global-guard mechanismapps/api/src/dkv/dkv.service.ts:695-729,dkv.controller.ts:128-155(read this session) — file-download and path-traversal-guard precedentapps/web/src/lib/app-version.ts,apps/web/src/components/layout/app-version-badge.tsx(read this session) — memoized public-fetch pattern to mirrorapps/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 pointspackages/shared/src/index.ts(read this session) — existingVersionResponse/HealthResponseshape to mirror forDesktopManifest- Live
gitea-runnercontainer/data/config.yaml(inspected this session viadocker exec) — confirms cache server enabled at172.18.0.1:42641 - Live
gitea/runner-images:ubuntu-latestcontainer (inspected this session viadocker 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-opener2.5.5,cargo-xwin0.23.1 gsd_run query package-legitimacy check(run this session) —OKverdicts 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_urlsignature - v2.tauri.app "Plugin > Autostart" (fetched this session) —
ManagerExt,app.autolaunch(),enable/disable/is_enabled - v2.tauri.app "Configuration Files" (fetched this session) —
--configJSON-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: truerequirement
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/cacheinstead 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.lockand 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@v4compatibility 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)