c0b145a9b0
- 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>
1310 lines
52 KiB
Rust
1310 lines
52 KiB
Rust
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(¤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<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(¤t, &release))
|
||
.unwrap_or_else(|_| update_labels(true, &update.version, ""));
|
||
// 15 s reichen fuer die Pruefung, nicht fuer den ~100-MB-Download:
|
||
// das Plugin wendet `Update.timeout` als Gesamt-Timeout des
|
||
// Downloads an (updater.rs Z. 698-700).
|
||
update.timeout = Some(Duration::from_secs(600));
|
||
let _ = app
|
||
.notification()
|
||
.builder()
|
||
.title("Tessera-Update")
|
||
.body(body)
|
||
.show();
|
||
let _ = update_item.set_text(&menu_text);
|
||
let _ = update_item.set_enabled(true);
|
||
if let Ok(mut pending) = app.state::<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¤t=%7B%7Bcurrent_version%7D%7D&base=https%3A%2F%2Ftessera.ctl.de"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn update_endpoint_none_bei_unparsbarer_adresse() {
|
||
assert!(update_endpoint("kein url").is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn release_labels_neue_basis_nennt_version_auch_bei_beta() {
|
||
let (menu_text, _) = release_labels(&v("1.2.0"), &v("1.3.0-beta.gdef5678"));
|
||
assert_eq!(menu_text, "Auf Version 1.3.0 aktualisieren");
|
||
}
|
||
|
||
#[test]
|
||
fn release_labels_gleiche_basis_nennt_beta_stempel() {
|
||
let (menu_text, _) = release_labels(&v("1.2.0"), &v("1.2.0-beta.gdef5678"));
|
||
assert_eq!(menu_text, "Auf Beta-Stand def5678 aktualisieren");
|
||
}
|
||
|
||
#[test]
|
||
fn server_host_mit_pfad_und_ohne_port() {
|
||
assert_eq!(
|
||
server_host(Some("https://tessera.ctl.de/")),
|
||
Some("tessera.ctl.de".to_string())
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn server_host_mit_nicht_standard_port() {
|
||
assert_eq!(
|
||
server_host(Some("http://localhost:3000/")),
|
||
Some("localhost:3000".to_string())
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn server_host_laesst_standardport_pfad_und_query_weg() {
|
||
assert_eq!(
|
||
server_host(Some("https://host:443/pfad?x=1")),
|
||
Some("host".to_string())
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn server_host_none_bei_none() {
|
||
assert_eq!(server_host(None), None);
|
||
}
|
||
|
||
#[test]
|
||
fn server_host_none_bei_leerem_string() {
|
||
assert_eq!(server_host(Some("")), None);
|
||
}
|
||
|
||
#[test]
|
||
fn server_host_none_bei_unparsbarer_adresse() {
|
||
assert_eq!(server_host(Some("kein url")), None);
|
||
}
|
||
|
||
#[test]
|
||
fn tray_labels_mit_host() {
|
||
let (tooltip, line) = tray_labels(Some("https://tessera.ctl.de/"));
|
||
assert_eq!(tooltip, "Tessera – tessera.ctl.de");
|
||
assert_eq!(line, "Verbunden mit tessera.ctl.de");
|
||
}
|
||
|
||
#[test]
|
||
fn tray_labels_ohne_adresse() {
|
||
let (tooltip, line) = tray_labels(None);
|
||
assert_eq!(tooltip, "Tessera – nicht verbunden");
|
||
assert_eq!(line, "Nicht verbunden");
|
||
}
|
||
|
||
#[test]
|
||
fn setup_page_url_unter_windows() {
|
||
assert_eq!(
|
||
setup_page_url(true).as_str(),
|
||
"http://tauri.localhost/setup.html"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn setup_page_url_unter_linux() {
|
||
assert_eq!(
|
||
setup_page_url(false).as_str(),
|
||
"tauri://localhost/setup.html"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn parse_server_url_gueltige_adresse() {
|
||
let parsed = parse_server_url("https://tessera.ctl.de").unwrap();
|
||
assert_eq!(parsed.as_str(), "https://tessera.ctl.de/");
|
||
}
|
||
|
||
#[test]
|
||
fn parse_server_url_lehnt_fremdes_schema_ab() {
|
||
assert_eq!(
|
||
parse_server_url("ftp://host"),
|
||
Err("Es sind nur Adressen mit http oder https erlaubt.".to_string())
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn parse_server_url_lehnt_unparsbare_adresse_ab() {
|
||
assert_eq!(
|
||
parse_server_url("kein url"),
|
||
Err("Diese Adresse ist ungültig.".to_string())
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn check_failure_labels_401_nennt_proxy_passwortschutz() {
|
||
let (menu_text, body) = check_failure_labels(Some(401));
|
||
assert!(menu_text.contains("HTTP 401"));
|
||
assert!(menu_text.ends_with("– erneut prüfen"));
|
||
assert!(body.contains("HTTP 401"));
|
||
assert!(body.contains("Passwortschutz"));
|
||
assert!(body.contains("Zugriffsliste"));
|
||
}
|
||
|
||
#[test]
|
||
fn check_failure_labels_403_gleiche_erklaerung() {
|
||
let (menu_text, body) = check_failure_labels(Some(403));
|
||
assert!(menu_text.contains("HTTP 403"));
|
||
assert!(body.contains("HTTP 403"));
|
||
assert!(body.contains("Passwortschutz"));
|
||
}
|
||
|
||
#[test]
|
||
fn check_failure_labels_anderer_status_ohne_proxy_erklaerung() {
|
||
let (menu_text, body) = check_failure_labels(Some(502));
|
||
assert!(menu_text.contains("HTTP 502"));
|
||
assert!(menu_text.ends_with("– erneut prüfen"));
|
||
assert!(!body.contains("Passwortschutz"));
|
||
assert!(body.contains("statt mit Paketdaten"));
|
||
}
|
||
|
||
#[test]
|
||
fn check_failure_labels_ohne_status_keine_verbindung() {
|
||
let (menu_text, body) = check_failure_labels(None);
|
||
assert!(menu_text.contains("keine Verbindung"));
|
||
assert!(menu_text.ends_with("– erneut prüfen"));
|
||
assert!(body.contains("vier Stunden"));
|
||
}
|
||
|
||
#[test]
|
||
fn diagnostic_update_url_ersetzt_platzhalter_und_kodiert_base() {
|
||
assert_eq!(
|
||
diagnostic_update_url("https://alpha.example", "windows", "x86_64", "1.2.0"),
|
||
"https://alpha.example/api-proxy/desktop/update?target=windows&arch=x86_64¤t=1.2.0&base=https%3A%2F%2Falpha.example"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn diagnostic_update_url_trimmt_schlussstrich() {
|
||
assert_eq!(
|
||
diagnostic_update_url("https://alpha.example/", "linux", "x86_64", "1.2.0"),
|
||
"https://alpha.example/api-proxy/desktop/update?target=linux&arch=x86_64¤t=1.2.0&base=https%3A%2F%2Falpha.example"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn update_item_konstanten() {
|
||
assert_eq!(UPDATE_ITEM_CHECKING, "Suche nach Updates…");
|
||
assert_eq!(UPDATE_ITEM_NONE, "Kein Update verfügbar – erneut prüfen");
|
||
assert_eq!(UPDATE_ITEM_INSECURE, "Update nur über https möglich");
|
||
assert_eq!(UPDATE_CHECK_INTERVAL, Duration::from_secs(4 * 3600));
|
||
}
|
||
}
|