From 01ec4d3d4eb63e564b7bd5693b826306ac61205c Mon Sep 17 00:00:00 2001 From: Schalli Date: Wed, 16 Sep 2026 15:02:02 +0200 Subject: [PATCH] =?UTF-8?q?docs(phase-18):=20Forschung=20=E2=80=94=20Cross?= =?UTF-8?q?-Bau=20Windows-NSIS=20auf=20Linux,=20act=5Frunner-Cache=20statt?= =?UTF-8?q?=20Artifact-Actions,=20API-Downloadmodul?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../18-RESEARCH.md | 749 ++++++++++++++++++ 1 file changed, 749 insertions(+) create mode 100644 .planning/phases/18-desktop-client-fertigstellen/18-RESEARCH.md diff --git a/.planning/phases/18-desktop-client-fertigstellen/18-RESEARCH.md b/.planning/phases/18-desktop-client-fertigstellen/18-RESEARCH.md new file mode 100644 index 0000000..24fbcb5 --- /dev/null +++ b/.planning/phases/18-desktop-client-fertigstellen/18-RESEARCH.md @@ -0,0 +1,749 @@ +# 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 (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 + + + +## 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) | + + +## 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+`) 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 { + 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 | null = null; + +export function loadApiVersion(): Promise { + if (!apiVersionPromise) { + apiVersionPromise = fetch(`${API_URL}/health/version`, { credentials: 'include' }) + .then((res) => (res.ok ? (res.json() as Promise) : 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::().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 | null = null; + +export function loadDesktopLatest(): Promise { + if (!desktopLatestPromise) { + desktopLatestPromise = fetch(`${API_URL}/desktop/latest`) + .then((res) => (res.ok ? (res.json() as Promise) : null)) + .catch(() => null); + } + return desktopLatestPromise; +} +``` + +## Assumptions Log + +| # | Claim | Section | Risk if Wrong | +|---|-------|---------|---------------| +| A1 | Tauri's exact default bundle output filename pattern for AppImage/NSIS in 2.11.3 | Pitfall 4, Code Example #2 | Low — the recommended `find`-then-rename pattern is deliberately filename-agnostic, so this assumption has no load-bearing effect on the plan | +| A2 | `actions/cache@v4` (not an older pinned minor) works correctly against this runner's local cache server for both `save` and `restore` sub-actions with `fail-on-cache-miss` | Code Example #2, Pitfall 1 | Medium — if the exact cache-action version/flag set behaves differently, the `publish` job could silently build without desktop packages instead of hard-failing; D-16's iteration loop is the designed safety net for exactly this | +| A3 | `reqwest` cross-compiling to `x86_64-pc-windows-msvc` will not need OpenSSL, based on Cargo.lock's target-conditional dependency graph rather than an actual cross-build having been run this session | Pitfall 3 | Low-Medium — if wrong, the fallback (`rustls-tls` feature) is already documented and simple to apply within D-16's iteration loop | +| A4 | Ubuntu 24.04 apt package names (`libwebkit2gtk-4.1-dev`, `libayatana-appindicator3-dev`, etc.) are current/correct for Tauri 2 on this exact runner image, based on the dev machine's already-installed package list plus official Tauri Linux prerequisites docs, not a fresh `apt-get install` actually run inside the runner container this session | Standard Stack Installation, Pitfall 5 | Low — `apt-get install` will error clearly and immediately if a package name is wrong/renamed, easily caught in D-16's iteration loop | + +**If this table is empty:** N/A — see above. + +## Open Questions + +1. **Exact default filename Tauri 2.11.3's bundler gives the NSIS `.exe` and the AppImage** + - What we know: Tauri v1 used `${productName}_${version}_${arch}-setup.exe`-style names; v2's exact current default was not confirmed against an authoritative primary source this session. + - What's unclear: Whether that pattern still holds in 2.11.3, and whether `productName: "Tessera"` (with no space) changes it. + - Recommendation: Don't rely on it — the CI script `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)