Files
tessera-ctl/apps/desktop/src-tauri/src/lib.rs
T
schalli c0b145a9b0 fix(desktop): nur eine Instanz — zweiter Start holt das Fenster nach vorne
- tauri-plugin-single-instance als erstes Plugin registriert
- show_main_window-Helper ersetzt die dreifach duplizierte Fenster-Sequenz

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 09:26:44 +02:00

1310 lines
52 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<Option<(Update, String)>>);
/// 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<String>);
/// Baut die Adresse eines API-Pfads aus der gespeicherten Server-Adresse.
/// Die API ist vom Client nur ueber den Web-Ursprung erreichbar
/// (Next.js-Rewrite `/api-proxy/*`, siehe 18-01) -- niemals direkt unter dem
/// Web-Hostnamen. Dies ist die einzige Stelle, an der der Rewrite-Praefix
/// steht.
fn api_url(server: &str, path: &str) -> String {
format!("{}/api-proxy{}", server.trim_end_matches('/'), path)
}
/// Haengt `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<sha7>` 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<sha7>` → 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<tauri::Url> {
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<u16>) -> (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<u16> {
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<tauri::Url, String> {
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<String> {
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<String> {
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<Wry>,
update: MenuItem<Wry>,
}
/// 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::<TrayItems>().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<Option<Update>> {
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(&current, 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<Wry>, status: Option<u16>) {
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::<LastCheckNotice>()
.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::<LastCheckNotice>().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::<TrayItems>().update.clone();
let _ = update_item.set_text(UPDATE_ITEM_CHECKING);
let _ = update_item.set_enabled(false);
if let Ok(mut pending) = app.state::<PendingUpdate>().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(&current, &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::<PendingUpdate>().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::<TrayItems>().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<u64> = None;
let on_chunk = move |chunk: usize, total: Option<u64>| {
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::<PendingUpdate>().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<String, String> {
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::<VersionResponse>()
.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<String> {
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::<PendingUpdate>()
.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::<PendingUpdate>()
.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&current=%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&current=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&current=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));
}
}