use std::time::Duration; use tauri::{ menu::{CheckMenuItemBuilder, MenuBuilder, MenuItemBuilder}, tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, AppHandle, Manager, RunEvent, WindowEvent, }; use tauri_plugin_autostart::{MacosLauncher, ManagerExt}; use tauri_plugin_notification::NotificationExt; use tauri_plugin_opener::OpenerExt; use tauri_plugin_store::StoreExt; #[derive(serde::Deserialize)] struct VersionResponse { version: String, } #[derive(serde::Deserialize)] struct DesktopLatest { version: String, channel: String, commit: String, } /// Baut die Adresse eines API-Pfads aus der gespeicherten Server-Adresse. /// Die API ist vom Client nur ueber den Web-Ursprung erreichbar /// (Next.js-Rewrite `/api-proxy/*`, siehe 18-01) -- niemals direkt unter dem /// Web-Hostnamen. Dies ist die einzige Stelle, an der der Rewrite-Praefix /// steht. fn api_url(server: &str, path: &str) -> String { format!("{}/api-proxy{}", server.trim_end_matches('/'), path) } /// Haengt das Query-Paar `desktop=1` an einen Klon der Adresse an und gibt /// den Klon zurueck -- das Original bleibt unveraendert. Der Parameter geht /// NUR in die Navigation, niemals in den gespeicherten Server-Wert /// (`server_url` im Store bleibt die reine Adresse); daraus liest die /// Next.js-Middleware (apps/web/src/middleware.ts) das Cookie /// `tessera_desktop`, an dem der Web-Client den Desktop-Client erkennt. Die /// Tray-URL "Update herunterladen" bekommt den Parameter bewusst NICHT -- /// sie oeffnet im System-Browser, dort muessen die Download-Links sichtbar /// bleiben. fn with_desktop_marker(url: &tauri::Url) -> tauri::Url { let mut marked = url.clone(); marked.query_pairs_mut().append_pair("desktop", "1"); marked } /// Liefert (Menuetext, Benachrichtigungstext) fuer den Update-Hinweis. Der /// Beta-Kanal (main) vergibt jedem Commit dieselbe X.Y.Z-Version (D-07, /// desktop-collect.sh) -- hat sich nur der Commit geaendert, waere der reine /// Versionstext ("Version 1.1.0 herunterladen") verwirrend, weil der Client /// bereits genau diese Version hat. Der zweite Zweig nennt darum den /// Commit-Stempel statt der unveraenderten Version. fn update_labels(version_changed: bool, version: &str, commit: &str) -> (String, String) { if version_changed { ( format!("Version {} herunterladen", version), format!( "Neue Version {} verfügbar – Download über das Symbol im Infobereich.", version ), ) } else { ( "Neuen Beta-Stand herunterladen".to_string(), format!( "Neuer Beta-Stand {} verfügbar – Download über das Symbol im Infobereich.", commit ), ) } } #[tauri::command] async fn check_server(url: String) -> Result { let parsed = tauri::Url::parse(&url).map_err(|_| "Diese Adresse ist ungültig.".to_string())?; if parsed.scheme() != "http" && parsed.scheme() != "https" { return Err("Es sind nur Adressen mit http oder https erlaubt.".to_string()); } let client = reqwest::Client::builder() .timeout(Duration::from_secs(8)) .build() .map_err(|e| e.to_string())?; let resp = client .get(api_url(&url, "/health/version")) .send() .await .map_err(|_| "Unter dieser Adresse antwortet kein Tessera-Server.".to_string())?; if !resp.status().is_success() { return Err(format!( "Der Server antwortete mit Status {}.", resp.status().as_u16() )); } let info = resp .json::() .await .map_err(|_| "Unter dieser Adresse antwortet kein Tessera-Server.".to_string())?; Ok(info.version) } #[tauri::command] fn save_server_url(app: AppHandle, url: String) -> Result<(), String> { let parsed = tauri::Url::parse(&url).map_err(|_| "Diese Adresse ist ungültig.".to_string())?; let normalized = parsed.as_str().to_string(); let store = app.store("config.json").map_err(|e| e.to_string())?; store.set("server_url", serde_json::json!(normalized)); store.save().map_err(|e| e.to_string())?; if let Some(window) = app.get_webview_window("main") { let _ = window.navigate(with_desktop_marker(&parsed)); } Ok(()) } pub fn run() { let app = tauri::Builder::default() .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, )) .plugin(tauri_plugin_opener::init()) .invoke_handler(tauri::generate_handler![check_server, save_server_url]) .setup(|app| { let store = app.store("config.json")?; let server_url = store.get("server_url"); let mut url_for_check: Option = None; if let Some(url_value) = server_url { if let Some(url_str) = url_value.as_str() { if !url_str.is_empty() { url_for_check = Some(url_str.to_string()); if let Some(window) = app.get_webview_window("main") { if let Ok(parsed) = url_str.parse::() { let _ = window.navigate(with_desktop_marker(&parsed)); } } } } } // Tray menu: Öffnen · Update herunterladen · — · Autostart-Haken · — · Beenden. // "update" bleibt gesperrt, bis die Versionspruefung eine neuere Version // findet; "autostart" spiegelt den tatsaechlichen Systemzustand beim Start. let open = MenuItemBuilder::with_id("open", "Öffnen").build(app)?; let update = MenuItemBuilder::with_id("update", "Update herunterladen") .enabled(false) .build(app)?; let autostart_label = if cfg!(target_os = "windows") { "Mit Windows starten" } else { "Beim Anmelden starten" }; let autostart = CheckMenuItemBuilder::with_id("autostart", autostart_label) .checked(app.autolaunch().is_enabled().unwrap_or(false)) .build(app)?; let quit = MenuItemBuilder::with_id("quit", "Beenden").build(app)?; let menu = MenuBuilder::new(app) .item(&open) .item(&update) .separator() .item(&autostart) .separator() .item(&quit) .build()?; let server_for_menu = url_for_check.clone(); let autostart_for_menu = autostart.clone(); let _tray = TrayIconBuilder::new() .icon(app.default_window_icon().unwrap().clone()) .menu(&menu) .tooltip("Tessera") .show_menu_on_left_click(false) .on_menu_event(move |app, event| match event.id().as_ref() { "open" => { if let Some(w) = app.get_webview_window("main") { // Minimiertes Fenster (z. B. per Win+D) zuerst wiederherstellen let _ = w.unminimize(); let _ = w.show(); let _ = w.set_focus(); } } "update" => { if let Some(server) = &server_for_menu { let target = format!( "{}/settings/general/desktop", server.trim_end_matches('/') ); let _ = app.opener().open_url(target, None::<&str>); } } "autostart" => { let mgr = app.autolaunch(); let currently_on = mgr.is_enabled().unwrap_or(false); let toggled = if currently_on { mgr.disable() } else { mgr.enable() }; match toggled { Ok(()) => { let _ = autostart_for_menu.set_checked(!currently_on); } Err(_) => { let _ = autostart_for_menu.set_checked(currently_on); } } } "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.unminimize(); 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(); let app_commit = env!("APP_COMMIT").to_string(); let update_item = update.clone(); tauri::async_runtime::spawn(async move { let url = api_url(&server_url, "/desktop/latest"); if let Ok(resp) = reqwest::get(&url).await { if let Ok(info) = resp.json::().await { // Beta-Kanal (main) vergibt jedem Commit dieselbe X.Y.Z-Version // (D-07, desktop-collect.sh) -- ohne den Commit-Vergleich saehe // ein Beta-Client zwischen zwei Freigabe-Tags nie einen neueren // Bau (WR-02, Code-Review Phase 18). Fuer den Live-Kanal bleibt // es beim reinen Versionsvergleich. Die Texte fuer Menue und // Benachrichtigung liefert `update_labels`. let version_changed = info.version != app_version; let is_newer = version_changed || (info.channel == "beta" && info.commit != app_commit); if is_newer { let (menu_text, body) = update_labels(version_changed, &info.version, &info.commit); let _ = app_handle .notification() .builder() .title("Tessera-Update") .body(body) .show(); let _ = update_item.set_text(menu_text); let _ = update_item.set_enabled(true); } } } }); } Ok(()) }) .on_window_event(|window, event| { 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| { // Fenster schliessen setzt code auf None -- App laeuft dann im // Infobereich weiter (siehe on_window_event). Der Tray-Eintrag // "Beenden" ruft dagegen app.exit(0), das liefert code: Some -- // dieser Fall muss durchgelassen werden, sonst bleibt der Prozess // samt Tray-Symbol haengen. if let RunEvent::ExitRequested { code: None, api, .. } = event { api.prevent_exit(); } }); } #[cfg(test)] mod tests { use super::*; #[test] fn with_desktop_marker_ohne_pfad_haengt_query_an() { let url = tauri::Url::parse("https://tessera.example.com").unwrap(); let marked = with_desktop_marker(&url); assert_eq!(marked.as_str(), "https://tessera.example.com/?desktop=1"); } #[test] fn with_desktop_marker_mit_vorhandenem_query_haengt_an() { let url = tauri::Url::parse("https://host/app?x=1").unwrap(); let marked = with_desktop_marker(&url); assert_eq!(marked.as_str(), "https://host/app?x=1&desktop=1"); } #[test] fn with_desktop_marker_laesst_original_unveraendert() { let url = tauri::Url::parse("https://host/app").unwrap(); let _ = with_desktop_marker(&url); assert_eq!(url.query(), None); } #[test] fn update_labels_bei_versionswechsel() { let (menu_text, body) = update_labels(true, "1.2.0", "abc1234"); assert_eq!(menu_text, "Version 1.2.0 herunterladen"); assert_eq!( body, "Neue Version 1.2.0 verfügbar – Download über das Symbol im Infobereich." ); } #[test] fn update_labels_bei_gleicher_version_beta_commit() { let (menu_text, body) = update_labels(false, "1.1.0", "abc1234"); assert_eq!(menu_text, "Neuen Beta-Stand herunterladen"); assert_eq!( body, "Neuer Beta-Stand abc1234 verfügbar – Download über das Symbol im Infobereich." ); } }