Files

750 lines
65 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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):**
```bash
# 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:**
```bash
# 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`):**
```typescript
// 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 };
}
```
```typescript
// 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):**
```typescript
// 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)
```sh
#!/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`)
```yaml
# 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`:
```yaml
- 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)
```bash
# 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`)
```rust
// 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`:
```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)
```rust
// 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
```sh
# 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)
```typescript
// 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:
```sh
sha256sum desktop-dist/Tessera-Setup-*.exe | awk '{print $1}'
```
### 8. `apps/web/src/lib/desktop.ts` (mirrors `lib/app-version.ts` exactly)
```typescript
// 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 (RESOLVED in plans: Q1 → 18-01 desktop-collect.sh find-then-rename; Q2 → 18-05 Iterationsschleife; Q3 → 18-05 actions/cache@v3-Fallback)
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 `find`s 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)