docs(06-desktop-client-ci-cd): create phase plan

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-25 09:25:24 +02:00
parent 3d8c0660f7
commit 44154a4697
4 changed files with 799 additions and 2 deletions
+11 -2
View File
@@ -197,7 +197,16 @@ Decimal phases appear between their surrounding integers in numeric order.
2. Desktop app connects to the existing web backend (no standalone server)
3. Code changes are automatically committed and pushed to Gitea with minimal manual intervention
**Plans**: 0/TBD
**Plans**: 3 plans
**Wave 1** *(parallel — disjoint files)*
- [ ] 06-01-PLAN.md -- Desktop foundation: Tauri toolchain, apps/desktop scaffold, URL-loading WebView + first-run server URL setup (DESK-01/02)
- [ ] 06-03-PLAN.md -- CI/CD: Gitea remote, act_runner, multi-stage Gitea Actions pipeline (lint+type-check -> tests -> docker build+deploy) (INFRA-04)
**Wave 2** *(blocked on 06-01)*
- [ ] 06-02-PLAN.md -- Desktop native: tray + close-to-tray, window-state, autostart, notifications + version check, branded icon, AppImage+NSIS bundles, /health/version API (DESK-01/02)
## Progress
@@ -211,4 +220,4 @@ Phases execute in numeric order: 1 -> 2 -> 3 -> 4 -> 5 -> 6
| 3. Module System & Domaincheck | 3/4 | In Progress| |
| 4. Marketplace & Portal Navigation | 0/4 | Not started | - |
| 5. Dashboard & Calendar | 5/5 | Complete | 2026-06-24 |
| 6. Desktop Client & CI/CD | 0/TBD | Not started | - |
| 6. Desktop Client & CI/CD | 0/3 | Not started | - |
@@ -0,0 +1,246 @@
---
phase: 06-desktop-client-ci-cd
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- apps/desktop/package.json
- apps/desktop/src-tauri/Cargo.toml
- apps/desktop/src-tauri/tauri.conf.json
- apps/desktop/src-tauri/build.rs
- apps/desktop/src-tauri/src/main.rs
- apps/desktop/src-tauri/src/lib.rs
- apps/desktop/src-tauri/capabilities/default.json
- apps/desktop/src/setup.html
autonomous: false
requirements:
- DESK-01
- DESK-02
user_setup: []
must_haves:
truths:
- "Tauri toolchain prerequisites (Rust + WebKitGTK dev headers) are installed and verifiable"
- "apps/desktop is a Tauri 2.x project registered in the pnpm workspace as @tessera/desktop"
- "On first launch the app shows a local setup page where the user enters a server URL"
- "The entered server URL is persisted via tauri-plugin-store and reused on subsequent launches"
- "After a URL is configured, the WebView loads the configured Tessera server (no bundled frontend assets)"
artifacts:
- path: "apps/desktop/src-tauri/tauri.conf.json"
provides: "Tauri window + bundle config, frontendDist pointing at local setup page"
contains: "productName"
- path: "apps/desktop/src-tauri/src/lib.rs"
provides: "Rust entry: plugin registration, store read, navigate-to-server-URL logic"
min_lines: 25
- path: "apps/desktop/src/setup.html"
provides: "First-run server URL configuration page using tauri-plugin-store"
contains: "server_url"
- path: "apps/desktop/src-tauri/capabilities/default.json"
provides: "Plugin permission grants for store"
contains: "store:default"
key_links:
- from: "apps/desktop/src/setup.html"
to: "tauri-plugin-store (config.json)"
via: "load() + store.set('server_url', url)"
pattern: "server_url"
- from: "apps/desktop/src-tauri/src/lib.rs"
to: "main WebView window"
via: "read stored server_url, navigate window to it on startup"
pattern: "server_url"
---
<objective>
Establish the Tauri 2.x desktop wrapper foundation as a thin URL-loading shell. This is the
first vertical slice for DESK-01/DESK-02: install the missing toolchain, scaffold `apps/desktop`
in the monorepo, and deliver an app that launches, prompts for a server URL on first run, persists
it, and loads the configured Tessera web frontend in the native WebView.
Purpose: A user can run the desktop app and reach the live Tessera web app — the thinnest
end-to-end desktop experience. All native polish (tray, window state, autostart, notifications,
icon, production bundles) is layered on in Plan 06-02.
Output: A runnable `apps/desktop` Tauri project (`pnpm --filter=@tessera/desktop tauri dev`)
that connects to a user-configured server URL and remembers it.
</objective>
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/06-desktop-client-ci-cd/06-CONTEXT.md
@.planning/phases/06-desktop-client-ci-cd/06-RESEARCH.md
@pnpm-workspace.yaml
@apps/web/src/app/globals.css
</context>
<artifacts_this_phase_produces>
This plan introduces the following symbols/files (consumed by Plan 06-02):
- `apps/desktop/` — Tauri project root, pnpm package `@tessera/desktop`
- `apps/desktop/src-tauri/src/lib.rs::run()` — Rust entry function (extended in 06-02)
- `apps/desktop/src-tauri/tauri.conf.json` — central Tauri config (window, bundle, plugins)
- `apps/desktop/src-tauri/capabilities/default.json` — permission grants (extended in 06-02)
- `apps/desktop/src/setup.html` — first-run server URL page
- tauri-plugin-store `config.json` key `server_url` — persisted server URL contract
</artifacts_this_phase_produces>
<tasks>
<task type="checkpoint:human-verify" gate="blocking-human">
<name>Task 1: Install Tauri toolchain prerequisites (Rust + WebKitGTK dev headers)</name>
<read_first>
- .planning/phases/06-desktop-client-ci-cd/06-RESEARCH.md (Environment Availability table, Pitfall 1, Installation block)
</read_first>
<what-built>
RESEARCH.md confirms the machine is MISSING the Rust toolchain and `libwebkit2gtk-4.1-dev`
(plus likely libssl-dev, libxdo-dev, build-essential). These require `sudo apt` and a rustup
network installer — both need human authorization, so this is a blocking human-action checkpoint.
Execute these commands (the executor presents them; the human runs/authorizes sudo):
- apt install: `sudo apt install libwebkit2gtk-4.1-dev build-essential curl wget file libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev`
- Rust: `curl --proto '=https' --tlsv1.2 https://sh.rustup.rs -sSf | sh -s -- -y` then `source "$HOME/.cargo/env"`
</what-built>
<how-to-verify>
Run each and confirm a real version/path is printed (not "not found"):
1. `rustc --version` prints a version >= 1.77.2
2. `cargo --version` prints a version
3. `pkg-config --modversion webkit2gtk-4.1` prints a version (e.g. 2.x)
4. `pkg-config --exists libssl && echo OK` prints OK
</how-to-verify>
<acceptance_criteria>
- `rustc --version` exits 0 and prints version >= 1.77.2
- `pkg-config --modversion webkit2gtk-4.1` exits 0 (no "not found")
</acceptance_criteria>
<resume-signal>Type "approved" once all four checks print versions, or describe which failed</resume-signal>
</task>
<task type="auto" tdd="false">
<name>Task 2: Scaffold apps/desktop Tauri project as URL-loading wrapper</name>
<files>apps/desktop/package.json, apps/desktop/src-tauri/Cargo.toml, apps/desktop/src-tauri/tauri.conf.json, apps/desktop/src-tauri/build.rs, apps/desktop/src-tauri/src/main.rs, apps/desktop/src-tauri/src/lib.rs, apps/desktop/src-tauri/capabilities/default.json</files>
<read_first>
- .planning/phases/06-desktop-client-ci-cd/06-RESEARCH.md (Pattern 1 URL-Loading WebView, Recommended Project Structure, Cargo.toml Plugin Dependencies, Capabilities Configuration, Pitfall 6 monorepo scaffolding)
- pnpm-workspace.yaml (confirms apps/* glob already covers apps/desktop)
- apps/api/package.json (for name/version pattern: `@tessera/*`, version 0.0.1, private true)
</read_first>
<action>
Create `apps/desktop/` manually to avoid the monorepo scaffold confusion in Pitfall 6 (do NOT run
create-tauri-app at repo root). Set package.json name to `@tessera/desktop`, version `0.0.1`, private true,
with scripts `tauri` (runs `tauri`), `dev` (`tauri dev`), `build` (`tauri build`). Add devDependency
`@tauri-apps/cli@2.11.3` and dependencies `@tauri-apps/api@2.11.1` plus `@tauri-apps/plugin-store@2.4.3`.
Install via `pnpm add -D @tauri-apps/cli --filter=@tessera/desktop` and `pnpm add @tauri-apps/api @tauri-apps/plugin-store --filter=@tessera/desktop`.
Create `src-tauri/Cargo.toml` with package name `tessera-desktop`, edition 2021, a `[lib]` entry named
`tessera_desktop_lib` (crate-type cdylib + staticlib + rlib), build-dependency `tauri-build = "2"`, and
dependencies: `tauri = { version = "2", features = ["tray-icon"] }`, `tauri-plugin-store = "2"`,
`serde = { version = "1", features = ["derive"] }`, `serde_json = "1"`. Only the store plugin is wired in
this plan; notification/autostart/window-state are added in 06-02.
Create `src-tauri/build.rs` calling `tauri_build::build()`.
Create `src-tauri/tauri.conf.json`: `productName` "Tessera", `version` "0.0.1", `identifier`
"de.ctl.tessera.desktop". Under `build`, set `frontendDist` to `../src` (the local setup page directory) and
`devUrl` to `http://localhost:1420`. Under `app.windows`, one window: label "main", title "Tessera",
width 1280, height 800, center true, decorations true, resizable true. Set `app.withGlobalTauri` true.
Add `app.security.csp` allowing connection to the configured server (use `default-src 'self'; connect-src *`
for the URL-configurable wrapper per D-02). Under `bundle`, set `active` true, `targets` `["appimage", "nsis"]`,
`icon` `["icons/icon.png", "icons/icon.ico"]` (icons added in 06-02 — note this in a SUMMARY follow-up if build
is attempted before icons exist).
Create `src-tauri/src/main.rs` as the generated entry: `#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]`
then `fn main() { tessera_desktop_lib::run() }`.
Create `src-tauri/src/lib.rs` exposing `pub fn run()`. Register the store plugin via
`tauri_plugin_store::Builder::new().build()`. In a `.setup(|app| { ... })` callback, open the store
`config.json`, read key `server_url`; if present, get the `main` webview window and call `window.navigate(url)`
to load the configured server; if absent, leave the WebView on the bundled setup page. Use
`tauri::generate_context!()` in `.run(...)`.
Create `src-tauri/capabilities/default.json` with identifier "default", windows `["main"]`, permissions
`["core:default", "store:default"]` (additional permissions added in 06-02).
</action>
<verify>
<automated>test -f apps/desktop/src-tauri/tauri.conf.json && test -f apps/desktop/src-tauri/src/lib.rs && grep -q '@tessera/desktop' apps/desktop/package.json && grep -q 'store:default' apps/desktop/src-tauri/capabilities/default.json && cd apps/desktop/src-tauri && cargo check 2>&1 | grep -qiv 'could not find webkit'</automated>
</verify>
<acceptance_criteria>
- `apps/desktop/package.json` name is `@tessera/desktop`
- `cargo check` inside `apps/desktop/src-tauri` completes without WebKitGTK "could not find" errors
- `tauri.conf.json` `frontendDist` is `../src` (not a hardcoded server URL)
- `lib.rs` reads `server_url` from store and calls `navigate` when present
</acceptance_criteria>
<done>cargo check passes; pnpm recognizes @tessera/desktop; config loads the local setup page as frontendDist</done>
</task>
<task type="auto" tdd="false">
<name>Task 3: First-run setup page — server URL input, validation, persistence, navigate</name>
<files>apps/desktop/src/setup.html</files>
<read_first>
- .planning/phases/06-desktop-client-ci-cd/06-RESEARCH.md (Pattern 3 Configurable Server URL, First-Run Setup Page code example, Security Domain — V5 Input Validation, malicious URL threat)
- apps/web/src/app/globals.css (OKLCH design tokens: --primary oklch(0.91 0.19 102), dark background oklch(0.17 0.01 260) — match setup page colors to the design system per D-06 spirit)
</read_first>
<action>
Create `apps/desktop/src/setup.html` as the local first-run page (D-02). It must:
- Render a centered card on dark background `oklch(0.17 0.01 260)` with a "Tessera" heading and a URL input
pre-filled with `http://localhost:3000`, plus a "Verbinden" button styled with primary `oklch(0.91 0.19 102)`.
All visible strings in German (response_language de): heading "Tessera", label "Server-URL eingeben:",
button "Verbinden", error text "Ungueltige URL" for invalid input.
- On submit, validate input with the `URL` constructor (per the established Phase 5 pattern, decision [05]
"URL constructor for client-side https-only validation"). Reject empty/malformed input; for non-https URLs
that are not localhost, show a warning string but allow (internal LAN servers may be http) — this mitigates
the malicious-URL Tampering threat T-06-01 (V5 input validation).
- On valid input, `import { load } from '@tauri-apps/plugin-store'`, `load('config.json', { autoSave: true })`,
`store.set('server_url', url)`, then `window.location.href = url` to navigate the WebView to the server.
- Use `<script type="module">` and rely on `withGlobalTauri`/the store plugin already permitted in capabilities.
</action>
<verify>
<automated>test -f apps/desktop/src/setup.html && grep -q "server_url" apps/desktop/src/setup.html && grep -q "new URL" apps/desktop/src/setup.html && grep -q "plugin-store" apps/desktop/src/setup.html</automated>
</verify>
<acceptance_criteria>
- setup.html validates the URL with `new URL(...)` before saving
- setup.html calls `store.set('server_url', ...)` and then navigates to the URL
- Visible strings are German ("Verbinden", "Server-URL eingeben:")
</acceptance_criteria>
<done>Entering a valid URL persists it to config.json and navigates the WebView; invalid input is rejected with a German error</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| user input → WebView navigation | Server URL typed by user controls where the WebView connects (untrusted text crosses into navigation) |
| local config store → app startup | Persisted `server_url` is read on launch and drives navigation |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-06-01 | Tampering | setup.html server URL input | mitigate | Validate with `new URL()` constructor; reject malformed; warn on non-https non-localhost (V5 input validation) |
| T-06-02 | Spoofing | WebView navigation target | accept | Internal-only tool; URL is user-chosen by design (D-02). CSP `connect-src *` required for configurable server. Locking to a single domain conflicts with the configurable-URL requirement; revisit at external-sales stage |
| T-06-03 | Information Disclosure | config.json stored URL | accept | URL is non-secret connection info stored via plugin-store (atomic, app-scoped path), not plaintext app config |
| T-06-SC | Tampering | npm/cargo installs | mitigate | All Tauri packages [Approved] in RESEARCH.md Package Legitimacy Audit; no [ASSUMED]/[SUS] blocking packages in this plan |
</threat_model>
<verification>
- `apps/desktop/src-tauri` `cargo check` passes (toolchain installed, config valid)
- `pnpm --filter=@tessera/desktop tauri dev` launches a window showing the setup page on first run
- Entering `http://localhost:3000` (with the dev stack running) navigates to the Tessera web app
- Relaunching the app skips setup and loads the stored URL directly
</verification>
<success_criteria>
- Tauri toolchain installed and verifiable (rustc, webkit2gtk-4.1 pkg-config)
- `apps/desktop` exists as `@tessera/desktop` and `cargo check` passes
- First-run setup page persists the server URL and connects the WebView to it (DESK-02)
- No frontend assets bundled — `frontendDist` is the local setup page only (DESK-01 wrapper pattern)
</success_criteria>
<output>
Create `.planning/phases/06-desktop-client-ci-cd/06-01-SUMMARY.md` when done.
</output>
@@ -0,0 +1,284 @@
---
phase: 06-desktop-client-ci-cd
plan: 02
type: execute
wave: 2
depends_on:
- 06-01
files_modified:
- apps/desktop/src-tauri/src/lib.rs
- apps/desktop/src-tauri/Cargo.toml
- apps/desktop/src-tauri/tauri.conf.json
- apps/desktop/src-tauri/capabilities/default.json
- apps/desktop/src-tauri/icons/icon.png
- apps/desktop/src-tauri/icons/icon.ico
- apps/desktop/src-tauri/icons/128x128.png
- apps/api/src/health/health.controller.ts
autonomous: false
requirements:
- DESK-01
- DESK-02
user_setup: []
must_haves:
truths:
- "Clicking the window close (X) hides the app to the system tray instead of quitting"
- "The system tray icon shows a context menu with Oeffnen and Beenden; Beenden actually exits the app"
- "Window size and position are saved on close and restored on next launch (first run 1280x800 centered)"
- "An Autostart toggle exists and registers/unregisters the app with the OS, defaulting to disabled"
- "On startup the app fetches the server /version endpoint and shows a native notification when a newer version is available"
- "The app uses a Tessera-branded icon derived from the OKLCH design system"
- "Production bundles build for Linux (AppImage) and Windows (NSIS)"
artifacts:
- path: "apps/desktop/src-tauri/src/lib.rs"
provides: "Tray, close-to-tray, plugin registration, version-check logic"
contains: "TrayIconBuilder"
- path: "apps/api/src/health/health.controller.ts"
provides: "GET /health/version endpoint returning app version"
contains: "version"
- path: "apps/desktop/src-tauri/icons/icon.png"
provides: "Tessera app + tray icon"
key_links:
- from: "apps/desktop/src-tauri/src/lib.rs"
to: "main window CloseRequested event"
via: "window.hide() + api.prevent_close()"
pattern: "prevent_close"
- from: "apps/desktop/src-tauri/src/lib.rs"
to: "apps/api GET /health/version"
via: "HTTP fetch of {server_url}/health/version on startup"
pattern: "version"
---
<objective>
Layer all native desktop behaviors onto the working URL-loading wrapper from Plan 06-01:
system tray with close-to-tray (D-03), window state persistence (D-05), autostart toggle (D-04),
native notifications + startup version check (D-01, D-08), Tessera-branded icon (D-06), and
production bundle targets AppImage + NSIS (D-07). Also add the API `/health/version` endpoint
the version check depends on. Code signing is intentionally NOT configured (D-09 — deferred to
external-sales stage).
Purpose: Completes DESK-01/DESK-02 — a user can install a real, well-behaved Tessera desktop app
that lives in the tray, remembers its window, optionally autostarts, and warns about updates.
Output: A buildable Tauri app producing unsigned AppImage (Linux) and NSIS (Windows) installers
with full native integration, plus a `/health/version` API endpoint.
</objective>
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/06-desktop-client-ci-cd/06-CONTEXT.md
@.planning/phases/06-desktop-client-ci-cd/06-RESEARCH.md
@.planning/phases/06-desktop-client-ci-cd/06-01-SUMMARY.md
@apps/api/src/health/health.controller.ts
@apps/web/src/app/globals.css
</context>
<artifacts_this_phase_produces>
- `apps/api` `GET /health/version` → `{ version, name }` (consumed by desktop version check)
- `apps/desktop/src-tauri/src/lib.rs::run()` — extended with tray, close-to-tray, 4 plugins, version check
- `apps/desktop/src-tauri/icons/{icon.png,icon.ico,128x128.png}` — branded app/tray icons
- Autostart toggle UI hook (invoked from within the loaded web app via tauri-plugin-autostart API)
</artifacts_this_phase_produces>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Add GET /health/version endpoint to the API</name>
<files>apps/api/src/health/health.controller.ts</files>
<read_first>
- apps/api/src/health/health.controller.ts (existing controller, @Public decorator pattern, HealthResponse import)
- .planning/phases/06-desktop-client-ci-cd/06-RESEARCH.md (Version Check Endpoint code example)
</read_first>
<behavior>
- GET /health/version returns HTTP 200 with JSON `{ version: string, name: 'tessera' }`
- The endpoint is public (no auth required) — reachable by the desktop shell before login
- version resolves from `process.env.npm_package_version` falling back to '0.0.1'
</behavior>
<action>
Extend the existing `HealthController` with a `@Public() @Get('version')` handler `getVersion()` returning an
object `{ version: process.env.npm_package_version ?? '0.0.1', name: 'tessera' }`. Reuse the existing `@Public`
decorator import already present in the file. If a `HealthResponse`-style shared type is desired, define a
small inline return type or extend `@tessera/shared`; keep it minimal. Mark the route public so the desktop
version check (D-08) can reach it pre-auth.
</action>
<verify>
<automated>cd apps/api && pnpm type-check && grep -q "'version'" src/health/health.controller.ts && grep -q "name: 'tessera'" src/health/health.controller.ts</automated>
</verify>
<acceptance_criteria>
- `GET /health/version` handler exists, is decorated `@Public()`, returns `{ version, name: 'tessera' }`
- `pnpm --filter=@tessera/api type-check` passes
</acceptance_criteria>
<done>API exposes a public /health/version returning the app version; type-check green</done>
</task>
<task type="auto" tdd="false">
<name>Task 2: Tray, close-to-tray, window-state, autostart, notification, version check in Rust</name>
<files>apps/desktop/src-tauri/Cargo.toml, apps/desktop/src-tauri/src/lib.rs, apps/desktop/src-tauri/capabilities/default.json</files>
<read_first>
- apps/desktop/src-tauri/src/lib.rs (the run() function from 06-01 — extend, do not rewrite the store/navigate logic)
- apps/desktop/src-tauri/Cargo.toml (existing deps from 06-01)
- apps/desktop/src-tauri/capabilities/default.json (existing permissions from 06-01)
- .planning/phases/06-desktop-client-ci-cd/06-RESEARCH.md (Pattern 2 Close-to-Tray full Rust example, Pitfall 2 both CloseRequested + ExitRequested, Capabilities Configuration, Version Check Rust example, A6 reqwest assumption)
</read_first>
<action>
Extend `Cargo.toml` dependencies with `tauri-plugin-notification = "2"`, `tauri-plugin-autostart = "2"`,
`tauri-plugin-window-state = "2"`, and `reqwest = { version = "0.12", features = ["json"] }` (per A6; if reqwest
proves problematic, fall back to JS-side fetch — note the fallback in SUMMARY). Keep the existing
`tauri = { features = ["tray-icon"] }`.
Extend `lib.rs::run()` registering all four plugins on the builder: `tauri_plugin_store` (already present),
`tauri_plugin_notification::init()`, `tauri_plugin_window_state::Builder::default().build()`, and
`tauri_plugin_autostart::init(tauri_plugin_autostart::MacosLauncher::LaunchAgent, None)`. Window-state plugin
auto-handles D-05 save/restore; the 1280x800 centered first-run default already lives in tauri.conf.json.
In `.setup`, build the tray (D-03): a `MenuBuilder` with items `open` ("Oeffnen") and `quit` ("Beenden")
separated by a separator. Create `TrayIconBuilder` with the bundled icon bytes (icon.png from Task 3),
tooltip "Tessera", `show_menu_on_left_click(false)`. `on_menu_event`: "open" → show + set_focus the `main`
window; "quit" → `app.exit(0)`. `on_tray_icon_event`: left-click Up → show + focus `main` window.
Add close-to-tray (D-03, Pitfall 2): `.on_window_event` handling `WindowEvent::CloseRequested` by calling
`window.hide()` then `api.prevent_close()`. ALSO handle the exit path so the process stays alive when the
last window hides — implement the `RunEvent::ExitRequested` guard (call `api.prevent_exit()`) via
`.build(...).run(|_app, event| ...)` so the only real exit path is the tray "Beenden" → `app.exit(0)`.
Add the version check (D-08): after reading server_url in setup, spawn an async task that GETs
`{server_url}/health/version`, parses JSON `version`, compares against the app's own
`env!("CARGO_PKG_VERSION")`; if different/newer, send a native notification via the notification plugin with a
German body (e.g. title "Tessera Update", body "Eine neue Version ist verfuegbar."). Download stays manual.
Extend `capabilities/default.json` permissions to add: `notification:default`,
`notification:allow-is-permission-granted`, `notification:allow-request-permission`, `notification:allow-notify`,
`autostart:allow-enable`, `autostart:allow-disable`, `autostart:allow-is-enabled`, `window-state:default`
(keep existing `core:default`, `store:default`). These grant the loaded web app (D-04 autostart toggle) and
Rust side the needed plugin access.
</action>
<verify>
<automated>cd apps/desktop/src-tauri && grep -q "TrayIconBuilder" src/lib.rs && grep -q "prevent_close" src/lib.rs && grep -q "prevent_exit" src/lib.rs && grep -q "health/version" src/lib.rs && grep -q "autostart:allow-enable" capabilities/default.json && grep -q "window-state:default" capabilities/default.json && cargo check</automated>
</verify>
<acceptance_criteria>
- `cargo check` passes with all four plugins registered
- lib.rs handles BOTH `prevent_close` (CloseRequested) AND `prevent_exit` (ExitRequested) per Pitfall 2
- lib.rs fetches `{server_url}/health/version` and notifies on version mismatch
- capabilities grants autostart + notification + window-state permissions
</acceptance_criteria>
<done>cargo check passes; tray + close-to-tray + window-state + autostart + version-check wired; D-01/03/04/05/08 covered in code</done>
</task>
<task type="auto" tdd="false">
<name>Task 3: Generate Tessera-branded icon and configure unsigned AppImage + NSIS bundles</name>
<files>apps/desktop/src-tauri/icons/icon.png, apps/desktop/src-tauri/icons/icon.ico, apps/desktop/src-tauri/icons/128x128.png, apps/desktop/src-tauri/tauri.conf.json</files>
<read_first>
- apps/desktop/src-tauri/tauri.conf.json (existing bundle config from 06-01 — targets already ["appimage","nsis"])
- apps/web/src/app/globals.css (OKLCH tokens: --primary oklch(0.91 0.19 102) yellow, dark bg oklch(0.17 0.01 260) — icon palette per D-06)
- .planning/phases/06-desktop-client-ci-cd/06-RESEARCH.md (bundle targets, NSIS over MSI rationale, Pitfall A5 icon format)
</read_first>
<action>
Create a Tessera app icon (D-06) derived from the OKLCH design system: a simple square mark using primary
yellow `oklch(0.91 0.19 102)` on the dark `oklch(0.17 0.01 260)` background (e.g. a stylized "T" or tessera
tile). Author a master 1024x1024 PNG (place a source SVG/PNG under `apps/desktop/src-tauri/icons/` or
`scratchpad`), then generate the platform icon set with `pnpm --filter=@tessera/desktop tauri icon <master.png>`,
which produces `icon.png`, `icon.ico`, `128x128.png`, and the platform variants Tauri expects. If `tauri icon`
cannot run headless, fall back to producing icon.png (1024), 128x128.png, and icon.ico manually with
ImageMagick (`convert`) and note it in SUMMARY.
Confirm `tauri.conf.json` `bundle.icon` references the generated `icons/icon.png` and `icons/icon.ico`, and
`bundle.targets` is `["appimage", "nsis"]` (D-07 AppImage primary; NSIS chosen over MSI per RESEARCH so the
Windows installer can cross-compile from Linux). Ensure the tray icon bytes referenced in lib.rs (Task 2)
point at an existing icon file (e.g. `icons/128x128.png` or `icons/icon.png`).
Per D-09, do NOT configure code signing — internal use ships unsigned by design; signing is deferred until
Tessera is sold externally. Leave all `bundle` signing/certificate fields unset so builds produce unsigned
AppImage and NSIS artifacts.
</action>
<verify>
<automated>test -f apps/desktop/src-tauri/icons/icon.png && test -f apps/desktop/src-tauri/icons/icon.ico && grep -q '"appimage"' apps/desktop/src-tauri/tauri.conf.json && grep -q '"nsis"' apps/desktop/src-tauri/tauri.conf.json && file apps/desktop/src-tauri/icons/icon.png | grep -qi png</automated>
</verify>
<acceptance_criteria>
- `icons/icon.png` and `icons/icon.ico` exist and are valid image files
- `bundle.targets` includes both `appimage` and `nsis`
- Icon palette uses the OKLCH primary/dark colors from the design system
- No code-signing/certificate fields configured (D-09 unsigned for internal use)
</acceptance_criteria>
<done>Branded icon set generated; bundle configured for unsigned AppImage + NSIS</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<name>Task 4: Build the desktop app and verify native behaviors</name>
<read_first>
- .planning/phases/06-desktop-client-ci-cd/06-RESEARCH.md (Validation Architecture — desktop smoke tests for D-02/D-03/D-05; Pitfall 2 warning signs)
</read_first>
<what-built>
The full native desktop wrapper: tray, close-to-tray, window-state, autostart, notifications, version check,
branded icon, AppImage + NSIS bundles. Tauri desktop behavior cannot be unit-tested (RESEARCH Wave 0 Gaps) —
it requires manual smoke verification of OS-level integration.
Before this checkpoint the executor runs (and reports output of):
- `pnpm --filter=@tessera/api type-check` and `pnpm --filter=@tessera/web type-check` (regression)
- `cd apps/desktop && pnpm tauri build` — produces the Linux AppImage (Windows NSIS may require a Windows
runner / cross toolchain; if NSIS cross-build is unavailable on this Linux host, report it and treat the
Windows target as build-config-verified only, AppImage as the proven artifact for DESK-01)
</what-built>
<how-to-verify>
With the dev web stack running, launch the app (`pnpm --filter=@tessera/desktop tauri dev` or the built AppImage):
1. First run shows the German setup page; enter `http://localhost:3000` → Tessera web app loads (DESK-02)
2. Click the window X → window disappears but a Tessera tray icon remains; the process is still alive (D-03)
3. Right-click tray → "Oeffnen" restores the window; "Beenden" fully exits the process (D-03)
4. Resize/move the window, close to tray and quit, relaunch → window restores prior size/position (D-05)
5. Confirm an Autostart toggle is reachable and defaults to off (D-04)
6. If the server reports a different /health/version, a native update notification appears (D-08)
7. Confirm `apps/desktop/src-tauri/target/release/bundle/appimage/*.AppImage` exists (DESK-01)
</how-to-verify>
<acceptance_criteria>
- Setup → server URL → web app loads (DESK-02)
- X hides to tray, process alive; "Beenden" exits (D-03)
- Window state restored on relaunch (D-05)
- An AppImage artifact is produced (DESK-01)
</acceptance_criteria>
<resume-signal>Type "approved" or describe which behavior failed</resume-signal>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| desktop shell → API /health/version | Desktop fetches version info from the configured server over HTTP |
| loaded web app → autostart plugin | The web UI (D-04 toggle) invokes OS-level autostart registration via granted capability |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-06-04 | Spoofing | /health/version response | accept | Version string is non-sensitive; worst case is a spurious update notice. No action taken on it (manual download only, D-08) |
| T-06-05 | Elevation | autostart capability exposed to WebView | accept | Internal-only app loading a trusted self-hosted server; autostart enable/disable is the only granted surface, default disabled (D-04) |
| T-06-06 | Denial of Service | startup version fetch blocks UI | mitigate | Run the version check in a spawned async task; never block window display on the HTTP call |
| T-06-10 | Spoofing | unsigned installer artifacts (D-09) | accept | Code signing deferred for internal-only distribution; revisit before external sales. Documented, time-bound risk |
| T-06-SC | Tampering | cargo/npm installs | mitigate | All Tauri plugins [Approved] in RESEARCH Package Legitimacy Audit; reqwest is a mainstream crate, pin to 0.12 |
</threat_model>
<verification>
- `pnpm --filter=@tessera/api type-check` and web type-check pass (no regression)
- `apps/desktop/src-tauri` `cargo check` passes with all plugins
- `pnpm --filter=@tessera/desktop tauri build` produces an AppImage
- Manual smoke test (Task 4) confirms tray, close-to-tray, window-state, version notification
</verification>
<success_criteria>
- Close-to-tray, tray menu, window-state, autostart, notification + version check all functioning (D-01/03/04/05/08)
- Branded icon present (D-06); unsigned AppImage + NSIS bundle targets configured (D-07, D-09)
- API exposes /health/version (supports D-08)
- DESK-01 (installable Windows/Linux wrapper) and DESK-02 (connects to web backend) satisfied
</success_criteria>
<output>
Create `.planning/phases/06-desktop-client-ci-cd/06-02-SUMMARY.md` when done.
</output>
@@ -0,0 +1,258 @@
---
phase: 06-desktop-client-ci-cd
plan: 03
type: execute
wave: 1
depends_on: []
files_modified:
- .gitea/workflows/ci.yml
- docker-compose.ci.yml
- docs/ci-cd-setup.md
autonomous: false
requirements:
- INFRA-04
user_setup:
- service: gitea
why: "Git remote + CI/CD host. Project currently has no git remote; Gitea instance access (URL, Actions enabled) is unknown per RESEARCH Open Question 3"
dashboard_config:
- task: "Create a Tessera repository in Gitea and add it as git remote"
location: "Gitea web UI → New Repository, then `git remote add origin <url>`"
- task: "Enable Actions for the repository and register an act_runner"
location: "Gitea → Repo Settings → Actions, and runner registration token under Admin/Repo → Actions → Runners"
must_haves:
truths:
- "A git remote pointing at the Tessera Gitea repository exists and main is pushed"
- "An act_runner is registered and able to execute Gitea Actions jobs in Docker"
- "A .gitea/workflows/ci.yml pipeline runs on push to main: lint+type-check → tests → docker build+deploy"
- "The pipeline reuses the existing apps/web and apps/api Dockerfiles and deploys via docker compose on the same server"
artifacts:
- path: ".gitea/workflows/ci.yml"
provides: "Multi-stage CI/CD pipeline (quality → test → build-deploy)"
contains: "runs-on"
- path: "docker-compose.ci.yml"
provides: "act_runner service definition (Docker socket mount, ephemeral)"
contains: "act_runner"
- path: "docs/ci-cd-setup.md"
provides: "Setup runbook: Gitea remote, act_runner registration, secrets"
key_links:
- from: ".gitea/workflows/ci.yml"
to: "existing docker-compose.yml services web + api"
via: "docker compose build + up -d on the deploy host"
pattern: "docker compose"
- from: ".gitea/workflows/ci.yml"
to: "root package.json turbo tasks"
via: "pnpm lint / type-check / test"
pattern: "pnpm (lint|test|type-check)"
---
<objective>
Establish the Gitea-based DevOps tooling (INFRA-04): connect the repo to a Gitea remote, register
an act_runner, and create a multi-stage Gitea Actions pipeline that, on every push to main, runs
Lint + TypeCheck → Vitest tests → Docker image builds → auto-deploy via docker compose on the same
server (D-12, D-13). This is developer tooling only — no Git/Gitea features are exposed inside the
Tessera application (D-10), and Claude pushes manually at milestones (D-11).
Purpose: Minimal-manual-effort version control + CI/CD so milestone pushes automatically lint, test,
build, and redeploy the running stack.
Output: `.gitea/workflows/ci.yml`, an `act_runner` service definition, and a setup runbook.
</objective>
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/06-desktop-client-ci-cd/06-CONTEXT.md
@.planning/phases/06-desktop-client-ci-cd/06-RESEARCH.md
@docker-compose.yml
@package.json
@turbo.json
</context>
<artifacts_this_phase_produces>
- `.gitea/workflows/ci.yml` — CI/CD pipeline (quality / test / build-deploy jobs)
- `docker-compose.ci.yml` — act_runner service (Docker socket mount, ephemeral mode)
- `docs/ci-cd-setup.md` — runbook for Gitea remote + runner registration + secrets
</artifacts_this_phase_produces>
<tasks>
<task type="checkpoint:human-action" gate="blocking-human">
<name>Task 1: Set up Gitea remote and register act_runner</name>
<read_first>
- .planning/phases/06-desktop-client-ci-cd/06-RESEARCH.md (Open Question 3 Gitea Instance Access, Environment Availability — Gitea/act_runner "Unknown", Pitfall 5 Docker socket security)
- docker-compose.yml (existing services + networks to understand deploy target)
</read_first>
<what-built>
The project has NO git remote configured and Gitea instance availability is unknown (RESEARCH Open Question 3).
Creating a Gitea repo, adding the remote, enabling Actions, and obtaining a runner registration token require
Gitea web-UI access and credentials only the human has — hence a blocking human-action checkpoint.
The executor presents these steps; the human performs them and supplies values back:
1. Confirm the Gitea base URL (e.g. https://gitea.example). If Gitea is not running, decide whether to add it
to docker-compose or use an existing instance.
2. Create a repository (e.g. `tessera`) and run `git remote add origin <gitea-repo-url>`.
3. In Gitea, enable Actions for the repo (Settings → Actions) and generate an act_runner registration token
(Admin/Repo → Actions → Runners → Create registration token).
4. Provide the runner registration token + Gitea instance URL so Task 2 can configure the runner, and confirm
the deploy host has the project checked out at the docker-compose path for `docker compose` deploys.
</what-built>
<how-to-verify>
1. `git remote -v` shows an `origin` pointing at the Gitea repo
2. The human confirms Actions is enabled for the repo in Gitea settings
3. A runner registration token + Gitea URL are available for Task 2
</how-to-verify>
<acceptance_criteria>
- `git remote -v` lists a Gitea `origin` remote
- Gitea Actions is enabled for the repository (human-confirmed)
- Runner registration token and Gitea URL captured for runner setup
</acceptance_criteria>
<resume-signal>Type "approved" with the Gitea URL confirmed and runner token available, or describe the blocker</resume-signal>
</task>
<task type="auto" tdd="false">
<name>Task 2: Define act_runner service and CI/CD setup runbook</name>
<files>docker-compose.ci.yml, docs/ci-cd-setup.md</files>
<read_first>
- docker-compose.yml (network names frontend-net/backend-net/data-net, service patterns, healthcheck style)
- .planning/phases/06-desktop-client-ci-cd/06-RESEARCH.md (act_runner setup, Pitfall 5 socket security + ephemeral runners, anti-pattern: mount host docker.sock not Docker-in-Docker, anti-pattern: skip registry build locally)
</read_first>
<action>
Create `docker-compose.ci.yml` (separate compose file so CI infra is opt-in, not part of the app stack)
defining an `act_runner` service using the official `gitea/act_runner:latest` image. Configure via env:
`GITEA_INSTANCE_URL` (from Task 1), `GITEA_RUNNER_REGISTRATION_TOKEN` (from Task 1, referenced from a
`.env`/secret — never hardcoded), `GITEA_RUNNER_NAME` "tessera-runner", and `GITEA_RUNNER_EPHEMERAL=1`
(Pitfall 5: ephemeral runner revokes credentials after each job). Mount the host Docker socket
`/var/run/docker.sock:/var/run/docker.sock` (RESEARCH anti-pattern: mount the socket, do NOT run
Docker-in-Docker) and a named volume for runner config/data. Add restart policy `unless-stopped`.
Create `docs/ci-cd-setup.md` runbook documenting: (1) the Gitea repo + remote setup from Task 1, (2) how to
start the runner `docker compose -f docker-compose.ci.yml up -d`, (3) required secrets/vars
(GITEA_INSTANCE_URL, GITEA_RUNNER_REGISTRATION_TOKEN, and any deploy secrets used by ci.yml such as the deploy
path), (4) the Docker socket security note + ephemeral runner rationale (Pitfall 5), (5) that registry push is
intentionally skipped — images build locally on the same server (D-13, RESEARCH anti-pattern).
</action>
<verify>
<automated>test -f docker-compose.ci.yml && grep -q "act_runner" docker-compose.ci.yml && grep -q "GITEA_RUNNER_EPHEMERAL" docker-compose.ci.yml && grep -q "/var/run/docker.sock" docker-compose.ci.yml && test -f docs/ci-cd-setup.md && docker compose -f docker-compose.ci.yml config >/dev/null</automated>
</verify>
<acceptance_criteria>
- `docker compose -f docker-compose.ci.yml config` validates (exit 0)
- act_runner mounts the host Docker socket and sets GITEA_RUNNER_EPHEMERAL=1
- Registration token is referenced from env/secret, never hardcoded
- docs/ci-cd-setup.md documents remote setup, runner start, secrets, and socket security note
</acceptance_criteria>
<done>act_runner compose file validates; runbook documents the full Gitea + runner setup</done>
</task>
<task type="auto" tdd="false">
<name>Task 3: Create .gitea/workflows/ci.yml multi-stage pipeline</name>
<files>.gitea/workflows/ci.yml</files>
<read_first>
- package.json (root turbo scripts: lint, test, type-check — pipeline calls these via pnpm)
- turbo.json (lint/test/type-check tasks exist)
- docker-compose.yml (service names `web` and `api`, the build+deploy targets)
- apps/web/Dockerfile, apps/api/Dockerfile (reused image builds — do not author new ones)
- .planning/phases/06-desktop-client-ci-cd/06-RESEARCH.md (Pattern 4 Multi-Stage Pipeline YAML, Pitfall 4 docker/build-push-action JWT error → use plain docker commands, anti-pattern skip registry, Security Domain CI secrets)
</read_first>
<action>
Create `.gitea/workflows/ci.yml` (GitHub Actions-compatible syntax, D-12). Name "Tessera CI/CD", trigger on
`push` to branch `main`. Three sequential jobs with `needs` chaining (D-12 staged quality gates):
1. `quality` (runs-on ubuntu-latest): `actions/checkout@v4`, `pnpm/action-setup@v4` (version 9, matching
packageManager pnpm@9.15.0), `actions/setup-node@v4` (node-version 24, cache pnpm), `pnpm install
--frozen-lockfile`, `pnpm lint` (Biome via turbo), `pnpm type-check`.
2. `test` (needs: quality): same checkout/pnpm/node setup, `pnpm install --frozen-lockfile`, `pnpm test`
(Vitest via turbo — `apps/web/vitest.config.ts` exists).
3. `build-deploy` (needs: test): checkout, then build + deploy with PLAIN docker compose commands (Pitfall 4 —
do NOT use docker/build-push-action which fails on Gitea's non-JWT token; Pattern 4 + RESEARCH anti-pattern):
`docker compose build web api` then `docker compose up -d web api`. Since the deploy target is the same
server as the runner (D-13), this builds images locally and restarts the services with no registry push.
Never echo secrets in steps. If the runbook (Task 2) defines deploy secrets/vars (e.g. a deploy path), read
them via Gitea Actions `${{ secrets.* }}` / `${{ vars.* }}`, not inline literals (Security Domain: CI secrets
leakage). Keep the workflow minimal and readable per CLAUDE.md (Claude builds maintainable infra).
</action>
<verify>
<automated>test -f .gitea/workflows/ci.yml && grep -q "on:" .gitea/workflows/ci.yml && grep -q "pnpm lint" .gitea/workflows/ci.yml && grep -q "pnpm test" .gitea/workflows/ci.yml && grep -q "docker compose build" .gitea/workflows/ci.yml && ! grep -q "build-push-action" .gitea/workflows/ci.yml && python3 -c "import yaml,sys; yaml.safe_load(open('.gitea/workflows/ci.yml'))"</automated>
</verify>
<acceptance_criteria>
- ci.yml is valid YAML and triggers on push to main
- Three chained jobs: quality (lint+type-check) → test (vitest) → build-deploy (docker compose)
- Uses plain `docker compose build`/`up -d` — NOT docker/build-push-action (Pitfall 4)
- No hardcoded secrets; secrets/vars referenced via `${{ }}` expressions
</acceptance_criteria>
<done>Valid multi-stage pipeline file present; runs lint+type-check → tests → local docker build+deploy on push</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<name>Task 4: Trigger the pipeline with a real push and confirm it runs green</name>
<read_first>
- .planning/phases/06-desktop-client-ci-cd/06-RESEARCH.md (Validation Architecture — INFRA-04 integration test "Push to Gitea, verify Actions run")
- docs/ci-cd-setup.md (the runbook produced in Task 2)
</read_first>
<what-built>
The complete CI/CD track: Gitea remote (Task 1), act_runner (Task 2), and the ci.yml pipeline (Task 3).
INFRA-04 can only be proven by an actual push that triggers Gitea Actions — this requires the live Gitea
instance + running runner, so it is a blocking human-verify checkpoint.
Before this checkpoint the executor ensures the runner is up (`docker compose -f docker-compose.ci.yml up -d`)
and the workflow + compose files are committed to the branch that will be pushed.
</what-built>
<how-to-verify>
1. Push the current main to the Gitea remote (`git push origin main`) — Claude does this manually per D-11.
2. Open the Gitea repo → Actions tab and confirm a "Tessera CI/CD" run started for the push.
3. Confirm the three jobs run in order and the `quality` and `test` jobs pass (lint, type-check, vitest).
4. Confirm `build-deploy` builds the web + api images and restarts them (`docker compose ps` shows them up).
5. Confirm no secrets appear in the job logs.
</how-to-verify>
<acceptance_criteria>
- A Gitea Actions run is triggered by the push (INFRA-04)
- quality + test jobs pass; build-deploy rebuilds and restarts web + api
- No secret values leaked in pipeline logs
</acceptance_criteria>
<resume-signal>Type "approved" once the pipeline run is green, or paste the failing job log</resume-signal>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Gitea push → act_runner execution | Pushed workflow code executes on the runner with Docker socket access |
| act_runner → host Docker daemon | Mounted `/var/run/docker.sock` grants the runner control over host containers |
| pipeline → secrets store | Deploy/registration secrets pass through Gitea Actions context |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-06-07 | Elevation | act_runner Docker socket mount | accept | Internal-only CI, only Claude pushes (D-11); GITEA_RUNNER_EPHEMERAL=1 revokes creds per job (Pitfall 5) |
| T-06-08 | Information Disclosure | secrets in pipeline logs | mitigate | Reference secrets via `${{ secrets.* }}`/`${{ vars.* }}`; never echo; registration token from env not hardcoded (Security Domain) |
| T-06-09 | Tampering | malicious workflow modification | accept | Single trusted committer (Claude), push to main only; no external contributors (D-10/D-11) |
| T-06-SC | Tampering | act_runner image | mitigate | Use official `gitea/act_runner` image; CI actions pinned to major (checkout@v4, setup-node@v4) |
</threat_model>
<verification>
- `docker compose -f docker-compose.ci.yml config` validates the runner service
- `.gitea/workflows/ci.yml` is valid YAML with three chained jobs and no build-push-action
- A real push (Task 4) triggers a green Gitea Actions run that rebuilds and redeploys web + api
</verification>
<success_criteria>
- Gitea remote configured and main pushed (INFRA-04)
- act_runner registered and executing jobs
- Multi-stage pipeline (lint+type-check → tests → docker build+deploy) runs on push (D-12, D-13)
- No Git/Gitea functionality added to the Tessera app itself (D-10)
</success_criteria>
<output>
Create `.planning/phases/06-desktop-client-ci-cd/06-03-SUMMARY.md` when done.
</output>