From d73aad1ef19e416af676705c1a84bcefc5bcc4b4 Mon Sep 17 00:00:00 2001 From: Schalli Date: Tue, 22 Sep 2026 11:26:09 +0200 Subject: [PATCH] fix(desktop): Update-Eintrag nennt den Grund einer fehlgeschlagenen Pruefung, Klick prueft erneut, Pruefung alle 4 h Befund 22.09.2026: alpha bietet 1.2.0-beta.gc001a08 an, der Client auf a6d1a64 zeigt aber nur den grauen Eintrag "Update installieren". Der Nginx Proxy Manager vor alpha beantwortet die Update-Anfrage mit 401 (Basic-Auth); die Webansicht kann das Passwortfenster beantworten, der Updater (eigener reqwest-Client) nicht. tauri-plugin-updater verschluckt einen Nicht-2xx-Status (updater.rs Z. 529-559: nur Log, last_error leer, Ergebnis Err(ReleaseNotFound)), und spawn_version_check fing das mit Err(_) => {} stumm ab. Ein fehlgeschlagener Check war damit vom Zustand "kein Update" nicht unterscheidbar, und ohne Neustart gab es keinen Weg, erneut zu pruefen. - Drei Endzustaende, alle anklickbar: "Auf Version/Beta-Stand ... aktualisieren", "Kein Update verfuegbar - erneut pruefen", "Update-Pruefung fehlgeschlagen (HTTP n | keine Verbindung) - erneut pruefen"; waehrend der Pruefung "Suche nach Updates..." (gesperrt), http-Server unveraendert gesperrt - Bei ReleaseNotFound stellt der Client dieselbe Anfrage einmal selbst (Platzhalter ersetzt, Timeout 8 s) und liest nur den Statuscode; 401/403 erklaeren den Proxy-Passwortschutz, Zugangsdaten werden bewusst NICHT in den Client eingebaut - Benachrichtigung je unterschiedlichem Fehlertext einmal (LastCheckNotice), Erfolg leert die Entprellung - Wiederhol-Thread alle 4 h (std::thread, kein neues Crate), liest die Adresse frisch, ueberspringt bei bereits abgelegtem Update - Klick ohne abgelegtes Update prueft erneut; der Browser-Weg bleibt nur Rueckfall einer fehlgeschlagenen Installation - 7 neue Tests (Labels, Diagnose-URL, Konstanten), zuvor rot (E0425) Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 1 + apps/desktop/src-tauri/src/lib.rs | 320 +++++++++++++++++++++++++++--- 2 files changed, 292 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c80f85..1b4f52c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T ### Behoben +- Desktop-App: der Update-Eintrag im Menü des Infobereich-Symbols bleibt nicht mehr stumm ausgegraut – schlägt die Update-Prüfung fehl, steht der Grund im Eintrag (z. B. „HTTP 401“, wenn ein Passwortschutz am Proxy die Anfrage abweist) und ein Klick prüft erneut; die App prüft außerdem alle vier Stunden, nicht mehr nur beim Start - Favoriten-Widget: kein Symbol bei Seiten mit Zertifikatsfehler oder internen Adressen – das Symbol wird jetzt trotz Zertifikatsfehler geholt, bei internen Adressen versucht es der Browser direkt ## 1.2.0 – 2026-09-17 diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 2d1d020..899fff3 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -12,13 +12,24 @@ use tauri_plugin_opener::OpenerExt; use tauri_plugin_store::StoreExt; use tauri_plugin_updater::{Update, UpdaterExt}; -/// Standardtext des Update-Eintrags im Tray-Menü, bevor eine neuere Version -/// gefunden wurde. Der Eintrag installiert das Update seit quick-260917-kgc -/// direkt in der App (Download, Signaturpruefung, Installation, Neustart); -/// den Browser oeffnet er nur noch als Rueckfall. `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 installieren"; +/// Menuetext des Update-Eintrags, solange eine Pruefung laeuft (gesperrt). +/// `spawn_version_check` setzt den Eintrag zu Beginn hierauf, auch nach einem +/// Serverwechsel, damit kein Hinweis des alten Servers stehen bleibt. Der +/// Eintrag installiert ein gefundenes Update seit quick-260917-kgc direkt in +/// der App (Download, Signaturpruefung, Installation, Neustart). +const UPDATE_ITEM_CHECKING: &str = "Suche nach Updates…"; + +/// Menuetext, wenn die Pruefung kein Update ergab. Der Eintrag bleibt +/// anklickbar und startet die Pruefung erneut (quick-260922-frg): der Nutzer +/// soll nie vor einem stumm ausgegrauten Eintrag stehen. Auch der Starttext +/// ohne gespeicherte Server-Adresse (Klick ohne Adresse tut nichts). +const UPDATE_ITEM_NONE: &str = "Kein Update verfügbar – erneut prüfen"; + +/// Abstand der wiederholten Update-Pruefung. Die Tray-App laeuft tagelang +/// durch; mit der Pruefung nur beim Start hat ein Client ein neues Paket auf +/// dem Server nie gesehen (Befund 22.09.2026, quick-260922-frg). Vier +/// Stunden reichen fuer den Beta-Kanal und belasten den Server nicht. +const UPDATE_CHECK_INTERVAL: Duration = Duration::from_secs(4 * 3600); /// Menuetext, wenn die gespeicherte Server-Adresse mit `http://` beginnt: /// das Updater-Plugin verweigert im Release-Bau unverschluesselte Endpunkte @@ -37,6 +48,15 @@ struct VersionResponse { /// `Update` ist Clone + Send + Sync, `app.manage` verlangt das. struct PendingUpdate(Mutex>); +/// Benachrichtigungstext der zuletzt gemeldeten fehlgeschlagenen +/// Update-Pruefung. Die Pruefung laeuft alle `UPDATE_CHECK_INTERVAL` erneut; +/// gegen einen dauerhaft sperrenden Proxy wuerde sonst alle vier Stunden +/// dieselbe Meldung erscheinen (T-FRG-02). Nur ein ABWEICHENDER Text wird +/// erneut gemeldet; ein Erfolg (`Ok(..)`) leert den Wert, damit ein spaeterer +/// Fehler wieder sichtbar wird. Eigener `app.manage`-Typ, damit +/// `spawn_version_check` seine Signatur behaelt. +struct LastCheckNotice(Mutex); + /// 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 @@ -76,7 +96,7 @@ fn with_client_marker(url: &tauri::Url, version: &str, commit: &str, os: &str) - /// aeltere Middleware weiterhin als Desktop-App erkannt wird. Die Werte /// gehen NUR in die Navigation, niemals in den gespeicherten Server-Wert /// (`server_url` im Store bleibt die reine Adresse). Der Browser-Rueckfall -/// des Tray-Eintrags "Update installieren" (`open_download_page`) bekommt +/// einer fehlgeschlagenen Update-Installation (`open_download_page`) bekommt /// den Marker bewusst NICHT -- er oeffnet im System-Browser, dort muessen /// die Download-Links sichtbar bleiben. fn with_desktop_marker(url: &tauri::Url) -> tauri::Url { @@ -179,6 +199,78 @@ fn update_endpoint(server: &str) -> Option { Some(url) } +/// Baut die Update-Adresse mit ERSETZTEN Platzhaltern -- dieselbe Anfrage, +/// die das Plugin stellt (`update_endpoint`, gleiche Kodierung ueber +/// `query_pairs_mut`), nur mit konkreten Werten. `spawn_version_check` stellt +/// sie nach `Err(ReleaseNotFound)` einmal selbst, um den Statuscode zu +/// erfahren: das Plugin verschluckt einen Nicht-2xx-Status (updater.rs +/// Z. 529-559 loggt nur, `last_error` bleibt leer, Ergebnis ist +/// `ReleaseNotFound`). Reine Funktion, darum in den Tests mit festen Werten +/// geprueft. +fn diagnostic_update_url(server: &str, target: &str, arch: &str, current: &str) -> String { + let base = server.trim_end_matches('/'); + match tauri::Url::parse(&api_url(server, "/desktop/update")) { + Ok(mut url) => { + url.query_pairs_mut() + .append_pair("target", target) + .append_pair("arch", arch) + .append_pair("current", current) + .append_pair("base", base); + url.into() + } + // Gespeicherte Adressen sind immer parsebar (parse_server_url); + // reine Absicherung, damit die Funktion kein Option liefern muss. + Err(_) => api_url(server, "/desktop/update"), + } +} + +/// Liefert (Menuetext, Benachrichtigungstext) fuer eine fehlgeschlagene +/// Update-Pruefung. `status` ist der Antwortcode des Servers, wenn er +/// geantwortet hat (`Some`), sonst `None` (keine Verbindung, TLS, Timeout). +/// 401/403 bekommen die Proxy-Erklaerung: Befund 22.09.2026 -- der Nginx +/// Proxy Manager vor alpha verlangt Basic-Auth, die Webansicht beantwortet +/// das Passwortfenster, der Updater (eigener `reqwest`-Client) kann es nicht. +/// Zugangsdaten gehoeren nicht in ausgelieferte Clients (T-FRG-03), darum +/// wird der Grund angezeigt, nicht umgangen. Alle Texte enden auf +/// "– erneut prüfen", weil der Klick die Pruefung wiederholt. +fn check_failure_labels(status: Option) -> (String, String) { + match status { + Some(code @ (401 | 403)) => ( + format!("Update-Prüfung fehlgeschlagen (HTTP {code}) – erneut prüfen"), + format!( + "Der Server hat die Update-Anfrage mit HTTP {code} abgewiesen. Meist steht ein \ + Passwortschutz oder eine Zugriffsliste am vorgeschalteten Proxy davor, die die \ + App für Updates nicht durchlaufen kann. Anmeldung und Arbeiten in der App sind \ + davon nicht betroffen." + ), + ), + Some(code) => ( + format!("Update-Prüfung fehlgeschlagen (HTTP {code}) – erneut prüfen"), + format!("Der Server hat auf die Update-Anfrage mit HTTP {code} geantwortet statt mit Paketdaten."), + ), + None => ( + "Update-Prüfung fehlgeschlagen (keine Verbindung) – erneut prüfen".to_string(), + "Der Server war für die Update-Prüfung nicht erreichbar. Die App prüft in vier \ + Stunden erneut – oder über den Menüeintrag." + .to_string(), + ), + } +} + +/// Stellt die Update-Anfrage ein zweites Mal mit eigenem `reqwest`-Client +/// (Timeout 8 s, Muster `check_server`) und liest NUR den Statuscode -- der +/// Rumpf wird nicht ausgewertet, Redirects folgen dem Standard (T-FRG-01: +/// Ziel ist die vom Nutzer gespeicherte Adresse, kein Geheimnis in der +/// Anfrage). `None`, wenn keine Antwort kam. +async fn probe_update_status(url: String) -> Option { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(8)) + .build() + .ok()?; + let resp = client.get(url).send().await.ok()?; + Some(resp.status().as_u16()) +} + /// Leitet (Menuetext, Benachrichtigungstext) aus Client- und Server-Version /// ab: eine neue Basis X.Y.Z wird als Version genannt (auch bei Beta), bei /// gleicher Basis der Beta-Stempel. @@ -321,14 +413,57 @@ async fn check_for_update( .await } +/// Meldet eine fehlgeschlagene Update-Pruefung: Menuetext mit Grund, Eintrag +/// wieder anklickbar (Klick = erneut pruefen), Benachrichtigung nur, wenn +/// der Text vom zuletzt gemeldeten abweicht (`LastCheckNotice`, T-FRG-02). +fn report_check_failure(app: &AppHandle, update_item: &MenuItem, status: Option) { + let (menu_text, body) = check_failure_labels(status); + let _ = update_item.set_text(&menu_text); + let _ = update_item.set_enabled(true); + let changed = app + .state::() + .0 + .lock() + .map(|mut last| { + if *last == body { + false + } else { + *last = body.clone(); + true + } + }) + .unwrap_or(true); + if changed { + let _ = app + .notification() + .builder() + .title("Tessera-Update") + .body(body) + .show(); + } +} + +/// Leert den zuletzt gemeldeten Fehlertext -- nach einer gelungenen Pruefung +/// soll ein spaeterer Fehler wieder gemeldet werden. +fn clear_check_notice(app: &AppHandle) { + if let Ok(mut last) = app.state::().0.lock() { + last.clear(); + } +} + /// 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 und leert den -/// abgelegten Update-Stand -- nach einem Wechsel darf kein Hinweis des alten -/// Servers stehen bleiben. +/// Serverwechsel, beim Klick auf den Eintrag und alle +/// `UPDATE_CHECK_INTERVAL` erneut laufen kann (async, non-blocking wie +/// bisher). Setzt den Update-Eintrag zuerst auf "Suche nach Updates…" +/// (gesperrt) und leert den abgelegten Update-Stand -- nach einem Wechsel +/// darf kein Hinweis des alten Servers stehen bleiben. Jeder Ausgang der +/// Pruefung endet in einem sichtbaren, anklickbaren Zustand (Update gefunden +/// / kein Update / fehlgeschlagen mit Grund); nur der http-Fall bleibt +/// gesperrt. Ein fehlgeschlagener Check war bisher vom Zustand "kein Update" +/// nicht unterscheidbar (quick-260922-frg). 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_text(UPDATE_ITEM_CHECKING); let _ = update_item.set_enabled(false); if let Ok(mut pending) = app.state::().0.lock() { *pending = None; @@ -365,8 +500,13 @@ fn spawn_version_check(app: AppHandle, server_url: String) { if let Ok(mut pending) = app.state::().0.lock() { *pending = Some((update, menu_text)); } + clear_check_notice(&app); + } + Ok(None) => { + let _ = update_item.set_text(UPDATE_ITEM_NONE); + let _ = update_item.set_enabled(true); + clear_check_notice(&app); } - Ok(None) => {} // Der Release-Bau verweigert http-Endpunkte (config.rs // `validate_endpoints`), der Debug-Bau warnt nur. Keine // `dangerousInsecureTransportProtocol`-Freigabe (T-KGC-06): der @@ -374,15 +514,33 @@ fn spawn_version_check(app: AppHandle, server_url: String) { Err(tauri_plugin_updater::Error::InsecureTransportProtocol) => { let _ = update_item.set_text(UPDATE_ITEM_INSECURE); } - Err(_) => {} + // Der Server hat geantwortet, aber nicht mit 2xx/204: das Plugin + // verschluckt den Statuscode (updater.rs Z. 529-559). Dieselbe + // Anfrage einmal selbst stellen und nur den Status lesen -- so + // steht im Eintrag "HTTP 401", wenn ein Passwortschutz am Proxy + // die Anfrage abweist (Befund 22.09.2026). + Err(tauri_plugin_updater::Error::ReleaseNotFound) => { + let status = probe_update_status(diagnostic_update_url( + &server_url, + std::env::consts::OS, + std::env::consts::ARCH, + env!("CARGO_PKG_VERSION"), + )) + .await; + report_check_failure(&app, &update_item, status); + } + // Keine Verbindung, TLS, Timeout (Reqwest/Network/Io) oder + // unlesbares Manifest: keine zweite Anfrage, Status unbekannt. + Err(_) => report_check_failure(&app, &update_item, None), } }); } /// Oeffnet die Seite Einstellungen → Desktop-App im System-Browser -- der -/// bisherige Weg des Tray-Eintrags, jetzt Rueckfall, wenn kein Update -/// abgelegt ist oder die Installation in der App fehlschlaegt. Bewusst ohne -/// `desktop=1` (siehe `with_desktop_marker`). +/// bisherige Weg des Tray-Eintrags, seit quick-260922-frg NUR noch Rueckfall, +/// wenn die Installation in der App fehlschlaegt (ein Klick ohne abgelegtes +/// Update prueft stattdessen erneut). Bewusst ohne `desktop=1` (siehe +/// `with_desktop_marker`). fn open_download_page(app: &AppHandle) { if let Some(server) = stored_server_url(app) { let target = format!("{}/settings/general/desktop", server.trim_end_matches('/')); @@ -566,13 +724,16 @@ pub fn run() { } // Tray menu: Verbunden mit ... (gesperrt) · — · Öffnen · - // Server-Adresse ändern... · Update installieren · — · + // Server-Adresse ändern... · Suche nach Updates… · — · // Autostart-Haken · — · Beenden. - // "connected" ist nicht anklickbar; "update" bleibt gesperrt, bis - // die Versionspruefung eine neuere Version findet, und heisst dann - // "Auf Version X.Y.Z aktualisieren" bzw. "Auf Beta-Stand ... - // aktualisieren"; "autostart" spiegelt den tatsaechlichen - // Systemzustand beim Start. + // "connected" ist nicht anklickbar; "update" startet gesperrt als + // "Suche nach Updates…", weil die Erstpruefung unten sofort + // anlaeuft, und heisst danach "Auf Version X.Y.Z aktualisieren" + // bzw. "Auf Beta-Stand ... aktualisieren", "Kein Update verfügbar + // – erneut prüfen" oder "Update-Prüfung fehlgeschlagen (...) – + // erneut prüfen" (alle anklickbar). Ohne gespeicherte Adresse + // "Kein Update verfügbar – erneut prüfen" (Klick tut nichts); + // "autostart" spiegelt den tatsaechlichen Systemzustand beim Start. let connected = MenuItemBuilder::with_id("connected", tray_labels(server_url.as_deref()).1) .enabled(false) @@ -580,8 +741,13 @@ pub fn run() { let open = MenuItemBuilder::with_id("open", "Öffnen").build(app)?; let change_server = MenuItemBuilder::with_id("change_server", "Server-Adresse ändern…").build(app)?; - let update = MenuItemBuilder::with_id("update", UPDATE_ITEM_DEFAULT) - .enabled(false) + let update_text = if server_url.is_some() { + UPDATE_ITEM_CHECKING + } else { + UPDATE_ITEM_NONE + }; + let update = MenuItemBuilder::with_id("update", update_text) + .enabled(server_url.is_none()) .build(app)?; let autostart_label = if cfg!(target_os = "windows") { "Mit Windows starten" @@ -610,6 +776,7 @@ pub fn run() { update: update.clone(), }); app.manage(PendingUpdate(Mutex::new(None))); + app.manage(LastCheckNotice(Mutex::new(String::new()))); let autostart_for_menu = autostart.clone(); @@ -637,8 +804,12 @@ pub fn run() { } "update" => { // `take()` verhindert Doppelklick-Downloads, der - // gesperrte Eintrag ebenso; ohne abgelegten Stand - // bleibt der Browser-Weg (Einstellungen → Desktop-App). + // gesperrte Eintrag ebenso. Ohne abgelegten Stand + // ("Kein Update verfügbar" / "Update-Prüfung + // fehlgeschlagen") prueft der Klick erneut -- der + // Browser-Weg (`open_download_page`) ist nur noch + // Rueckfall einer fehlgeschlagenen Installation. + // Ohne gespeicherte Adresse gibt es nichts zu pruefen. let pending = app .state::() .0 @@ -649,7 +820,11 @@ pub fn run() { Some((update, menu_text)) => { spawn_update_install(app.clone(), update, menu_text) } - None => open_download_page(app), + None => { + if let Some(url) = stored_server_url(app) { + spawn_version_check(app.clone(), url); + } + } } } "autostart" => { @@ -696,6 +871,34 @@ pub fn run() { spawn_version_check(app.handle().clone(), url); } + // Wiederholte Pruefung alle UPDATE_CHECK_INTERVAL. Bewusst ein + // schlichter Betriebssystem-Thread mit `sleep` statt + // `tokio::time`: das Tauri-Async-Runtime braeuchte dafuer das + // `time`-Feature bzw. ein eigenes Crate, und ein schlafender + // Thread kostet nichts. Je Durchlauf wird die Adresse frisch + // gelesen (Serverwechsel zaehlt). Haelt `PendingUpdate` bereits + // ein Update, wird der Durchlauf uebersprungen: die Pruefung + // wuerde den Stand leeren und "Neuer Beta-Stand" erneut melden. + // Waehrend einer Installation ist der Stand per `take()` leer; + // faellt die 4-h-Marke genau in den Download, wird nur der + // Fortschrittstext ueberschrieben (T-FRG-04, akzeptiert). + let handle = app.handle().clone(); + std::thread::spawn(move || loop { + std::thread::sleep(UPDATE_CHECK_INTERVAL); + let pending = handle + .state::() + .0 + .lock() + .map(|guard| guard.is_some()) + .unwrap_or(false); + if pending { + continue; + } + if let Some(url) = stored_server_url(&handle) { + spawn_version_check(handle.clone(), url); + } + }); + Ok(()) }) .on_window_event(|window, event| { @@ -1008,4 +1211,63 @@ mod tests { Err("Diese Adresse ist ungültig.".to_string()) ); } + + #[test] + fn check_failure_labels_401_nennt_proxy_passwortschutz() { + let (menu_text, body) = check_failure_labels(Some(401)); + assert!(menu_text.contains("HTTP 401")); + assert!(menu_text.ends_with("– erneut prüfen")); + assert!(body.contains("HTTP 401")); + assert!(body.contains("Passwortschutz")); + assert!(body.contains("Zugriffsliste")); + } + + #[test] + fn check_failure_labels_403_gleiche_erklaerung() { + let (menu_text, body) = check_failure_labels(Some(403)); + assert!(menu_text.contains("HTTP 403")); + assert!(body.contains("HTTP 403")); + assert!(body.contains("Passwortschutz")); + } + + #[test] + fn check_failure_labels_anderer_status_ohne_proxy_erklaerung() { + let (menu_text, body) = check_failure_labels(Some(502)); + assert!(menu_text.contains("HTTP 502")); + assert!(menu_text.ends_with("– erneut prüfen")); + assert!(!body.contains("Passwortschutz")); + assert!(body.contains("statt mit Paketdaten")); + } + + #[test] + fn check_failure_labels_ohne_status_keine_verbindung() { + let (menu_text, body) = check_failure_labels(None); + assert!(menu_text.contains("keine Verbindung")); + assert!(menu_text.ends_with("– erneut prüfen")); + assert!(body.contains("vier Stunden")); + } + + #[test] + fn diagnostic_update_url_ersetzt_platzhalter_und_kodiert_base() { + assert_eq!( + diagnostic_update_url("https://alpha.example", "windows", "x86_64", "1.2.0"), + "https://alpha.example/api-proxy/desktop/update?target=windows&arch=x86_64¤t=1.2.0&base=https%3A%2F%2Falpha.example" + ); + } + + #[test] + fn diagnostic_update_url_trimmt_schlussstrich() { + assert_eq!( + diagnostic_update_url("https://alpha.example/", "linux", "x86_64", "1.2.0"), + "https://alpha.example/api-proxy/desktop/update?target=linux&arch=x86_64¤t=1.2.0&base=https%3A%2F%2Falpha.example" + ); + } + + #[test] + fn update_item_konstanten() { + assert_eq!(UPDATE_ITEM_CHECKING, "Suche nach Updates…"); + assert_eq!(UPDATE_ITEM_NONE, "Kein Update verfügbar – erneut prüfen"); + assert_eq!(UPDATE_ITEM_INSECURE, "Update nur über https möglich"); + assert_eq!(UPDATE_CHECK_INTERVAL, Duration::from_secs(4 * 3600)); + } }