From 29c132ecf35afafc7cd5864261be7b6e2add3280 Mon Sep 17 00:00:00 2001 From: Schalli Date: Thu, 17 Sep 2026 14:51:01 +0200 Subject: [PATCH] =?UTF-8?q?feat(desktop):=20Verbundenen=20Server=20im=20In?= =?UTF-8?q?fobereich=20zeigen,=20Server-Adresse=20nachtr=C3=A4glich=20?= =?UTF-8?q?=C3=A4nderbar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Tray zeigt erste (gesperrte) Menüzeile "Verbunden mit {host}" und Tooltip "Tessera – {host}" (bzw. "nicht verbunden") - Neuer Menüpunkt "Server-Adresse ändern…" navigiert zur gebündelten Setup-Seite (lokaler Ursprung, kein Capability-Eintrag nötig) - setup.html erkennt per neuem Command get_server_url den Änderungsmodus (Feld vorbelegt, "Aktuell verbunden mit: …", Knopf "Abbrechen" → open_server) - Nach save_server_url aktualisieren apply_server (Tooltip + Menüzeile) und spawn_version_check (aus setup herausgezogen, läuft neu gegen den neuen Server) ohne Neustart; Tray-Klick "update" liest die Adresse jetzt beim Klick aus dem Store - Reine Helfer server_host, tray_labels, setup_page_url, parse_server_url mit 13 neuen Tests (RED zuerst: 13 Compile-Fehler auf die fehlenden Helfer, dann GREEN); 18 Rust-Tests gesamt, fmt/check/clippy sauber - capabilities/default.json unverändert (T-JN2-01, kein remote-Block) Co-Authored-By: Claude Opus 5 (1M context) --- apps/desktop/src-tauri/src/lib.rs | 374 +++++++++++++++++++++++++----- apps/desktop/src/setup.html | 70 +++++- 2 files changed, 381 insertions(+), 63 deletions(-) diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 62006dd..82b58fb 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -1,14 +1,20 @@ use std::time::Duration; use tauri::{ - menu::{CheckMenuItemBuilder, MenuBuilder, MenuItemBuilder}, + menu::{CheckMenuItemBuilder, MenuBuilder, MenuItem, MenuItemBuilder}, tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, - AppHandle, Manager, RunEvent, WindowEvent, + AppHandle, Manager, RunEvent, WindowEvent, Wry, }; use tauri_plugin_autostart::{MacosLauncher, ManagerExt}; use tauri_plugin_notification::NotificationExt; use tauri_plugin_opener::OpenerExt; use tauri_plugin_store::StoreExt; +/// Standardtext des Update-Eintrags im Tray-Menü, bevor eine neuere Version +/// gefunden wurde. `spawn_version_check` setzt den Eintrag nach einem +/// Serverwechsel hierauf zurück, damit kein Hinweis des alten Servers stehen +/// bleibt. +const UPDATE_ITEM_DEFAULT: &str = "Update herunterladen"; + #[derive(serde::Deserialize)] struct VersionResponse { version: String, @@ -71,12 +77,147 @@ fn update_labels(version_changed: bool, version: &str, commit: &str) -> (String, } } -#[tauri::command] -async fn check_server(url: String) -> Result { - let parsed = tauri::Url::parse(&url).map_err(|_| "Diese Adresse ist ungültig.".to_string())?; +/// Parst eine vom Nutzer eingegebene Server-Adresse und erlaubt nur http/https +/// -- dieselben Fehlertexte wie bisher inline in `check_server`. `check_server`, +/// `save_server_url` und `open_server` rufen ausschliesslich diesen Helfer; +/// das Verhalten bleibt unveraendert, das Duplikat ist weg. +fn parse_server_url(url: &str) -> 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()); } + Ok(parsed) +} + +/// Liefert nur den Host der gespeicherten Server-Adresse (ohne Schema, ohne +/// Pfad) -- die Anzeige im Tray soll kurz bleiben. `host:port` nur, wenn +/// ausdruecklich ein Nicht-Standardport angegeben wurde (der `url`-Crate +/// laesst Standardports beim Parsen automatisch weg). +fn server_host(url: Option<&str>) -> Option { + let url = url?; + if url.is_empty() { + return None; + } + let parsed = tauri::Url::parse(url).ok()?; + let host = parsed.host_str()?; + match parsed.port() { + Some(port) => Some(format!("{}:{}", host, port)), + None => Some(host.to_string()), + } +} + +/// Liefert (Tooltip, Menüzeile) für den Tray -- einzige Stelle, die beide +/// Texte aus der Server-Adresse ableitet. Gedankenstrich U+2013 wie in +/// `update_labels`. +fn tray_labels(url: Option<&str>) -> (String, String) { + match server_host(url) { + Some(host) => ( + format!("Tessera – {}", host), + format!("Verbunden mit {}", host), + ), + None => ( + "Tessera – nicht verbunden".to_string(), + "Nicht verbunden".to_string(), + ), + } +} + +/// Spiegelt Tauris nicht öffentliche `tauri_protocol_url` +/// (tauri-2.11.3 manager/mod.rs Z. 339-346): WebView2 kennt kein eigenes +/// Schema, darum unter Windows/Android `http://tauri.localhost`, sonst +/// `tauri://localhost`. `useHttpsScheme` ist in tauri.conf.json nicht +/// gesetzt, darum bewusst `http`. Der `tauri dev`-Fall mit `devUrl` wird in +/// diesem Projekt nicht genutzt (CI baut Release) und ist hier bewusst nicht +/// abgebildet. `WebviewUrl::App` laesst sich nicht an `navigate` geben, +/// darum die feste Adresse. +fn setup_page_url(windows: bool) -> tauri::Url { + let base = if windows { + "http://tauri.localhost/setup.html" + } else { + "tauri://localhost/setup.html" + }; + tauri::Url::parse(base).expect("setup_page_url: feste Adresse muss gueltig sein") +} + +/// Liest die gespeicherte Server-Adresse aus dem Store -- einzige Lesestelle +/// (Start, `get_server_url`, `open_server`, Tray-Klick "update"); nur +/// nicht-leere Werte gelten als gespeichert. +fn stored_server_url(app: &AppHandle) -> Option { + let store = app.store("config.json").ok()?; + let value = store.get("server_url")?; + let s = value.as_str()?.to_string(); + if s.is_empty() { + None + } else { + Some(s) + } +} + +/// Menü-Handles für die Auffrischung ohne Neustart (`apply_server`, +/// `spawn_version_check`). `MenuItem` ist Send + Sync, `app.manage` verlangt +/// das. +struct TrayItems { + connected: MenuItem, + update: MenuItem, +} + +/// Setzt Tooltip und die gesperrte "Verbunden mit ..."-Zeile aus derselben +/// Quelle -- die EINZIGE Stelle, die beide Anzeigen setzt (Start und +/// Wechsel), sonst laufen die drei Anzeigen auseinander. `set_tooltip` ist +/// unter Linux ein No-Op (Tauri-Doku); die Menüzeile bleibt dort die Anzeige. +/// Fehler werden wie im Bestand ignoriert. +fn apply_server(app: &AppHandle, url: Option<&str>) { + let (tooltip, line) = tray_labels(url); + if let Some(tray) = app.tray_by_id("main") { + let _ = tray.set_tooltip(Some(tooltip)); + } + let _ = app.state::().connected.set_text(line); +} + +/// Versionsprüfung, aus `setup` herausgezogen, damit sie nach einem +/// Serverwechsel erneut laufen kann (async, non-blocking wie bisher). Setzt +/// den Update-Eintrag zuerst auf den Standardtext zurück -- nach einem +/// Wechsel darf kein Hinweis des alten Servers stehen bleiben. +fn spawn_version_check(app: AppHandle, server_url: String) { + let update_item = app.state::().update.clone(); + let _ = update_item.set_text(UPDATE_ITEM_DEFAULT); + let _ = update_item.set_enabled(false); + + let app_version = env!("CARGO_PKG_VERSION").to_string(); + let app_commit = env!("APP_COMMIT").to_string(); + 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 + .notification() + .builder() + .title("Tessera-Update") + .body(body) + .show(); + let _ = update_item.set_text(menu_text); + let _ = update_item.set_enabled(true); + } + } + } + }); +} + +#[tauri::command] +async fn check_server(url: String) -> Result { + parse_server_url(&url)?; let client = reqwest::Client::builder() .timeout(Duration::from_secs(8)) @@ -106,13 +247,16 @@ async fn check_server(url: String) -> Result { #[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 parsed = parse_server_url(&url)?; 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())?; + apply_server(&app, Some(&normalized)); + spawn_version_check(app.clone(), normalized.clone()); + if let Some(window) = app.get_webview_window("main") { let _ = window.navigate(with_desktop_marker(&parsed)); } @@ -120,6 +264,30 @@ fn save_server_url(app: AppHandle, url: String) -> Result<(), String> { Ok(()) } +/// Liefert NUR den gespeicherten Wert `server_url` aus dem Store, nichts +/// anderes. Nur vom lokalen Ursprung aufrufbar (T-JN2-01): capabilities/ +/// default.json hat keinen `remote`-Block, darum verweigert Tauri App- +/// Commands aus der Server-Seite (tauri-2.11.3 webview/mod.rs Z. 1819-1823). +#[tauri::command] +fn get_server_url(app: AppHandle) -> Option { + stored_server_url(&app) +} + +/// Navigiert das Fenster `main` zur gespeicherten Server-Adresse -- Gegenstück +/// zum "Abbrechen"-Knopf der Setup-Seite im Änderungsmodus. +#[tauri::command] +fn open_server(app: AppHandle) -> Result<(), String> { + let stored = stored_server_url(&app) + .ok_or_else(|| "Es ist keine Server-Adresse gespeichert.".to_string())?; + let parsed = parse_server_url(&stored)?; + if let Some(window) = app.get_webview_window("main") { + window + .navigate(with_desktop_marker(&parsed)) + .map_err(|e| e.to_string())?; + } + Ok(()) +} + pub fn run() { let app = tauri::Builder::default() .plugin(tauri_plugin_store::Builder::new().build()) @@ -130,31 +298,37 @@ pub fn run() { None, )) .plugin(tauri_plugin_opener::init()) - .invoke_handler(tauri::generate_handler![check_server, save_server_url]) + .invoke_handler(tauri::generate_handler![ + check_server, + save_server_url, + get_server_url, + open_server + ]) .setup(|app| { - let store = app.store("config.json")?; - let server_url = store.get("server_url"); + let server_url = stored_server_url(app.handle()); - 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)); - } - } + if let Some(url) = &server_url { + if let Some(window) = app.get_webview_window("main") { + if let Ok(parsed) = parse_server_url(url) { + 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. + // Tray menu: Verbunden mit ... (gesperrt) · — · Öffnen · + // Server-Adresse ändern... · Update herunterladen · — · + // Autostart-Haken · — · Beenden. + // "connected" ist nicht anklickbar; "update" bleibt gesperrt, bis + // die Versionspruefung eine neuere Version findet; "autostart" + // spiegelt den tatsaechlichen Systemzustand beim Start. + let connected = + MenuItemBuilder::with_id("connected", tray_labels(server_url.as_deref()).1) + .enabled(false) + .build(app)?; let open = MenuItemBuilder::with_id("open", "Öffnen").build(app)?; - let update = MenuItemBuilder::with_id("update", "Update herunterladen") + let change_server = + MenuItemBuilder::with_id("change_server", "Server-Adresse ändern…").build(app)?; + let update = MenuItemBuilder::with_id("update", UPDATE_ITEM_DEFAULT) .enabled(false) .build(app)?; let autostart_label = if cfg!(target_os = "windows") { @@ -168,7 +342,10 @@ pub fn run() { let quit = MenuItemBuilder::with_id("quit", "Beenden").build(app)?; let menu = MenuBuilder::new(app) + .item(&connected) + .separator() .item(&open) + .item(&change_server) .item(&update) .separator() .item(&autostart) @@ -176,13 +353,17 @@ pub fn run() { .item(&quit) .build()?; - let server_for_menu = url_for_check.clone(); + app.manage(TrayItems { + connected: connected.clone(), + update: update.clone(), + }); + let autostart_for_menu = autostart.clone(); - let _tray = TrayIconBuilder::new() + let _tray = TrayIconBuilder::with_id("main") .icon(app.default_window_icon().unwrap().clone()) .menu(&menu) - .tooltip("Tessera") + .tooltip(tray_labels(server_url.as_deref()).0) .show_menu_on_left_click(false) .on_menu_event(move |app, event| match event.id().as_ref() { "open" => { @@ -193,8 +374,16 @@ pub fn run() { let _ = w.set_focus(); } } + "change_server" => { + if let Some(w) = app.get_webview_window("main") { + let _ = w.navigate(setup_page_url(cfg!(windows))); + let _ = w.unminimize(); + let _ = w.show(); + let _ = w.set_focus(); + } + } "update" => { - if let Some(server) = &server_for_menu { + if let Some(server) = stored_server_url(app) { let target = format!( "{}/settings/general/desktop", server.trim_end_matches('/') @@ -240,40 +429,10 @@ pub fn run() { }) .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); - } - } - } - }); + apply_server(app.handle(), server_url.as_deref()); + + if let Some(url) = server_url { + spawn_version_check(app.handle().clone(), url); } Ok(()) @@ -346,4 +505,95 @@ mod tests { "Neuer Beta-Stand abc1234 verfügbar – Download über das Symbol im Infobereich." ); } + + #[test] + fn server_host_mit_pfad_und_ohne_port() { + assert_eq!( + server_host(Some("https://tessera.ctl.de/")), + Some("tessera.ctl.de".to_string()) + ); + } + + #[test] + fn server_host_mit_nicht_standard_port() { + assert_eq!( + server_host(Some("http://localhost:3000/")), + Some("localhost:3000".to_string()) + ); + } + + #[test] + fn server_host_laesst_standardport_pfad_und_query_weg() { + assert_eq!( + server_host(Some("https://host:443/pfad?x=1")), + Some("host".to_string()) + ); + } + + #[test] + fn server_host_none_bei_none() { + assert_eq!(server_host(None), None); + } + + #[test] + fn server_host_none_bei_leerem_string() { + assert_eq!(server_host(Some("")), None); + } + + #[test] + fn server_host_none_bei_unparsbarer_adresse() { + assert_eq!(server_host(Some("kein url")), None); + } + + #[test] + fn tray_labels_mit_host() { + let (tooltip, line) = tray_labels(Some("https://tessera.ctl.de/")); + assert_eq!(tooltip, "Tessera – tessera.ctl.de"); + assert_eq!(line, "Verbunden mit tessera.ctl.de"); + } + + #[test] + fn tray_labels_ohne_adresse() { + let (tooltip, line) = tray_labels(None); + assert_eq!(tooltip, "Tessera – nicht verbunden"); + assert_eq!(line, "Nicht verbunden"); + } + + #[test] + fn setup_page_url_unter_windows() { + assert_eq!( + setup_page_url(true).as_str(), + "http://tauri.localhost/setup.html" + ); + } + + #[test] + fn setup_page_url_unter_linux() { + assert_eq!( + setup_page_url(false).as_str(), + "tauri://localhost/setup.html" + ); + } + + #[test] + fn parse_server_url_gueltige_adresse() { + let parsed = parse_server_url("https://tessera.ctl.de").unwrap(); + assert_eq!(parsed.as_str(), "https://tessera.ctl.de/"); + } + + #[test] + fn parse_server_url_lehnt_fremdes_schema_ab() { + assert_eq!( + parse_server_url("ftp://host"), + Err("Es sind nur Adressen mit http oder https erlaubt.".to_string()) + ); + } + + #[test] + fn parse_server_url_lehnt_unparsbare_adresse_ab() { + assert_eq!( + parse_server_url("kein url"), + Err("Diese Adresse ist ungültig.".to_string()) + ); + } } diff --git a/apps/desktop/src/setup.html b/apps/desktop/src/setup.html index a26560a..e13f7a5 100644 --- a/apps/desktop/src/setup.html +++ b/apps/desktop/src/setup.html @@ -140,6 +140,25 @@ opacity: 0.5; cursor: not-allowed; } + + .current-server { + display: none; + text-align: left; + font-size: 0.8125rem; + color: oklch(0.75 0 0); + margin: -20px 0 24px; + } + + button.secondary { + margin-top: 12px; + background: transparent; + color: oklch(0.85 0 0); + border: 1px solid oklch(0.30 0.01 260); + } + + button[hidden] { + display: none; + } @@ -155,7 +174,8 @@

Tessera

-

Desktop-App einrichten

+

Desktop-App einrichten

+

Das ist die Adresse, unter der Sie Tessera auch im Browser öffnen.

@@ -171,6 +191,7 @@

+