0e4eb9bf9e
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
750 lines
65 KiB
Markdown
750 lines
65 KiB
Markdown
# 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)
|