use semver::Version; use std::sync::Mutex; use std::time::Duration; use tauri::{ menu::{CheckMenuItemBuilder, MenuBuilder, MenuItem, MenuItemBuilder}, tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, 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; use tauri_plugin_updater::{Update, UpdaterExt}; /// 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 /// (`Error::InsecureTransportProtocol`). Der Eintrag bleibt gesperrt, der /// Text erklaert dauerhaft, warum -- keine Benachrichtigung bei jedem Start. const UPDATE_ITEM_INSECURE: &str = "Update nur über https möglich"; #[derive(serde::Deserialize)] struct VersionResponse { version: String, } /// Gefundenes Update samt dem Menuetext, der zu ihm gehoert. Wird von /// `spawn_version_check` befuellt und vom Tray-Klick "update" per `take()` /// entnommen -- so kann ein Doppelklick keinen zweiten Download starten. /// `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 /// 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 `desktop=1` sowie Version, Commit-Stempel und Betriebssystem an /// einen Klon der Adresse an und gibt den Klon zurueck -- das Original /// bleibt unveraendert. Reine Funktion (kein `env!`), darum in den Tests /// unten mit festen Werten geprueft; `with_desktop_marker` ist die Huelle /// mit den echten Werten der laufenden App. `commit` wird getrimmt, bleibt /// aber auch leer als Paar erhalten (`dc=`), damit die Middleware /// zuverlaessig erkennt, ob alle drei Parameter vorhanden sind. fn with_client_marker(url: &tauri::Url, version: &str, commit: &str, os: &str) -> tauri::Url { let mut marked = url.clone(); marked .query_pairs_mut() .append_pair("desktop", "1") .append_pair("dv", version) .append_pair("dc", commit.trim()) .append_pair("dos", os); marked } /// Huelle um `with_client_marker` mit den Werten der laufenden App. Seit /// quick-260918-gza wandern Version, Commit-Stempel und Betriebssystem /// (`dv`, `dc`, `dos`) mit -- die Next.js-Middleware /// (apps/web/src/middleware.ts) legt daraus das Cookie /// `tessera_desktop_client` an, aus dem der Fehler-melden-Knopf die /// Herkunft der Meldung fuellt (Betreff-Kuerzel `[Desktop/Windows]` bzw. /// `[Desktop/Linux]` statt `[Browser]`, weil WebView2 und WebKitGTK im /// User-Agent sonst wie ein gewoehnlicher Browser aussehen). `desktop=1` /// bleibt unveraendert an erster Stelle, damit ein neuer Client gegen eine /// 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 /// 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 { with_client_marker( url, env!("CARGO_PKG_VERSION"), env!("APP_COMMIT"), std::env::consts::OS, ) } /// 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 ("Auf Version 1.1.0 aktualisieren") verwirrend, weil der /// Client bereits genau diese Version hat. Der zweite Zweig nennt darum den /// Commit-Stempel statt der unveraenderten Version. Die Texte sprechen vom /// Aktualisieren, nicht vom Herunterladen: der Klick installiert das Update /// in der App (quick-260917-kgc). fn update_labels(version_changed: bool, version: &str, commit: &str) -> (String, String) { if version_changed { ( format!("Auf Version {} aktualisieren", version), format!( "Neue Version {} verfügbar – Aktualisieren über das Symbol im Infobereich.", version ), ) } else { ( format!("Auf Beta-Stand {} aktualisieren", commit), format!( "Neuer Beta-Stand {} verfügbar – Aktualisieren über das Symbol im Infobereich.", commit ), ) } } /// Liest den Beta-Stempel `beta.g` aus dem Prerelease-Teil einer /// Server-Version (`1.2.0-beta.gabc1234` → `abc1234`). Das `g` ist Pflicht: /// ein rein numerischer SHA mit fuehrender Null (`0123456`) waere kein /// gueltiger SemVer-Identifier, `check()` endete dann mit Err (Recherche /// Pitfall 2). Live-Versionen ohne Prerelease und fremde Formate (`rc.1`) /// liefern None. fn beta_commit(release: &Version) -> Option<&str> { release .pre .as_str() .strip_prefix("beta.g") .filter(|sha| !sha.is_empty()) } /// Vergleichsregel des Updaters -- ersetzt den Standardvergleich des Plugins /// vollstaendig (updater.rs Z. 576-579; SemVer allein saehe /// `1.2.0-beta.gXXXX < 1.2.0` und ein Beta-Client bekaeme nie einen neueren /// Beta-Bau). Regel: /// - Basisversion X.Y.Z des Servers groesser → Update; kleiner → keins. /// - Gleiche Basis mit Beta-Stempel `beta.g` → Update genau dann, /// wenn der Client einen Commit-Stempel kennt und er sich unterscheidet. /// Der Beta-Kanal (main) vergibt jedem Commit dieselbe X.Y.Z-Version /// (D-07); ohne den Commit-Vergleich saehe ein Beta-Client zwischen zwei /// Freigabe-Tags nie einen neueren Bau (WR-02, Code-Review Phase 18). /// - Gleiche Basis ohne Prerelease (Live-Freigabe) → KEIN Update. Das ist die /// bisherige Regel: ein Live-Client 1.2.0 bietet sich nie selbst 1.2.0 an /// (keine Update-Schleife). Der Fall "Beta-Client wird auf einen Live-Server /// derselben Basis umgestellt" ist client-seitig nicht entscheidbar (der /// Client kennt nur CARGO_PKG_VERSION und APP_COMMIT, nicht den Tag-Commit /// der Freigabe) und bekommt das Update mit der naechsten Freigabe. /// - Leerer Client-Commit (Quell-Tarball ohne git) → nur Basisvergleich. fn is_update_newer(current: &Version, current_commit: &str, release: &Version) -> bool { let base = |v: &Version| (v.major, v.minor, v.patch); if base(release) > base(current) { return true; } if base(release) < base(current) { return false; } match beta_commit(release) { Some(sha) => !current_commit.is_empty() && sha != current_commit, None => false, } } /// Baut die Endpunkt-Adresse fuer das Updater-Plugin aus der gespeicherten /// Server-Adresse. Die Platzhalter `{{target}}`, `{{arch}}` und /// `{{current_version}}` stehen als Rohtext im Wert; `query_pairs_mut` /// kodiert sie zu `%7B%7B…%7D%7D`, und das Plugin ersetzt beide Schreibweisen /// (updater.rs Z. 476-486). `base` ist der Origin, aus dem die API die /// absolute Download-Adresse bildet -- das Plugin verlangt eine `url::Url`, /// relative Pfade scheitern. `api_url` bleibt die einzige Stelle mit dem /// `/api-proxy`-Praefix. fn update_endpoint(server: &str) -> Option { let mut url = tauri::Url::parse(&api_url(server, "/desktop/update")).ok()?; url.query_pairs_mut() .append_pair("target", "{{target}}") .append_pair("arch", "{{arch}}") .append_pair("current", "{{current_version}}") .append_pair("base", server.trim_end_matches('/')); 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. fn release_labels(current: &Version, release: &Version) -> (String, String) { let base = |v: &Version| (v.major, v.minor, v.patch); let version_changed = base(release) != base(current); let version = format!("{}.{}.{}", release.major, release.minor, release.patch); let commit = beta_commit(release).unwrap_or(""); update_labels(version_changed, &version, commit) } /// Beschriftung fuer die Setup-Seite: Version und Commit-Stempel der /// installierten App, damit Anwender sehen, welchen Stand sie fahren /// (Beta-Baue teilen sich die X.Y.Z-Version, nur der Stempel unterscheidet /// sie). Ohne Stempel (lokaler Bau ohne Git) nur die Version. fn client_info_label(version: &str, commit: &str) -> String { let commit = commit.trim(); if commit.is_empty() { format!("Tessera-App {version}") } else { format!("Tessera-App {version} · Stand {commit}") } } /// 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); } /// Fragt den Update-Endpunkt ueber das Updater-Plugin ab. Der eigene /// `version_comparator` (`is_update_newer`) ersetzt den Standardvergleich /// des Plugins. Die 15 s gelten fuer die Pruefung; das Plugin uebernimmt /// denselben Wert als GESAMT-Timeout des Downloads (updater.rs Z. 698-700), /// darum setzt `spawn_version_check` vor der Ablage `update.timeout` neu. async fn check_for_update( app: &AppHandle, endpoint: tauri::Url, ) -> tauri_plugin_updater::Result> { let current_commit = env!("APP_COMMIT"); app.updater_builder() .endpoints(vec![endpoint])? .timeout(Duration::from_secs(15)) .version_comparator(move |current, release| { is_update_newer(¤t, current_commit, &release.version) }) .build()? .check() .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, 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_CHECKING); let _ = update_item.set_enabled(false); if let Ok(mut pending) = app.state::().0.lock() { *pending = None; } // Gespeicherte Adressen sind immer parsebar (parse_server_url) -- reine // Absicherung. let Some(endpoint) = update_endpoint(&server_url) else { return; }; // Cargo garantiert eine gueltige SemVer-Version; desktop-version.sh // schreibt immer reines X.Y.Z (D-07). let current = Version::parse(env!("CARGO_PKG_VERSION")) .expect("CARGO_PKG_VERSION muss eine gueltige SemVer-Version sein"); tauri::async_runtime::spawn(async move { match check_for_update(&app, endpoint).await { Ok(Some(mut update)) => { let (menu_text, body) = Version::parse(&update.version) .map(|release| release_labels(¤t, &release)) .unwrap_or_else(|_| update_labels(true, &update.version, "")); // 15 s reichen fuer die Pruefung, nicht fuer den ~100-MB-Download: // das Plugin wendet `Update.timeout` als Gesamt-Timeout des // Downloads an (updater.rs Z. 698-700). update.timeout = Some(Duration::from_secs(600)); let _ = app .notification() .builder() .title("Tessera-Update") .body(body) .show(); let _ = update_item.set_text(&menu_text); let _ = update_item.set_enabled(true); 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); } // Der Release-Bau verweigert http-Endpunkte (config.rs // `validate_endpoints`), der Debug-Bau warnt nur. Keine // `dangerousInsecureTransportProtocol`-Freigabe (T-KGC-06): der // Eintrag bleibt gesperrt und erklaert den Grund. Err(tauri_plugin_updater::Error::InsecureTransportProtocol) => { let _ = update_item.set_text(UPDATE_ITEM_INSECURE); } // 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, 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('/')); let _ = app.opener().open_url(target, None::<&str>); } } /// Laedt das abgelegte Update, prueft die Signatur, installiert es und /// startet die App neu. Fortschritt und Zustand stehen im Menuetext des /// gesperrten Eintrags. Fehler: Menuetext und Stand zurueck, Eintrag wieder /// aktiv, Benachrichtigung mit Grund, Einstellungsseite im Browser als /// Rueckfall. fn spawn_update_install(app: AppHandle, update: Update, menu_text: String) { let item = app.state::().update.clone(); let _ = item.set_enabled(false); let _ = item.set_text("Update wird heruntergeladen…"); let _ = app .notification() .builder() .title("Tessera-Update") .body("Update wird heruntergeladen…") .show(); tauri::async_runtime::spawn(async move { let item_progress = item.clone(); let item_finish = item.clone(); let mut received: u64 = 0; let mut last_percent: Option = None; let on_chunk = move |chunk: usize, total: Option| { received += chunk as u64; if let Some(total) = total.filter(|t| *t > 0) { let percent = (received * 100 / total).min(100); if last_percent != Some(percent) { last_percent = Some(percent); let _ = item_progress.set_text(format!("Lädt … {} %", percent)); } } }; let on_finish = move || { let _ = item_finish.set_text("Wird installiert…"); }; match update.download_and_install(on_chunk, on_finish).await { Ok(()) => { // Windows: nie erreicht -- das Plugin startet den NSIS-Installer // mit `/P /UPDATE /R /ARGS` und beendet den Prozess per // `std::process::exit(0)`; der Run-Handler wird dabei nicht // durchlaufen. Linux: die AppImage ist ersetzt; `restart()` aus // dem Nebenthread laeuft ueber RESTART_EXIT_CODE // (`code: Some(..)`) am `prevent_exit` des Run-Handlers vorbei // (tauri app.rs Z. 588-611). app.restart(); } Err(e) => { let _ = item.set_text(&menu_text); if let Ok(mut pending) = app.state::().0.lock() { *pending = Some((update, menu_text.clone())); } let _ = item.set_enabled(true); let _ = app .notification() .builder() .title("Tessera-Update") .body(format!( "Update fehlgeschlagen: {}. Die Download-Seite wird im Browser geöffnet.", e )) .show(); open_download_page(&app); } } }); } #[tauri::command] async fn check_server(url: String) -> Result { parse_server_url(&url)?; 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 = 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)); } 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(()) } /// Version und Commit-Stempel der laufenden App fuer die Setup-Seite. #[tauri::command] fn get_client_info() -> String { client_info_label(env!("CARGO_PKG_VERSION"), env!("APP_COMMIT")) } /// Hauptfenster nach vorne holen. Ein minimiertes Fenster (z. B. per Win+D) /// wird zuerst wiederhergestellt. fn show_main_window(app: &AppHandle) { if let Some(w) = app.get_webview_window("main") { let _ = w.unminimize(); let _ = w.show(); let _ = w.set_focus(); } } pub fn run() { let app = tauri::Builder::default() // Muss als erstes Plugin stehen. Verhindert den Doppelstart (z. B. beim // Windows-Login) mit zwei Tray-Symbolen: Ein zweiter Start holt nur das // Fenster der laufenden Instanz nach vorne und beendet sich sofort. .plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| { show_main_window(app); })) .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()) .plugin(tauri_plugin_updater::Builder::new().build()) .invoke_handler(tauri::generate_handler![ check_server, save_server_url, get_server_url, open_server, get_client_info ]) .setup(|app| { // Hauptfenster im Code statt in tauri.conf.json (`app.windows` ist // leer), weil nur der Builder einen Download-Handler annimmt. Ohne // Handler verpufft ein Klick auf "Herunterladen" (Einstellungen -> // Desktop-App) in der Webansicht: webkit2gtk verwirft den Download // still, WebView2 zeigte ebenfalls nichts (Nutzer, 22.09.2026, // Windows und Linux). Der Handler bricht den Download in der App ab // und reicht die Adresse an den System-Browser weiter -- dort gibt // es Fortschritt, Speicherort und ein Passwortfenster fuer einen // vorgeschalteten Proxy. Masse/Zentrierung wie bisher in der // Konfiguration; Fensterzustand stellt tauri_plugin_window_state // weiterhin her. tauri::WebviewWindowBuilder::new( app, "main", tauri::WebviewUrl::App("setup.html".into()), ) .title("Tessera") .inner_size(1280.0, 800.0) .center() .decorations(true) .resizable(true) .on_download(|webview, event| { if let tauri::webview::DownloadEvent::Requested { url, .. } = event { let _ = webview .app_handle() .opener() .open_url(url.to_string(), None::<&str>); return false; } true }) .build()?; let server_url = stored_server_url(app.handle()); 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: Verbunden mit ... (gesperrt) · — · Öffnen · // Server-Adresse ändern... · Suche nach Updates… · — · // Autostart-Haken · — · Beenden. // "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) .build(app)?; let open = MenuItemBuilder::with_id("open", "Öffnen").build(app)?; let change_server = MenuItemBuilder::with_id("change_server", "Server-Adresse ändern…").build(app)?; 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" } 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(&connected) .separator() .item(&open) .item(&change_server) .item(&update) .separator() .item(&autostart) .separator() .item(&quit) .build()?; app.manage(TrayItems { connected: connected.clone(), update: update.clone(), }); app.manage(PendingUpdate(Mutex::new(None))); app.manage(LastCheckNotice(Mutex::new(String::new()))); let autostart_for_menu = autostart.clone(); let _tray = TrayIconBuilder::with_id("main") .icon(app.default_window_icon().unwrap().clone()) .menu(&menu) .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" => show_main_window(app), "change_server" => { if let Some(w) = app.get_webview_window("main") { let _ = w.navigate(setup_page_url(cfg!(windows))); } show_main_window(app); } "update" => { // `take()` verhindert Doppelklick-Downloads, der // 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 .lock() .ok() .and_then(|mut guard| guard.take()); match pending { Some((update, menu_text)) => { spawn_update_install(app.clone(), update, menu_text) } None => { if let Some(url) = stored_server_url(app) { spawn_version_check(app.clone(), url); } } } } "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 { show_main_window(tray.app_handle()); } }) .build(app)?; apply_server(app.handle(), server_url.as_deref()); if let Some(url) = server_url { 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| { 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_client_marker_ohne_pfad_haengt_query_an() { let url = tauri::Url::parse("https://tessera.example.com").unwrap(); let marked = with_client_marker(&url, "1.2.0", "a6d1a64", "windows"); assert_eq!( marked.as_str(), "https://tessera.example.com/?desktop=1&dv=1.2.0&dc=a6d1a64&dos=windows" ); } #[test] fn with_client_marker_mit_vorhandenem_query_haengt_an() { let url = tauri::Url::parse("https://host/app?x=1").unwrap(); let marked = with_client_marker(&url, "1.2.0", "a6d1a64", "windows"); assert_eq!( marked.as_str(), "https://host/app?x=1&desktop=1&dv=1.2.0&dc=a6d1a64&dos=windows" ); } #[test] fn with_client_marker_laesst_original_unveraendert() { let url = tauri::Url::parse("https://host/app").unwrap(); let _ = with_client_marker(&url, "1.2.0", "a6d1a64", "windows"); assert_eq!(url.query(), None); } #[test] fn with_client_marker_leerer_commit_bleibt_als_leeres_paar_erhalten() { let url = tauri::Url::parse("https://host/app").unwrap(); let marked = with_client_marker(&url, "1.2.0", "", "linux"); assert_eq!( marked.as_str(), "https://host/app?desktop=1&dv=1.2.0&dc=&dos=linux" ); let marked_whitespace = with_client_marker(&url, "1.2.0", " ", "linux"); assert_eq!( marked_whitespace.as_str(), "https://host/app?desktop=1&dv=1.2.0&dc=&dos=linux" ); } #[test] fn with_desktop_marker_haengt_alle_vier_paare_an() { let url = tauri::Url::parse("https://host/app").unwrap(); let marked = with_desktop_marker(&url); let pairs: Vec<(String, String)> = marked .query_pairs() .map(|(k, v)| (k.to_string(), v.to_string())) .collect(); assert!(pairs.contains(&("desktop".to_string(), "1".to_string()))); assert!(pairs.contains(&("dv".to_string(), env!("CARGO_PKG_VERSION").to_string()))); assert!(pairs.contains(&("dos".to_string(), std::env::consts::OS.to_string()))); assert!(pairs.iter().any(|(k, _)| k == "dc")); } #[test] fn update_labels_bei_versionswechsel() { let (menu_text, body) = update_labels(true, "1.2.0", "abc1234"); assert_eq!(menu_text, "Auf Version 1.2.0 aktualisieren"); assert_eq!( body, "Neue Version 1.2.0 verfügbar – Aktualisieren über das Symbol im Infobereich." ); } #[test] fn client_info_label_mit_stempel() { assert_eq!( client_info_label("1.2.0", "7479cb4"), "Tessera-App 1.2.0 · Stand 7479cb4" ); } #[test] fn client_info_label_ohne_stempel() { assert_eq!(client_info_label("1.2.0", ""), "Tessera-App 1.2.0"); assert_eq!(client_info_label("1.2.0", " "), "Tessera-App 1.2.0"); } #[test] fn update_labels_bei_gleicher_version_beta_commit() { let (menu_text, body) = update_labels(false, "1.1.0", "abc1234"); assert_eq!(menu_text, "Auf Beta-Stand abc1234 aktualisieren"); assert_eq!( body, "Neuer Beta-Stand abc1234 verfügbar – Aktualisieren über das Symbol im Infobereich." ); } fn v(s: &str) -> Version { Version::parse(s).unwrap() } #[test] fn is_update_newer_hoehere_basis_live() { assert!(is_update_newer(&v("1.2.0"), "abc1234", &v("1.3.0"))); } #[test] fn is_update_newer_hoehere_basis_beta() { assert!(is_update_newer( &v("1.2.0"), "abc1234", &v("1.3.0-beta.gdef5678") )); } #[test] fn is_update_newer_niedrigere_basis() { assert!(!is_update_newer( &v("1.3.0"), "abc1234", &v("1.2.0-beta.g9999999") )); } #[test] fn is_update_newer_gleiche_basis_anderer_beta_stempel() { assert!(is_update_newer( &v("1.2.0"), "abc1234", &v("1.2.0-beta.gdef5678") )); } #[test] fn is_update_newer_gleiche_basis_gleicher_beta_stempel() { assert!(!is_update_newer( &v("1.2.0"), "abc1234", &v("1.2.0-beta.gabc1234") )); } #[test] fn is_update_newer_gleiche_basis_live_ohne_prerelease() { assert!(!is_update_newer(&v("1.2.0"), "abc1234", &v("1.2.0"))); } #[test] fn is_update_newer_leerer_client_commit_nur_basisvergleich() { assert!(!is_update_newer(&v("1.2.0"), "", &v("1.2.0-beta.gdef5678"))); } #[test] fn is_update_newer_fremdes_prerelease_format() { assert!(!is_update_newer(&v("1.2.0"), "abc1234", &v("1.2.0-rc.1"))); } #[test] fn beta_commit_mit_praefix_g_erlaubt_fuehrende_null() { assert!(Version::parse("1.2.0-beta.g0123456").is_ok()); assert_eq!(beta_commit(&v("1.2.0-beta.g0123456")), Some("0123456")); } #[test] fn beta_commit_none_ohne_prerelease() { assert_eq!(beta_commit(&v("1.2.0")), None); } #[test] fn beta_commit_none_bei_fremdem_prerelease() { assert_eq!(beta_commit(&v("1.2.0-rc.1")), None); } #[test] fn update_endpoint_traegt_platzhalter_und_base() { assert_eq!( update_endpoint("https://tessera.ctl.de/").unwrap().as_str(), "https://tessera.ctl.de/api-proxy/desktop/update?target=%7B%7Btarget%7D%7D&arch=%7B%7Barch%7D%7D¤t=%7B%7Bcurrent_version%7D%7D&base=https%3A%2F%2Ftessera.ctl.de" ); } #[test] fn update_endpoint_none_bei_unparsbarer_adresse() { assert!(update_endpoint("kein url").is_none()); } #[test] fn release_labels_neue_basis_nennt_version_auch_bei_beta() { let (menu_text, _) = release_labels(&v("1.2.0"), &v("1.3.0-beta.gdef5678")); assert_eq!(menu_text, "Auf Version 1.3.0 aktualisieren"); } #[test] fn release_labels_gleiche_basis_nennt_beta_stempel() { let (menu_text, _) = release_labels(&v("1.2.0"), &v("1.2.0-beta.gdef5678")); assert_eq!(menu_text, "Auf Beta-Stand def5678 aktualisieren"); } #[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()) ); } #[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)); } }