feat(06-02): add native desktop features — tray, window-state, autostart, version check

- Add GET /health/version public endpoint to API
- Extend Tauri with 4 plugins: notification, autostart, window-state, store
- Implement close-to-tray with prevent_close + prevent_exit (Pitfall 2)
- Tray menu with Oeffnen/Beenden (German labels)
- Async startup version check against server /health/version
- Generate Tessera-branded icons (yellow T on dark bg, OKLCH palette)
- Update capabilities for notification, autostart, window-state permissions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-25 14:01:12 +02:00
parent 4d94a254fd
commit c65ada0ba9
6 changed files with 1146 additions and 34 deletions
@@ -49,8 +49,9 @@ completed: 2026-06-25
- **Duration:** 3 min - **Duration:** 3 min
- **Started:** 2026-06-25T08:56:13Z - **Started:** 2026-06-25T08:56:13Z
- **Completed:** 2026-06-25T08:59:12Z - **Completed:** 2026-06-25T08:59:12Z
- **Tasks:** 2 completed, 1 awaiting human verification (Task 4) - **Tasks:** 4 completed (all)
- **Files created:** 3 - **Files created:** 3
- **CI Fixes:** 3 (corepack, prisma conditional postinstall, .gitkeep)
## Accomplishments ## Accomplishments
@@ -65,7 +66,10 @@ Each task was committed atomically:
1. **Task 1: Set up Gitea remote and register act_runner** -- completed prior to this execution (checkpoint:human-action) 1. **Task 1: Set up Gitea remote and register act_runner** -- completed prior to this execution (checkpoint:human-action)
2. **Task 2: Define act_runner service and CI/CD setup runbook** -- `c0e3293` (feat) 2. **Task 2: Define act_runner service and CI/CD setup runbook** -- `c0e3293` (feat)
3. **Task 3: Create .gitea/workflows/ci.yml multi-stage pipeline** -- `756925b` (feat) 3. **Task 3: Create .gitea/workflows/ci.yml multi-stage pipeline** -- `756925b` (feat)
4. **Task 4: Trigger the pipeline with a real push** -- checkpoint:human-verify (awaiting) 4. **Task 4: Trigger the pipeline with a real push** -- Runs #85-89, green on Run #89 (`4d94a25`)
- Fix: corepack statt pnpm/action-setup (`e5d45e1`)
- Fix: prisma postinstall conditional (`c48e61f`)
- Fix: .gitkeep in apps/web/public (`4d94a25`)
## Files Created ## Files Created
@@ -96,9 +100,8 @@ No new threat surfaces introduced beyond those documented in the plan's threat m
## Next Phase Readiness ## Next Phase Readiness
- Pipeline ready for first real push (Task 4 checkpoint pending human verification) - Pipeline verified green (Run #89) — INFRA-04 fully validated
- After push verification, INFRA-04 will be fully validated - All future pushes to main automatically lint, type-check, test, and redeploy
- All future pushes to main will automatically run lint, type-check, tests, and redeploy
## Self-Check: PASSED ## Self-Check: PASSED
@@ -107,4 +110,4 @@ No new threat surfaces introduced beyond those documented in the plan's threat m
--- ---
*Phase: 06-desktop-client-ci-cd* *Phase: 06-desktop-client-ci-cd*
*Completed: 2026-06-25 (Tasks 2-3; Task 4 pending)* *Completed: 2026-06-25 (all tasks done, pipeline green)*
+9
View File
@@ -12,4 +12,13 @@ export class HealthController {
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}; };
} }
@Public()
@Get('version')
getVersion() {
return {
version: process.env.npm_package_version ?? '0.0.1',
name: 'tessera',
};
}
} }
+1019 -14
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -13,5 +13,9 @@ tauri-build = { version = "2", features = [] }
[dependencies] [dependencies]
tauri = { version = "2", features = ["tray-icon"] } tauri = { version = "2", features = ["tray-icon"] }
tauri-plugin-store = "2" tauri-plugin-store = "2"
tauri-plugin-notification = "2"
tauri-plugin-autostart = "2"
tauri-plugin-window-state = "2"
reqwest = { version = "0.12", features = ["json"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
@@ -5,6 +5,14 @@
"windows": ["main"], "windows": ["main"],
"permissions": [ "permissions": [
"core:default", "core:default",
"store:default" "store:default",
"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"
] ]
} }
+96 -13
View File
@@ -1,23 +1,34 @@
use tauri::Manager; use tauri::{
Manager,
menu::{MenuBuilder, MenuItemBuilder},
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
RunEvent, WindowEvent,
};
use tauri_plugin_autostart::MacosLauncher;
use tauri_plugin_notification::NotificationExt;
use tauri_plugin_store::StoreExt; use tauri_plugin_store::StoreExt;
/// Main entry point for the Tessera desktop application. #[derive(serde::Deserialize)]
/// struct VersionResponse {
/// Registers plugins, checks for a stored server URL on startup, version: String,
/// and either navigates to the configured server or shows the }
/// bundled setup page for first-run configuration.
pub fn run() { pub fn run() {
tauri::Builder::default() let app = tauri::Builder::default()
.plugin(tauri_plugin_store::Builder::new().build()) .plugin(tauri_plugin_store::Builder::new().build())
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_window_state::Builder::default().build())
.plugin(tauri_plugin_autostart::init(MacosLauncher::LaunchAgent, None))
.setup(|app| { .setup(|app| {
// Attempt to read a previously stored server URL
let store = app.store("config.json")?; let store = app.store("config.json")?;
let server_url = store.get("server_url"); let server_url = store.get("server_url");
let mut url_for_check: Option<String> = None;
if let Some(url_value) = server_url { if let Some(url_value) = server_url {
if let Some(url_str) = url_value.as_str() { if let Some(url_str) = url_value.as_str() {
if !url_str.is_empty() { if !url_str.is_empty() {
// Navigate the main window to the configured server URL url_for_check = Some(url_str.to_string());
if let Some(window) = app.get_webview_window("main") { if let Some(window) = app.get_webview_window("main") {
if let Ok(parsed) = url_str.parse::<tauri::Url>() { if let Ok(parsed) = url_str.parse::<tauri::Url>() {
let _ = window.navigate(parsed); let _ = window.navigate(parsed);
@@ -26,11 +37,83 @@ pub fn run() {
} }
} }
} }
// If no server_url is stored, the WebView remains on the
// bundled setup page (frontendDist = ../src/setup.html) // Tray menu
let open = MenuItemBuilder::with_id("open", "Oeffnen").build(app)?;
let quit = MenuItemBuilder::with_id("quit", "Beenden").build(app)?;
let menu = MenuBuilder::new(app)
.item(&open)
.separator()
.item(&quit)
.build()?;
let _tray = TrayIconBuilder::new()
.icon(app.default_window_icon().unwrap().clone())
.menu(&menu)
.tooltip("Tessera")
.show_menu_on_left_click(false)
.on_menu_event(|app, event| match event.id().as_ref() {
"open" => {
if let Some(w) = app.get_webview_window("main") {
let _ = w.show();
let _ = w.set_focus();
}
}
"quit" => {
app.exit(0);
}
_ => {}
})
.on_tray_icon_event(|tray, event| {
if let TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
} = event
{
if let Some(w) = tray.app_handle().get_webview_window("main") {
let _ = w.show();
let _ = w.set_focus();
}
}
})
.build(app)?;
// Version check (async, non-blocking)
if let Some(server_url) = url_for_check {
let app_handle = app.handle().clone();
let app_version = env!("CARGO_PKG_VERSION").to_string();
tauri::async_runtime::spawn(async move {
let url = format!("{}/health/version", server_url.trim_end_matches('/'));
if let Ok(resp) = reqwest::get(&url).await {
if let Ok(info) = resp.json::<VersionResponse>().await {
if info.version != app_version {
let _ = app_handle
.notification()
.builder()
.title("Tessera Update")
.body("Eine neue Version ist verfuegbar.")
.show();
}
}
}
});
}
Ok(()) Ok(())
}) })
.run(tauri::generate_context!()) .on_window_event(|window, event| {
.expect("error while running Tessera desktop application"); if let WindowEvent::CloseRequested { api, .. } = event {
let _ = window.hide();
api.prevent_close();
}
})
.build(tauri::generate_context!())
.expect("error while building Tessera desktop application");
app.run(|_app, event| {
if let RunEvent::ExitRequested { api, .. } = event {
api.prevent_exit();
}
});
} }