use semver::Version; use std::sync::atomic::{AtomicBool, Ordering}; 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>); /// Ablauf-Merker des Updaters neben `PendingUpdate`. /// /// `installing`: von `spawn_update_install` bis zum Fehlerfall gesetzt (im /// Erfolgsfall startet die App neu bzw. beendet sich, der Merker bleibt). /// Solange er gesetzt ist, tun Tray-Klick, `spawn_version_check` und die /// 4-h-Schleife nichts -- vorher konnte eine Pruefung waehrend des Downloads /// ein neues Update ablegen und den Eintrag wieder aktiv schalten, ein /// zweiter Klick startete dann einen parallelen Download samt Installer. /// /// `offered`: eine Pruefung hat ein Update angeboten ("Auf Version … /// aktualisieren"). Der Klick darauf prueft frisch (`install_after`); schlug /// diese Pruefung fehl, war der abgelegte Stand weg und der naechste Klick /// bot nur wieder an -- zwei weitere Klicks bis zur Installation. Der Merker /// ueberlebt die fehlgeschlagene Pruefung, der naechste erfolgreiche Klick /// installiert direkt. Geleert, wenn eine Pruefung "kein Update" ergibt oder /// der Server wechselt. #[derive(Default)] struct UpdateFlow { installing: AtomicBool, offered: AtomicBool, } impl UpdateFlow { /// Beansprucht die Installation. `false`, wenn bereits eine laeuft -- /// dann darf der Aufrufer nichts starten. fn try_begin_install(&self) -> bool { self.installing .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) .is_ok() } /// Gibt die Installation nach einem Fehler wieder frei. fn install_failed(&self) { self.installing.store(false, Ordering::SeqCst); } fn is_installing(&self) -> bool { self.installing.load(Ordering::SeqCst) } fn set_offered(&self, offered: bool) { self.offered.store(offered, Ordering::SeqCst); } /// Soll der Tray-Klick nach der frischen Pruefung installieren? Ja, wenn /// ein Stand abgelegt war ODER vorher schon ein Update angeboten wurde /// (Klick auf "Update-Prüfung fehlgeschlagen … – erneut prüfen" nach /// einem Angebot). fn install_on_click(&self, had_pending: bool) -> bool { had_pending || self.offered.load(Ordering::SeqCst) } } /// 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()), } } /// Die drei Benachrichtigungs-Berechtigungen, die die Server-Seite in der /// Webview bekommt -- genau diese und keine weitere (T-IF2-03). Die /// Bezeichner stammen aus tauri-plugin-notification 2.3.3 /// (`permissions/autogenerated/commands/notify.toml`, /// `is_permission_granted.toml`, `request_permission.toml`). Eine unbekannte /// Kennung wuerde in `add_capability` ebenfalls in einen Panic laufen; darum /// pinnt ein Test die exakte Menge samt Reihenfolge. const SERVER_NOTIFICATION_PERMISSIONS: [&str; 3] = [ "notification:allow-notify", "notification:allow-is-permission-granted", "notification:allow-request-permission", ]; /// Bereits berechtigte Herkunfts-Muster. `add_capability` haengt bei jedem /// Aufruf an die bestehenden Erlaubnisse an; ein zweites Speichern derselben /// Adresse soll nichts verdoppeln. static GRANTED_SERVER_ORIGINS: Mutex> = Mutex::new(Vec::new()); /// Baut aus der gespeicherten Server-Adresse das URL-Muster fuer die /// Laufzeit-Berechtigung: genau `Schema://Host[:Port]`, ohne Pfad, Query und /// Fragment (quick-260929-if2, E-01). /// /// Jedes Zeichen des Hosts ausserhalb von ASCII `A-Z a-z 0-9 . -` bekommt ein /// Backslash (URLPattern-Maskierung). Grund: der `url`-Crate nimmt /// `http://*.example.com/` an und liefert den Host `*.example.com`, der /// ungeschuetzt zum Platzhalter fuer JEDE Subdomain wuerde. IPv6-Adressen /// kommen in Klammern (`[::1]`); der URLPattern-Zerleger (urlpattern 0.3.0) /// weist `http://[::1]:8080` mit `Tokenizer(InvalidName, 1)` ab, die /// maskierte Form `http://\[\:\:1\]:8080` dagegen parst und trifft nur /// `[::1]:8080` (beides gemessen). /// /// Der Port steht nur, wenn er ausdruecklich und nicht der Standardport ist /// (der `url`-Crate laesst Standardports weg). /// /// SELBSTPRUEFUNG, Pflicht: `add_capability` fuehrt intern /// `Resolved::resolve(..).unwrap()` aus, waehrend es die Sperre der /// Laufzeit-Berechtigungen haelt, und tauri-utils 2.9.3 gerät bei einem /// nicht zerlegbaren Muster in einen Panic ("invalid URL pattern for remote /// URL"). Ein Fehler kommt NICHT als `Err` zurueck, er legt `setup()` lahm -- /// und ein `catch_unwind` haette die Sperre vergiftet, sodass jeder spaetere /// IPC-Aufruf scheitert. Deshalb wird das Muster VOR dem Aufruf mit /// demselben Zerleger geprueft, den Tauri fuer `remote.urls` benutzt /// (`RemoteUrlPattern`), und muss die gespeicherte Adresse tatsaechlich /// treffen. Sonst `None`: kein Muster, keine Berechtigung. fn server_origin_pattern(url: &str) -> Option { use std::str::FromStr; let parsed = parse_server_url(url).ok()?; let host = parsed.host_str()?; let escaped: String = host .chars() .flat_map(|c| { if c.is_ascii_alphanumeric() || c == '.' || c == '-' { vec![c] } else { vec!['\\', c] } }) .collect(); let mut pattern = format!("{}://{}", parsed.scheme(), escaped); if let Some(port) = parsed.port() { pattern.push_str(&format!(":{}", port)); } let checked = tauri::utils::acl::RemoteUrlPattern::from_str(&pattern).ok()?; if checked.test(&parsed) { Some(pattern) } else { None } } /// Erteilt der gespeicherten Server-Adresse -- und nur ihr -- zur Laufzeit das /// Recht, das Benachrichtigungs-Plugin aus der Webview aufzurufen /// (quick-260929-if2, E-01). Der globale Erinnerungs-Melder der Web-Oberflaeche /// ruft `plugin:notification|notify`, damit auch bei ausgeblendetem Fenster /// (Infobereich) eine Windows-Meldung erscheint. /// /// WARUM ZUR LAUFZEIT UND NICHT IN `capabilities/default.json`: die statische /// Datei hat absichtlich keinen `remote`-Block (T-JN2-01, siehe /// `get_server_url`), Tauri verweigert deshalb jeden Plugin- und App-Aufruf /// aus der Server-Seite. Die Serveradresse ist erst zur Laufzeit bekannt und /// je Installation verschieden. Die Berechtigung gilt genau fuer den /// gespeicherten Ursprung (maskiert und selbstgeprueft, siehe /// `server_origin_pattern` -- nie ein Platzhalter), nur fuer das Fenster /// `main` und nur fuer die drei Benachrichtigungs-Rechte. App-Befehle wie /// `save_server_url` bleiben lokal-only, T-JN2-01 gilt unveraendert. Nach /// einem Serverwechsel behaelt der alte Ursprung sein Benachrichtigungsrecht /// bis zum App-Neustart (akzeptiert, T-IF2-03). /// /// Verworfene Alternativen: (1) ein Abruf der API von Rust aus -- der /// vorgeschaltete Basic-Auth-Schutz von alpha liefert reqwest ein 401 (wie /// beim Updater), und das Sitzungs-Cookie lebt nur in der Webview. /// (2) Die Web-Benachrichtigung in der Webview -- das Plugin ersetzt /// `window.Notification` durch einen Ersatz mit demselben IPC-Aufruf, der /// unter Windows bei jedem Seitenaufruf "denied" meldet. /// /// Darf den Start nie verhindern: bei einem Muster, das sich nicht bilden /// laesst, bleibt es bei einer Zeile auf stderr -- die Desktop-Meldung ist /// dann fuer diese Adresse aus, Browser-Meldung und E-Mail laufen weiter. fn grant_server_notifications(app: &AppHandle, url: &str) { let Some(pattern) = server_origin_pattern(url) else { eprintln!( "Benachrichtigungen: keine Berechtigung fuer die Server-Adresse erteilt (Muster nicht bildbar)" ); return; }; let result = grant_origin_once(&GRANTED_SERVER_ORIGINS, &pattern, |pattern| { let mut capability = tauri::ipc::CapabilityBuilder::new("server-notifications") .remote(pattern.to_string()) .local(false) .window("main"); for permission in SERVER_NOTIFICATION_PERMISSIONS { capability = capability.permission(permission); } app.add_capability(capability) }); if let Err(e) = result { eprintln!("Benachrichtigungen: Berechtigung nicht erteilt: {}", e); } } /// Fuehrt `grant` fuer `pattern` hoechstens einmal erfolgreich aus. Die Sperre /// von `granted` bleibt ueber den Aufruf gehalten, damit zwei gleichzeitige /// Aufrufe nicht doppelt berechtigen; vermerkt wird der Ursprung erst NACH /// dem Erfolg. Frueher stand er schon vorher in der Liste -- schlug /// `add_capability` fehl, gab es bis zum Neustart keinen neuen Versuch. /// Bereits vermerkt: `Ok(())` ohne Aufruf. fn grant_origin_once( granted: &Mutex>, pattern: &str, grant: impl FnOnce(&str) -> Result<(), E>, ) -> Result<(), E> { let mut granted = match granted.lock() { Ok(guard) => guard, Err(poisoned) => poisoned.into_inner(), }; if granted.iter().any(|p| p == pattern) { return Ok(()); } grant(pattern)?; granted.push(pattern.to_string()); Ok(()) } /// 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). /// /// `install_after`: der Klick auf "Auf Version … aktualisieren" installiert /// NICHT den abgelegten Stand, sondern prueft zuerst frisch und installiert /// genau, was diese Pruefung liefert. Grund (Nutzer, 29.09.2026: "Signatur- /// verifikation failed", danach Browser): die Download-Adresse /// `/desktop/download/` liefert immer den AKTUELLEN Installer; ein /// Stunden alter Stand trug noch die Signatur der Vorversion, nach dem /// Server-Update passten Datei und Signatur nicht mehr zusammen. /// /// Waehrend einer Installation (`UpdateFlow::installing`) tut die Funktion /// nichts: kein Menuetext, kein Leeren, keine Ablage. fn spawn_version_check(app: AppHandle, server_url: String, install_after: bool) { if app.state::().is_installing() { return; } 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 { let result = check_for_update(&app, endpoint).await; // Eine parallel gestartete Pruefung (Klick waehrend der 4-h-Schleife) // kann inzwischen eine Installation ausgeloest haben: dann weder // Menuetext noch Ablage anfassen. if app.state::().is_installing() { return; } match result { 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)); clear_check_notice(&app); if install_after { spawn_update_install(app.clone(), update, menu_text); return; } 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)); } app.state::().set_offered(true); } Ok(None) => { let _ = update_item.set_text(UPDATE_ITEM_NONE); let _ = update_item.set_enabled(true); app.state::().set_offered(false); 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. /// /// Beansprucht `UpdateFlow::installing`; laeuft bereits eine Installation, /// passiert nichts. Freigegeben wird der Merker nur im Fehlerfall. fn spawn_update_install(app: AppHandle, update: Update, menu_text: String) { if !app.state::().try_begin_install() { return; } 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())); } app.state::().install_failed(); 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())?; // Vor dem Navigieren: die Seite darf danach sofort Desktop-Meldungen ausloesen. grant_server_notifications(&app, &normalized); apply_server(&app, Some(&normalized)); // Ein Angebot des alten Servers gilt nicht fuer den neuen. app.state::().set_offered(false); spawn_version_check(app.clone(), normalized.clone(), false); 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")) } /// Ziel fuer den System-Browser, wenn die Seite ein neues Fenster verlangt /// (`window.open`, Links mit `target="_blank"`). Nur http/https -- andere /// Schemata (file:, javascript:, eigene Protokolle) werden verworfen. fn external_target(url: &tauri::Url) -> Option { match url.scheme() { "http" | "https" => Some(url.to_string()), _ => None, } } /// Downloads, die die App selbst speichert statt sie an den System-Browser /// zu geben: Inhalte, die die Seite im Speicher erzeugt hat (`blob:`, /// `data:`) -- der Browser kann sie nicht abrufen. fn saves_in_app(url: &tauri::Url) -> bool { matches!(url.scheme(), "blob" | "data") } /// Titel und Text der Meldung nach einem in der App gespeicherten Download. fn download_notice(path: Option<&std::path::Path>, success: bool) -> (String, String) { let name = path .and_then(|p| p.file_name()) .map(|n| n.to_string_lossy().into_owned()); let folder = path .and_then(|p| p.parent()) .and_then(|p| p.file_name()) .map(|n| n.to_string_lossy().into_owned()); match (success, name) { (true, Some(name)) => ( "Download gespeichert".to_string(), match folder { Some(folder) => format!("„{name}“ liegt im Ordner {folder}."), None => format!("„{name}“ wurde gespeichert."), }, ), (true, None) => ( "Download gespeichert".to_string(), "Die Datei wurde gespeichert.".to_string(), ), (false, _) => ( "Download fehlgeschlagen".to_string(), "Die Datei konnte nicht gespeichert werden.".to_string(), ), } } /// 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) // Neue Fenster (Suche-Widget per `window.open`, „In neuem Tab // öffnen“ bei XFrame/eigenen Modulen/Favoriten) verwarf die // Webansicht still -- Nutzer, 29.09.2026: Suche im Client ohne // Funktion. Jetzt gehen sie wie im Browser-Tab nach aussen, in den // System-Browser; die App selbst oeffnet kein zweites Fenster. .on_new_window({ let handle = app.handle().clone(); move |url, _features| { if let Some(target) = external_target(&url) { let _ = handle.opener().open_url(target, None::<&str>); } tauri::webview::NewWindowResponse::Deny } }) .on_download(|webview, event| match event { // Dateien, die die Seite selbst erzeugt (blob:/data:, z. B. // Zertifikats-Downloads), kennt der System-Browser nicht -- // Windows meldete „Holen Sie sich eine App, um diesen // ‚blob‘-Link zu öffnen“ (VM 8233, 01.10.2026). Die speichert // die App selbst im Ordner Downloads und meldet es danach. tauri::webview::DownloadEvent::Requested { url, .. } => { if saves_in_app(&url) { return true; } let _ = webview .app_handle() .opener() .open_url(url.to_string(), None::<&str>); false } tauri::webview::DownloadEvent::Finished { url, path, success } => { if saves_in_app(&url) { let (title, body) = download_notice(path.as_deref(), success); let _ = webview .app_handle() .notification() .builder() .title(title) .body(body) .show(); } true } _ => true, }) .build()?; let server_url = stored_server_url(app.handle()); if let Some(url) = &server_url { // Vor dem ersten `navigate`: Benachrichtigungsrecht fuer die // gespeicherte Adresse (quick-260929-if2, E-01). grant_server_notifications(app.handle(), 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(UpdateFlow::default()); 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. Liegt ein Update vor, // wird es NICHT aus dem Speicher installiert, sondern // frisch geprueft und das Ergebnis installiert // (`install_after`, siehe spawn_version_check). Ohne // abgelegten Stand ("Kein Update verfügbar" / // "Update-Prüfung fehlgeschlagen") prueft der Klick // nur erneut. Der Browser-Weg (`open_download_page`) // bleibt Rueckfall einer fehlgeschlagenen Installation. // Ohne gespeicherte Adresse gibt es nichts zu pruefen. // Waehrend einer Installation tut der Klick nichts // (`UpdateFlow`). War vorher schon ein Update // angeboten (Stand nach fehlgeschlagener Pruefung // weg), installiert der Klick trotzdem direkt. let flow = app.state::(); if flow.is_installing() { return; } let had_pending = app .state::() .0 .lock() .ok() .and_then(|mut guard| guard.take()) .is_some(); if let Some(url) = stored_server_url(app) { let install_after = flow.install_on_click(had_pending); spawn_version_check(app.clone(), url, install_after); } } "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, false); } // 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; // darum prueft die Schleife zusaetzlich `UpdateFlow::installing` // und ueberspringt den Durchlauf (vorher: Pruefung mitten im // Download legte ein neues Update ab und schaltete den Eintrag // wieder aktiv -- zweiter paralleler Download moeglich). let handle = app.handle().clone(); std::thread::spawn(move || loop { std::thread::sleep(UPDATE_CHECK_INTERVAL); if handle.state::().is_installing() { continue; } 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, false); } }); 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 saves_in_app_nur_blob_und_data() { for (url, expected) in [ ("blob:http://localhost:3000/0c1d-11", true), ("data:application/x-pem-file;base64,QUJD", true), ("https://alpha.tessera.ctl.de/desktop/download/x.exe", false), ("http://intranet.local/datei.pdf", false), ] { let parsed = tauri::Url::parse(url).unwrap(); assert_eq!(saves_in_app(&parsed), expected, "{url}"); } } #[test] fn download_notice_nennt_datei_und_ordner() { let path = std::path::Path::new("/home/tessera/Downloads/www.example.de.crt"); let (title, body) = download_notice(Some(path), true); assert_eq!(title, "Download gespeichert"); assert_eq!(body, "„www.example.de.crt“ liegt im Ordner Downloads."); let (title, _) = download_notice(None, false); assert_eq!(title, "Download fehlgeschlagen"); let (_, body) = download_notice(None, true); assert_eq!(body, "Die Datei wurde gespeichert."); } #[test] fn external_target_nur_http_und_https() { let https = tauri::Url::parse("https://www.google.com/search?q=tessera").unwrap(); assert_eq!( external_target(&https).as_deref(), Some("https://www.google.com/search?q=tessera") ); let http = tauri::Url::parse("http://intranet.local/").unwrap(); assert_eq!( external_target(&http).as_deref(), Some("http://intranet.local/") ); for bad in [ "file:///C:/Windows/system32/calc.exe", "javascript:alert(1)", "ms-settings:privacy", ] { let url = tauri::Url::parse(bad).unwrap(); assert_eq!(external_target(&url), None, "{bad}"); } } #[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)); } // --- UpdateFlow --- #[test] fn update_flow_nur_eine_installation_gleichzeitig() { let flow = UpdateFlow::default(); assert!(!flow.is_installing()); assert!(flow.try_begin_install()); assert!(flow.is_installing()); assert!( !flow.try_begin_install(), "zweite Installation muss abgewiesen werden" ); } #[test] fn update_flow_fehler_gibt_installation_frei() { let flow = UpdateFlow::default(); assert!(flow.try_begin_install()); flow.install_failed(); assert!(!flow.is_installing()); assert!(flow.try_begin_install()); } #[test] fn update_flow_klick_installiert_bei_abgelegtem_stand() { let flow = UpdateFlow::default(); assert!(flow.install_on_click(true)); assert!(!flow.install_on_click(false)); } #[test] fn update_flow_klick_nach_angebot_und_fehlgeschlagener_pruefung_installiert() { let flow = UpdateFlow::default(); flow.set_offered(true); // Stand wurde vom ersten Klick entnommen, frische Pruefung schlug fehl. assert!(flow.install_on_click(false)); // "Kein Update" oder Serverwechsel leert das Angebot. flow.set_offered(false); assert!(!flow.install_on_click(false)); } // --- grant_origin_once --- #[test] fn grant_origin_once_vermerkt_erst_nach_erfolg() { let granted = Mutex::new(Vec::new()); let r: Result<(), &str> = grant_origin_once(&granted, "https://a", |_| Err("kaputt")); assert_eq!(r, Err("kaputt")); assert!(granted.lock().unwrap().is_empty()); let mut calls = 0; let r: Result<(), &str> = grant_origin_once(&granted, "https://a", |p| { calls += 1; assert_eq!(p, "https://a"); Ok(()) }); assert_eq!(r, Ok(())); assert_eq!(calls, 1, "nach einem Fehler muss ein neuer Versuch laufen"); assert_eq!(*granted.lock().unwrap(), vec!["https://a".to_string()]); } #[test] fn grant_origin_once_ruft_bei_vermerktem_ursprung_nicht_erneut() { let granted = Mutex::new(vec!["https://a".to_string()]); let r: Result<(), &str> = grant_origin_once(&granted, "https://a", |_| panic!("darf nicht laufen")); assert_eq!(r, Ok(())); let r: Result<(), &str> = grant_origin_once(&granted, "https://b", |_| Ok(())); assert_eq!(r, Ok(())); assert_eq!(granted.lock().unwrap().len(), 2); } // --- server_origin_* (quick-260929-if2, E-01) --- fn muster(p: &str) -> tauri::utils::acl::RemoteUrlPattern { p.parse().expect("Muster muss zerlegbar sein") } fn adresse(s: &str) -> tauri::Url { tauri::Url::parse(s).expect("Testadresse muss gueltig sein") } #[test] fn server_origin_entfernt_pfad_query_und_fragment() { assert_eq!( server_origin_pattern("https://alpha.tessera.ctl.de/dashboard?x=1#h").as_deref(), Some("https://alpha.tessera.ctl.de") ); } #[test] fn server_origin_behaelt_nicht_standard_port() { assert_eq!( server_origin_pattern("http://192.168.13.12:8080/").as_deref(), Some("http://192.168.13.12:8080") ); } #[test] fn server_origin_laesst_standardport_weg() { assert_eq!( server_origin_pattern("https://alpha.tessera.ctl.de:443/").as_deref(), Some("https://alpha.tessera.ctl.de") ); } #[test] fn server_origin_mit_und_ohne_schlussstrich_gleich() { assert_eq!( server_origin_pattern("https://tessera.example.de/"), server_origin_pattern("https://tessera.example.de") ); assert!(server_origin_pattern("https://tessera.example.de").is_some()); } #[test] fn server_origin_none_bei_fremdem_schema_und_unparsbarer_eingabe() { assert_eq!(server_origin_pattern("ftp://tessera.example.de/"), None); assert_eq!(server_origin_pattern("das ist keine adresse"), None); assert_eq!(server_origin_pattern(""), None); } #[test] fn server_origin_ipv6_wird_maskiert() { assert_eq!( server_origin_pattern("http://[::1]:8080/").as_deref(), Some(r"http://\[\:\:1\]:8080") ); } #[test] fn server_origin_platzhalter_host_wird_maskiert() { assert_eq!( server_origin_pattern("http://*.example.com/").as_deref(), Some(r"http://\*.example.com") ); } #[test] fn server_origin_trifft_eigenen_ursprung_mit_anderem_pfad_und_query() { let p = muster(&server_origin_pattern("https://alpha.tessera.ctl.de/x").unwrap()); assert!(p.test(&adresse("https://alpha.tessera.ctl.de/"))); assert!(p.test(&adresse("https://alpha.tessera.ctl.de/dashboard?a=b#c"))); } #[test] fn server_origin_trifft_kein_anderes_schema_port_subdomain_oder_host() { let p = muster(&server_origin_pattern("https://alpha.tessera.ctl.de/").unwrap()); assert!(!p.test(&adresse("http://alpha.tessera.ctl.de/"))); assert!(!p.test(&adresse("https://alpha.tessera.ctl.de:8443/"))); assert!(!p.test(&adresse("https://x.alpha.tessera.ctl.de/"))); assert!(!p.test(&adresse("https://beta.tessera.ctl.de/"))); let mit_port = muster(&server_origin_pattern("http://192.168.13.12:8080/").unwrap()); assert!(mit_port.test(&adresse("http://192.168.13.12:8080/a"))); assert!(!mit_port.test(&adresse("http://192.168.13.12:8081/"))); assert!(!mit_port.test(&adresse("http://192.168.13.12/"))); } #[test] fn server_origin_platzhalter_host_trifft_keine_subdomain() { let p = muster(&server_origin_pattern("http://*.example.com/").unwrap()); assert!(!p.test(&adresse("http://a.example.com/"))); } #[test] fn server_origin_ipv6_trifft_nur_die_eigene_adresse() { let p = muster(&server_origin_pattern("http://[::1]:8080/").unwrap()); assert!(p.test(&adresse("http://[::1]:8080/dashboard"))); assert!(!p.test(&adresse("http://[::2]:8080/"))); assert!(!p.test(&adresse("http://[::1]/"))); } #[test] fn server_origin_berechtigungen_sind_genau_diese_drei() { // Pinnt die Menge (T-IF2-03): ein zusaetzliches `notification:default` // oder eine Platzhalter-Berechtigung laesst den Test rot werden. assert_eq!( SERVER_NOTIFICATION_PERMISSIONS, [ "notification:allow-notify", "notification:allow-is-permission-granted", "notification:allow-request-permission", ] ); } }