dfc4e9b781
Der Client reichte jeden Download an den System-Browser weiter. Dateien, die die Seite selbst erzeugt (blob:/data:, z. B. alle Downloads im Zertifikat-Manager), kann der Browser nicht abrufen - Windows zeigte nur "Holen Sie sich eine App, um diesen blob-Link zu oeffnen" (VM 8233). Solche Downloads speichert die App jetzt selbst (Ordner Downloads) und meldet Dateiname und Ordner; http/https-Downloads gehen weiter an den Browser. Dazu CHANGELOG und Quick-Doku zu 261001-l4q. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1875 lines
76 KiB
Rust
1875 lines
76 KiB
Rust
use semver::Version;
|
||
use std::sync::atomic::{AtomicBool, Ordering};
|
||
use std::sync::Mutex;
|
||
use std::time::Duration;
|
||
use tauri::{
|
||
menu::{CheckMenuItemBuilder, MenuBuilder, MenuItem, MenuItemBuilder},
|
||
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
|
||
AppHandle, Manager, RunEvent, WindowEvent, Wry,
|
||
};
|
||
use tauri_plugin_autostart::{MacosLauncher, ManagerExt};
|
||
use tauri_plugin_notification::NotificationExt;
|
||
use tauri_plugin_opener::OpenerExt;
|
||
use tauri_plugin_store::StoreExt;
|
||
use tauri_plugin_updater::{Update, UpdaterExt};
|
||
|
||
/// Menuetext des Update-Eintrags, solange eine Pruefung laeuft (gesperrt).
|
||
/// `spawn_version_check` setzt den Eintrag zu Beginn hierauf, auch nach einem
|
||
/// Serverwechsel, damit kein Hinweis des alten Servers stehen bleibt. Der
|
||
/// Eintrag installiert ein gefundenes Update seit quick-260917-kgc direkt in
|
||
/// der App (Download, Signaturpruefung, Installation, Neustart).
|
||
const UPDATE_ITEM_CHECKING: &str = "Suche nach Updates…";
|
||
|
||
/// Menuetext, wenn die Pruefung kein Update ergab. Der Eintrag bleibt
|
||
/// anklickbar und startet die Pruefung erneut (quick-260922-frg): der Nutzer
|
||
/// soll nie vor einem stumm ausgegrauten Eintrag stehen. Auch der Starttext
|
||
/// ohne gespeicherte Server-Adresse (Klick ohne Adresse tut nichts).
|
||
const UPDATE_ITEM_NONE: &str = "Kein Update verfügbar – erneut prüfen";
|
||
|
||
/// Abstand der wiederholten Update-Pruefung. Die Tray-App laeuft tagelang
|
||
/// durch; mit der Pruefung nur beim Start hat ein Client ein neues Paket auf
|
||
/// dem Server nie gesehen (Befund 22.09.2026, quick-260922-frg). Vier
|
||
/// Stunden reichen fuer den Beta-Kanal und belasten den Server nicht.
|
||
const UPDATE_CHECK_INTERVAL: Duration = Duration::from_secs(4 * 3600);
|
||
|
||
/// Menuetext, wenn die gespeicherte Server-Adresse mit `http://` beginnt:
|
||
/// das Updater-Plugin verweigert im Release-Bau unverschluesselte Endpunkte
|
||
/// (`Error::InsecureTransportProtocol`). Der Eintrag bleibt gesperrt, der
|
||
/// Text erklaert dauerhaft, warum -- keine Benachrichtigung bei jedem Start.
|
||
const UPDATE_ITEM_INSECURE: &str = "Update nur über https möglich";
|
||
|
||
#[derive(serde::Deserialize)]
|
||
struct VersionResponse {
|
||
version: String,
|
||
}
|
||
|
||
/// Gefundenes Update samt dem Menuetext, der zu ihm gehoert. Wird von
|
||
/// `spawn_version_check` befuellt und vom Tray-Klick "update" per `take()`
|
||
/// entnommen -- so kann ein Doppelklick keinen zweiten Download starten.
|
||
/// `Update` ist Clone + Send + Sync, `app.manage` verlangt das.
|
||
struct PendingUpdate(Mutex<Option<(Update, String)>>);
|
||
|
||
/// Ablauf-Merker des Updaters neben `PendingUpdate`.
|
||
///
|
||
/// `installing`: von `spawn_update_install` bis zum Fehlerfall gesetzt (im
|
||
/// Erfolgsfall startet die App neu bzw. beendet sich, der Merker bleibt).
|
||
/// Solange er gesetzt ist, tun Tray-Klick, `spawn_version_check` und die
|
||
/// 4-h-Schleife nichts -- vorher konnte eine Pruefung waehrend des Downloads
|
||
/// ein neues Update ablegen und den Eintrag wieder aktiv schalten, ein
|
||
/// zweiter Klick startete dann einen parallelen Download samt Installer.
|
||
///
|
||
/// `offered`: eine Pruefung hat ein Update angeboten ("Auf Version …
|
||
/// aktualisieren"). Der Klick darauf prueft frisch (`install_after`); schlug
|
||
/// diese Pruefung fehl, war der abgelegte Stand weg und der naechste Klick
|
||
/// bot nur wieder an -- zwei weitere Klicks bis zur Installation. Der Merker
|
||
/// ueberlebt die fehlgeschlagene Pruefung, der naechste erfolgreiche Klick
|
||
/// installiert direkt. Geleert, wenn eine Pruefung "kein Update" ergibt oder
|
||
/// der Server wechselt.
|
||
#[derive(Default)]
|
||
struct UpdateFlow {
|
||
installing: AtomicBool,
|
||
offered: AtomicBool,
|
||
}
|
||
|
||
impl UpdateFlow {
|
||
/// Beansprucht die Installation. `false`, wenn bereits eine laeuft --
|
||
/// dann darf der Aufrufer nichts starten.
|
||
fn try_begin_install(&self) -> bool {
|
||
self.installing
|
||
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
|
||
.is_ok()
|
||
}
|
||
|
||
/// Gibt die Installation nach einem Fehler wieder frei.
|
||
fn install_failed(&self) {
|
||
self.installing.store(false, Ordering::SeqCst);
|
||
}
|
||
|
||
fn is_installing(&self) -> bool {
|
||
self.installing.load(Ordering::SeqCst)
|
||
}
|
||
|
||
fn set_offered(&self, offered: bool) {
|
||
self.offered.store(offered, Ordering::SeqCst);
|
||
}
|
||
|
||
/// Soll der Tray-Klick nach der frischen Pruefung installieren? Ja, wenn
|
||
/// ein Stand abgelegt war ODER vorher schon ein Update angeboten wurde
|
||
/// (Klick auf "Update-Prüfung fehlgeschlagen … – erneut prüfen" nach
|
||
/// einem Angebot).
|
||
fn install_on_click(&self, had_pending: bool) -> bool {
|
||
had_pending || self.offered.load(Ordering::SeqCst)
|
||
}
|
||
}
|
||
|
||
/// Benachrichtigungstext der zuletzt gemeldeten fehlgeschlagenen
|
||
/// Update-Pruefung. Die Pruefung laeuft alle `UPDATE_CHECK_INTERVAL` erneut;
|
||
/// gegen einen dauerhaft sperrenden Proxy wuerde sonst alle vier Stunden
|
||
/// dieselbe Meldung erscheinen (T-FRG-02). Nur ein ABWEICHENDER Text wird
|
||
/// erneut gemeldet; ein Erfolg (`Ok(..)`) leert den Wert, damit ein spaeterer
|
||
/// Fehler wieder sichtbar wird. Eigener `app.manage`-Typ, damit
|
||
/// `spawn_version_check` seine Signatur behaelt.
|
||
struct LastCheckNotice(Mutex<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()),
|
||
}
|
||
}
|
||
|
||
/// Die drei Benachrichtigungs-Berechtigungen, die die Server-Seite in der
|
||
/// Webview bekommt -- genau diese und keine weitere (T-IF2-03). Die
|
||
/// Bezeichner stammen aus tauri-plugin-notification 2.3.3
|
||
/// (`permissions/autogenerated/commands/notify.toml`,
|
||
/// `is_permission_granted.toml`, `request_permission.toml`). Eine unbekannte
|
||
/// Kennung wuerde in `add_capability` ebenfalls in einen Panic laufen; darum
|
||
/// pinnt ein Test die exakte Menge samt Reihenfolge.
|
||
const SERVER_NOTIFICATION_PERMISSIONS: [&str; 3] = [
|
||
"notification:allow-notify",
|
||
"notification:allow-is-permission-granted",
|
||
"notification:allow-request-permission",
|
||
];
|
||
|
||
/// Bereits berechtigte Herkunfts-Muster. `add_capability` haengt bei jedem
|
||
/// Aufruf an die bestehenden Erlaubnisse an; ein zweites Speichern derselben
|
||
/// Adresse soll nichts verdoppeln.
|
||
static GRANTED_SERVER_ORIGINS: Mutex<Vec<String>> = Mutex::new(Vec::new());
|
||
|
||
/// Baut aus der gespeicherten Server-Adresse das URL-Muster fuer die
|
||
/// Laufzeit-Berechtigung: genau `Schema://Host[:Port]`, ohne Pfad, Query und
|
||
/// Fragment (quick-260929-if2, E-01).
|
||
///
|
||
/// Jedes Zeichen des Hosts ausserhalb von ASCII `A-Z a-z 0-9 . -` bekommt ein
|
||
/// Backslash (URLPattern-Maskierung). Grund: der `url`-Crate nimmt
|
||
/// `http://*.example.com/` an und liefert den Host `*.example.com`, der
|
||
/// ungeschuetzt zum Platzhalter fuer JEDE Subdomain wuerde. IPv6-Adressen
|
||
/// kommen in Klammern (`[::1]`); der URLPattern-Zerleger (urlpattern 0.3.0)
|
||
/// weist `http://[::1]:8080` mit `Tokenizer(InvalidName, 1)` ab, die
|
||
/// maskierte Form `http://\[\:\:1\]:8080` dagegen parst und trifft nur
|
||
/// `[::1]:8080` (beides gemessen).
|
||
///
|
||
/// Der Port steht nur, wenn er ausdruecklich und nicht der Standardport ist
|
||
/// (der `url`-Crate laesst Standardports weg).
|
||
///
|
||
/// SELBSTPRUEFUNG, Pflicht: `add_capability` fuehrt intern
|
||
/// `Resolved::resolve(..).unwrap()` aus, waehrend es die Sperre der
|
||
/// Laufzeit-Berechtigungen haelt, und tauri-utils 2.9.3 gerät bei einem
|
||
/// nicht zerlegbaren Muster in einen Panic ("invalid URL pattern for remote
|
||
/// URL"). Ein Fehler kommt NICHT als `Err` zurueck, er legt `setup()` lahm --
|
||
/// und ein `catch_unwind` haette die Sperre vergiftet, sodass jeder spaetere
|
||
/// IPC-Aufruf scheitert. Deshalb wird das Muster VOR dem Aufruf mit
|
||
/// demselben Zerleger geprueft, den Tauri fuer `remote.urls` benutzt
|
||
/// (`RemoteUrlPattern`), und muss die gespeicherte Adresse tatsaechlich
|
||
/// treffen. Sonst `None`: kein Muster, keine Berechtigung.
|
||
fn server_origin_pattern(url: &str) -> Option<String> {
|
||
use std::str::FromStr;
|
||
let parsed = parse_server_url(url).ok()?;
|
||
let host = parsed.host_str()?;
|
||
let escaped: String = host
|
||
.chars()
|
||
.flat_map(|c| {
|
||
if c.is_ascii_alphanumeric() || c == '.' || c == '-' {
|
||
vec![c]
|
||
} else {
|
||
vec!['\\', c]
|
||
}
|
||
})
|
||
.collect();
|
||
let mut pattern = format!("{}://{}", parsed.scheme(), escaped);
|
||
if let Some(port) = parsed.port() {
|
||
pattern.push_str(&format!(":{}", port));
|
||
}
|
||
let checked = tauri::utils::acl::RemoteUrlPattern::from_str(&pattern).ok()?;
|
||
if checked.test(&parsed) {
|
||
Some(pattern)
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
|
||
/// Erteilt der gespeicherten Server-Adresse -- und nur ihr -- zur Laufzeit das
|
||
/// Recht, das Benachrichtigungs-Plugin aus der Webview aufzurufen
|
||
/// (quick-260929-if2, E-01). Der globale Erinnerungs-Melder der Web-Oberflaeche
|
||
/// ruft `plugin:notification|notify`, damit auch bei ausgeblendetem Fenster
|
||
/// (Infobereich) eine Windows-Meldung erscheint.
|
||
///
|
||
/// WARUM ZUR LAUFZEIT UND NICHT IN `capabilities/default.json`: die statische
|
||
/// Datei hat absichtlich keinen `remote`-Block (T-JN2-01, siehe
|
||
/// `get_server_url`), Tauri verweigert deshalb jeden Plugin- und App-Aufruf
|
||
/// aus der Server-Seite. Die Serveradresse ist erst zur Laufzeit bekannt und
|
||
/// je Installation verschieden. Die Berechtigung gilt genau fuer den
|
||
/// gespeicherten Ursprung (maskiert und selbstgeprueft, siehe
|
||
/// `server_origin_pattern` -- nie ein Platzhalter), nur fuer das Fenster
|
||
/// `main` und nur fuer die drei Benachrichtigungs-Rechte. App-Befehle wie
|
||
/// `save_server_url` bleiben lokal-only, T-JN2-01 gilt unveraendert. Nach
|
||
/// einem Serverwechsel behaelt der alte Ursprung sein Benachrichtigungsrecht
|
||
/// bis zum App-Neustart (akzeptiert, T-IF2-03).
|
||
///
|
||
/// Verworfene Alternativen: (1) ein Abruf der API von Rust aus -- der
|
||
/// vorgeschaltete Basic-Auth-Schutz von alpha liefert reqwest ein 401 (wie
|
||
/// beim Updater), und das Sitzungs-Cookie lebt nur in der Webview.
|
||
/// (2) Die Web-Benachrichtigung in der Webview -- das Plugin ersetzt
|
||
/// `window.Notification` durch einen Ersatz mit demselben IPC-Aufruf, der
|
||
/// unter Windows bei jedem Seitenaufruf "denied" meldet.
|
||
///
|
||
/// Darf den Start nie verhindern: bei einem Muster, das sich nicht bilden
|
||
/// laesst, bleibt es bei einer Zeile auf stderr -- die Desktop-Meldung ist
|
||
/// dann fuer diese Adresse aus, Browser-Meldung und E-Mail laufen weiter.
|
||
fn grant_server_notifications(app: &AppHandle, url: &str) {
|
||
let Some(pattern) = server_origin_pattern(url) else {
|
||
eprintln!(
|
||
"Benachrichtigungen: keine Berechtigung fuer die Server-Adresse erteilt (Muster nicht bildbar)"
|
||
);
|
||
return;
|
||
};
|
||
let result = grant_origin_once(&GRANTED_SERVER_ORIGINS, &pattern, |pattern| {
|
||
let mut capability = tauri::ipc::CapabilityBuilder::new("server-notifications")
|
||
.remote(pattern.to_string())
|
||
.local(false)
|
||
.window("main");
|
||
for permission in SERVER_NOTIFICATION_PERMISSIONS {
|
||
capability = capability.permission(permission);
|
||
}
|
||
app.add_capability(capability)
|
||
});
|
||
if let Err(e) = result {
|
||
eprintln!("Benachrichtigungen: Berechtigung nicht erteilt: {}", e);
|
||
}
|
||
}
|
||
|
||
/// Fuehrt `grant` fuer `pattern` hoechstens einmal erfolgreich aus. Die Sperre
|
||
/// von `granted` bleibt ueber den Aufruf gehalten, damit zwei gleichzeitige
|
||
/// Aufrufe nicht doppelt berechtigen; vermerkt wird der Ursprung erst NACH
|
||
/// dem Erfolg. Frueher stand er schon vorher in der Liste -- schlug
|
||
/// `add_capability` fehl, gab es bis zum Neustart keinen neuen Versuch.
|
||
/// Bereits vermerkt: `Ok(())` ohne Aufruf.
|
||
fn grant_origin_once<E>(
|
||
granted: &Mutex<Vec<String>>,
|
||
pattern: &str,
|
||
grant: impl FnOnce(&str) -> Result<(), E>,
|
||
) -> Result<(), E> {
|
||
let mut granted = match granted.lock() {
|
||
Ok(guard) => guard,
|
||
Err(poisoned) => poisoned.into_inner(),
|
||
};
|
||
if granted.iter().any(|p| p == pattern) {
|
||
return Ok(());
|
||
}
|
||
grant(pattern)?;
|
||
granted.push(pattern.to_string());
|
||
Ok(())
|
||
}
|
||
|
||
/// Liefert (Tooltip, Menüzeile) für den Tray -- einzige Stelle, die beide
|
||
/// Texte aus der Server-Adresse ableitet. Gedankenstrich U+2013 wie in
|
||
/// `update_labels`.
|
||
fn tray_labels(url: Option<&str>) -> (String, String) {
|
||
match server_host(url) {
|
||
Some(host) => (
|
||
format!("Tessera – {}", host),
|
||
format!("Verbunden mit {}", host),
|
||
),
|
||
None => (
|
||
"Tessera – nicht verbunden".to_string(),
|
||
"Nicht verbunden".to_string(),
|
||
),
|
||
}
|
||
}
|
||
|
||
/// Spiegelt Tauris nicht öffentliche `tauri_protocol_url`
|
||
/// (tauri-2.11.3 manager/mod.rs Z. 339-346): WebView2 kennt kein eigenes
|
||
/// Schema, darum unter Windows/Android `http://tauri.localhost`, sonst
|
||
/// `tauri://localhost`. `useHttpsScheme` ist in tauri.conf.json nicht
|
||
/// gesetzt, darum bewusst `http`. Der `tauri dev`-Fall mit `devUrl` wird in
|
||
/// diesem Projekt nicht genutzt (CI baut Release) und ist hier bewusst nicht
|
||
/// abgebildet. `WebviewUrl::App` laesst sich nicht an `navigate` geben,
|
||
/// darum die feste Adresse.
|
||
fn setup_page_url(windows: bool) -> tauri::Url {
|
||
let base = if windows {
|
||
"http://tauri.localhost/setup.html"
|
||
} else {
|
||
"tauri://localhost/setup.html"
|
||
};
|
||
tauri::Url::parse(base).expect("setup_page_url: feste Adresse muss gueltig sein")
|
||
}
|
||
|
||
/// Liest die gespeicherte Server-Adresse aus dem Store -- einzige Lesestelle
|
||
/// (Start, `get_server_url`, `open_server`, Tray-Klick "update"); nur
|
||
/// nicht-leere Werte gelten als gespeichert.
|
||
fn stored_server_url(app: &AppHandle) -> Option<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).
|
||
///
|
||
/// `install_after`: der Klick auf "Auf Version … aktualisieren" installiert
|
||
/// NICHT den abgelegten Stand, sondern prueft zuerst frisch und installiert
|
||
/// genau, was diese Pruefung liefert. Grund (Nutzer, 29.09.2026: "Signatur-
|
||
/// verifikation failed", danach Browser): die Download-Adresse
|
||
/// `/desktop/download/<os>` liefert immer den AKTUELLEN Installer; ein
|
||
/// Stunden alter Stand trug noch die Signatur der Vorversion, nach dem
|
||
/// Server-Update passten Datei und Signatur nicht mehr zusammen.
|
||
///
|
||
/// Waehrend einer Installation (`UpdateFlow::installing`) tut die Funktion
|
||
/// nichts: kein Menuetext, kein Leeren, keine Ablage.
|
||
fn spawn_version_check(app: AppHandle, server_url: String, install_after: bool) {
|
||
if app.state::<UpdateFlow>().is_installing() {
|
||
return;
|
||
}
|
||
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 {
|
||
let result = check_for_update(&app, endpoint).await;
|
||
// Eine parallel gestartete Pruefung (Klick waehrend der 4-h-Schleife)
|
||
// kann inzwischen eine Installation ausgeloest haben: dann weder
|
||
// Menuetext noch Ablage anfassen.
|
||
if app.state::<UpdateFlow>().is_installing() {
|
||
return;
|
||
}
|
||
match result {
|
||
Ok(Some(mut update)) => {
|
||
let (menu_text, body) = Version::parse(&update.version)
|
||
.map(|release| release_labels(¤t, &release))
|
||
.unwrap_or_else(|_| update_labels(true, &update.version, ""));
|
||
// 15 s reichen fuer die Pruefung, nicht fuer den ~100-MB-Download:
|
||
// das Plugin wendet `Update.timeout` als Gesamt-Timeout des
|
||
// Downloads an (updater.rs Z. 698-700).
|
||
update.timeout = Some(Duration::from_secs(600));
|
||
clear_check_notice(&app);
|
||
if install_after {
|
||
spawn_update_install(app.clone(), update, menu_text);
|
||
return;
|
||
}
|
||
let _ = app
|
||
.notification()
|
||
.builder()
|
||
.title("Tessera-Update")
|
||
.body(body)
|
||
.show();
|
||
let _ = update_item.set_text(&menu_text);
|
||
let _ = update_item.set_enabled(true);
|
||
if let Ok(mut pending) = app.state::<PendingUpdate>().0.lock() {
|
||
*pending = Some((update, menu_text));
|
||
}
|
||
app.state::<UpdateFlow>().set_offered(true);
|
||
}
|
||
Ok(None) => {
|
||
let _ = update_item.set_text(UPDATE_ITEM_NONE);
|
||
let _ = update_item.set_enabled(true);
|
||
app.state::<UpdateFlow>().set_offered(false);
|
||
clear_check_notice(&app);
|
||
}
|
||
// Der Release-Bau verweigert http-Endpunkte (config.rs
|
||
// `validate_endpoints`), der Debug-Bau warnt nur. Keine
|
||
// `dangerousInsecureTransportProtocol`-Freigabe (T-KGC-06): der
|
||
// Eintrag bleibt gesperrt und erklaert den Grund.
|
||
Err(tauri_plugin_updater::Error::InsecureTransportProtocol) => {
|
||
let _ = update_item.set_text(UPDATE_ITEM_INSECURE);
|
||
}
|
||
// Der Server hat geantwortet, aber nicht mit 2xx/204: das Plugin
|
||
// verschluckt den Statuscode (updater.rs Z. 529-559). Dieselbe
|
||
// Anfrage einmal selbst stellen und nur den Status lesen -- so
|
||
// steht im Eintrag "HTTP 401", wenn ein Passwortschutz am Proxy
|
||
// die Anfrage abweist (Befund 22.09.2026).
|
||
Err(tauri_plugin_updater::Error::ReleaseNotFound) => {
|
||
let status = probe_update_status(diagnostic_update_url(
|
||
&server_url,
|
||
std::env::consts::OS,
|
||
std::env::consts::ARCH,
|
||
env!("CARGO_PKG_VERSION"),
|
||
))
|
||
.await;
|
||
report_check_failure(&app, &update_item, status);
|
||
}
|
||
// Keine Verbindung, TLS, Timeout (Reqwest/Network/Io) oder
|
||
// unlesbares Manifest: keine zweite Anfrage, Status unbekannt.
|
||
Err(_) => report_check_failure(&app, &update_item, None),
|
||
}
|
||
});
|
||
}
|
||
|
||
/// Oeffnet die Seite Einstellungen → Desktop-App im System-Browser -- der
|
||
/// bisherige Weg des Tray-Eintrags, seit quick-260922-frg NUR noch Rueckfall,
|
||
/// wenn die Installation in der App fehlschlaegt (ein Klick ohne abgelegtes
|
||
/// Update prueft stattdessen erneut). Bewusst ohne `desktop=1` (siehe
|
||
/// `with_desktop_marker`).
|
||
fn open_download_page(app: &AppHandle) {
|
||
if let Some(server) = stored_server_url(app) {
|
||
let target = format!("{}/settings/general/desktop", server.trim_end_matches('/'));
|
||
let _ = app.opener().open_url(target, None::<&str>);
|
||
}
|
||
}
|
||
|
||
/// Laedt das abgelegte Update, prueft die Signatur, installiert es und
|
||
/// startet die App neu. Fortschritt und Zustand stehen im Menuetext des
|
||
/// gesperrten Eintrags. Fehler: Menuetext und Stand zurueck, Eintrag wieder
|
||
/// aktiv, Benachrichtigung mit Grund, Einstellungsseite im Browser als
|
||
/// Rueckfall.
|
||
///
|
||
/// Beansprucht `UpdateFlow::installing`; laeuft bereits eine Installation,
|
||
/// passiert nichts. Freigegeben wird der Merker nur im Fehlerfall.
|
||
fn spawn_update_install(app: AppHandle, update: Update, menu_text: String) {
|
||
if !app.state::<UpdateFlow>().try_begin_install() {
|
||
return;
|
||
}
|
||
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()));
|
||
}
|
||
app.state::<UpdateFlow>().install_failed();
|
||
let _ = item.set_enabled(true);
|
||
let _ = app
|
||
.notification()
|
||
.builder()
|
||
.title("Tessera-Update")
|
||
.body(format!(
|
||
"Update fehlgeschlagen: {}. Die Download-Seite wird im Browser geöffnet.",
|
||
e
|
||
))
|
||
.show();
|
||
open_download_page(&app);
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
#[tauri::command]
|
||
async fn check_server(url: String) -> Result<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())?;
|
||
|
||
// Vor dem Navigieren: die Seite darf danach sofort Desktop-Meldungen ausloesen.
|
||
grant_server_notifications(&app, &normalized);
|
||
apply_server(&app, Some(&normalized));
|
||
// Ein Angebot des alten Servers gilt nicht fuer den neuen.
|
||
app.state::<UpdateFlow>().set_offered(false);
|
||
spawn_version_check(app.clone(), normalized.clone(), false);
|
||
|
||
if let Some(window) = app.get_webview_window("main") {
|
||
let _ = window.navigate(with_desktop_marker(&parsed));
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// Liefert NUR den gespeicherten Wert `server_url` aus dem Store, nichts
|
||
/// anderes. Nur vom lokalen Ursprung aufrufbar (T-JN2-01): capabilities/
|
||
/// default.json hat keinen `remote`-Block, darum verweigert Tauri App-
|
||
/// Commands aus der Server-Seite (tauri-2.11.3 webview/mod.rs Z. 1819-1823).
|
||
#[tauri::command]
|
||
fn get_server_url(app: AppHandle) -> Option<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"))
|
||
}
|
||
|
||
/// Ziel fuer den System-Browser, wenn die Seite ein neues Fenster verlangt
|
||
/// (`window.open`, Links mit `target="_blank"`). Nur http/https -- andere
|
||
/// Schemata (file:, javascript:, eigene Protokolle) werden verworfen.
|
||
fn external_target(url: &tauri::Url) -> Option<String> {
|
||
match url.scheme() {
|
||
"http" | "https" => Some(url.to_string()),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// Downloads, die die App selbst speichert statt sie an den System-Browser
|
||
/// zu geben: Inhalte, die die Seite im Speicher erzeugt hat (`blob:`,
|
||
/// `data:`) -- der Browser kann sie nicht abrufen.
|
||
fn saves_in_app(url: &tauri::Url) -> bool {
|
||
matches!(url.scheme(), "blob" | "data")
|
||
}
|
||
|
||
/// Titel und Text der Meldung nach einem in der App gespeicherten Download.
|
||
fn download_notice(path: Option<&std::path::Path>, success: bool) -> (String, String) {
|
||
let name = path
|
||
.and_then(|p| p.file_name())
|
||
.map(|n| n.to_string_lossy().into_owned());
|
||
let folder = path
|
||
.and_then(|p| p.parent())
|
||
.and_then(|p| p.file_name())
|
||
.map(|n| n.to_string_lossy().into_owned());
|
||
match (success, name) {
|
||
(true, Some(name)) => (
|
||
"Download gespeichert".to_string(),
|
||
match folder {
|
||
Some(folder) => format!("„{name}“ liegt im Ordner {folder}."),
|
||
None => format!("„{name}“ wurde gespeichert."),
|
||
},
|
||
),
|
||
(true, None) => (
|
||
"Download gespeichert".to_string(),
|
||
"Die Datei wurde gespeichert.".to_string(),
|
||
),
|
||
(false, _) => (
|
||
"Download fehlgeschlagen".to_string(),
|
||
"Die Datei konnte nicht gespeichert werden.".to_string(),
|
||
),
|
||
}
|
||
}
|
||
|
||
/// Hauptfenster nach vorne holen. Ein minimiertes Fenster (z. B. per Win+D)
|
||
/// wird zuerst wiederhergestellt.
|
||
fn show_main_window(app: &AppHandle) {
|
||
if let Some(w) = app.get_webview_window("main") {
|
||
let _ = w.unminimize();
|
||
let _ = w.show();
|
||
let _ = w.set_focus();
|
||
}
|
||
}
|
||
|
||
pub fn run() {
|
||
let app = tauri::Builder::default()
|
||
// Muss als erstes Plugin stehen. Verhindert den Doppelstart (z. B. beim
|
||
// Windows-Login) mit zwei Tray-Symbolen: Ein zweiter Start holt nur das
|
||
// Fenster der laufenden Instanz nach vorne und beendet sich sofort.
|
||
.plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| {
|
||
show_main_window(app);
|
||
}))
|
||
.plugin(tauri_plugin_store::Builder::new().build())
|
||
.plugin(tauri_plugin_notification::init())
|
||
.plugin(tauri_plugin_window_state::Builder::default().build())
|
||
.plugin(tauri_plugin_autostart::init(
|
||
MacosLauncher::LaunchAgent,
|
||
None,
|
||
))
|
||
.plugin(tauri_plugin_opener::init())
|
||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||
.invoke_handler(tauri::generate_handler![
|
||
check_server,
|
||
save_server_url,
|
||
get_server_url,
|
||
open_server,
|
||
get_client_info
|
||
])
|
||
.setup(|app| {
|
||
// Hauptfenster im Code statt in tauri.conf.json (`app.windows` ist
|
||
// leer), weil nur der Builder einen Download-Handler annimmt. Ohne
|
||
// Handler verpufft ein Klick auf "Herunterladen" (Einstellungen ->
|
||
// Desktop-App) in der Webansicht: webkit2gtk verwirft den Download
|
||
// still, WebView2 zeigte ebenfalls nichts (Nutzer, 22.09.2026,
|
||
// Windows und Linux). Der Handler bricht den Download in der App ab
|
||
// und reicht die Adresse an den System-Browser weiter -- dort gibt
|
||
// es Fortschritt, Speicherort und ein Passwortfenster fuer einen
|
||
// vorgeschalteten Proxy. Masse/Zentrierung wie bisher in der
|
||
// Konfiguration; Fensterzustand stellt tauri_plugin_window_state
|
||
// weiterhin her.
|
||
tauri::WebviewWindowBuilder::new(
|
||
app,
|
||
"main",
|
||
tauri::WebviewUrl::App("setup.html".into()),
|
||
)
|
||
.title("Tessera")
|
||
.inner_size(1280.0, 800.0)
|
||
.center()
|
||
.decorations(true)
|
||
.resizable(true)
|
||
// Neue Fenster (Suche-Widget per `window.open`, „In neuem Tab
|
||
// öffnen“ bei XFrame/eigenen Modulen/Favoriten) verwarf die
|
||
// Webansicht still -- Nutzer, 29.09.2026: Suche im Client ohne
|
||
// Funktion. Jetzt gehen sie wie im Browser-Tab nach aussen, in den
|
||
// System-Browser; die App selbst oeffnet kein zweites Fenster.
|
||
.on_new_window({
|
||
let handle = app.handle().clone();
|
||
move |url, _features| {
|
||
if let Some(target) = external_target(&url) {
|
||
let _ = handle.opener().open_url(target, None::<&str>);
|
||
}
|
||
tauri::webview::NewWindowResponse::Deny
|
||
}
|
||
})
|
||
.on_download(|webview, event| match event {
|
||
// Dateien, die die Seite selbst erzeugt (blob:/data:, z. B.
|
||
// Zertifikats-Downloads), kennt der System-Browser nicht --
|
||
// Windows meldete „Holen Sie sich eine App, um diesen
|
||
// ‚blob‘-Link zu öffnen“ (VM 8233, 01.10.2026). Die speichert
|
||
// die App selbst im Ordner Downloads und meldet es danach.
|
||
tauri::webview::DownloadEvent::Requested { url, .. } => {
|
||
if saves_in_app(&url) {
|
||
return true;
|
||
}
|
||
let _ = webview
|
||
.app_handle()
|
||
.opener()
|
||
.open_url(url.to_string(), None::<&str>);
|
||
false
|
||
}
|
||
tauri::webview::DownloadEvent::Finished { url, path, success } => {
|
||
if saves_in_app(&url) {
|
||
let (title, body) = download_notice(path.as_deref(), success);
|
||
let _ = webview
|
||
.app_handle()
|
||
.notification()
|
||
.builder()
|
||
.title(title)
|
||
.body(body)
|
||
.show();
|
||
}
|
||
true
|
||
}
|
||
_ => true,
|
||
})
|
||
.build()?;
|
||
|
||
let server_url = stored_server_url(app.handle());
|
||
|
||
if let Some(url) = &server_url {
|
||
// Vor dem ersten `navigate`: Benachrichtigungsrecht fuer die
|
||
// gespeicherte Adresse (quick-260929-if2, E-01).
|
||
grant_server_notifications(app.handle(), url);
|
||
if let Some(window) = app.get_webview_window("main") {
|
||
if let Ok(parsed) = parse_server_url(url) {
|
||
let _ = window.navigate(with_desktop_marker(&parsed));
|
||
}
|
||
}
|
||
}
|
||
|
||
// Tray menu: Verbunden mit ... (gesperrt) · — · Öffnen ·
|
||
// Server-Adresse ändern... · Suche nach Updates… · — ·
|
||
// Autostart-Haken · — · Beenden.
|
||
// "connected" ist nicht anklickbar; "update" startet gesperrt als
|
||
// "Suche nach Updates…", weil die Erstpruefung unten sofort
|
||
// anlaeuft, und heisst danach "Auf Version X.Y.Z aktualisieren"
|
||
// bzw. "Auf Beta-Stand ... aktualisieren", "Kein Update verfügbar
|
||
// – erneut prüfen" oder "Update-Prüfung fehlgeschlagen (...) –
|
||
// erneut prüfen" (alle anklickbar). Ohne gespeicherte Adresse
|
||
// "Kein Update verfügbar – erneut prüfen" (Klick tut nichts);
|
||
// "autostart" spiegelt den tatsaechlichen Systemzustand beim Start.
|
||
let connected =
|
||
MenuItemBuilder::with_id("connected", tray_labels(server_url.as_deref()).1)
|
||
.enabled(false)
|
||
.build(app)?;
|
||
let open = MenuItemBuilder::with_id("open", "Öffnen").build(app)?;
|
||
let change_server =
|
||
MenuItemBuilder::with_id("change_server", "Server-Adresse ändern…").build(app)?;
|
||
let update_text = if server_url.is_some() {
|
||
UPDATE_ITEM_CHECKING
|
||
} else {
|
||
UPDATE_ITEM_NONE
|
||
};
|
||
let update = MenuItemBuilder::with_id("update", update_text)
|
||
.enabled(server_url.is_none())
|
||
.build(app)?;
|
||
let autostart_label = if cfg!(target_os = "windows") {
|
||
"Mit Windows starten"
|
||
} else {
|
||
"Beim Anmelden starten"
|
||
};
|
||
let autostart = CheckMenuItemBuilder::with_id("autostart", autostart_label)
|
||
.checked(app.autolaunch().is_enabled().unwrap_or(false))
|
||
.build(app)?;
|
||
let quit = MenuItemBuilder::with_id("quit", "Beenden").build(app)?;
|
||
|
||
let menu = MenuBuilder::new(app)
|
||
.item(&connected)
|
||
.separator()
|
||
.item(&open)
|
||
.item(&change_server)
|
||
.item(&update)
|
||
.separator()
|
||
.item(&autostart)
|
||
.separator()
|
||
.item(&quit)
|
||
.build()?;
|
||
|
||
app.manage(TrayItems {
|
||
connected: connected.clone(),
|
||
update: update.clone(),
|
||
});
|
||
app.manage(PendingUpdate(Mutex::new(None)));
|
||
app.manage(UpdateFlow::default());
|
||
app.manage(LastCheckNotice(Mutex::new(String::new())));
|
||
|
||
let autostart_for_menu = autostart.clone();
|
||
|
||
let _tray = TrayIconBuilder::with_id("main")
|
||
.icon(app.default_window_icon().unwrap().clone())
|
||
.menu(&menu)
|
||
.tooltip(tray_labels(server_url.as_deref()).0)
|
||
.show_menu_on_left_click(false)
|
||
.on_menu_event(move |app, event| match event.id().as_ref() {
|
||
"open" => show_main_window(app),
|
||
"change_server" => {
|
||
if let Some(w) = app.get_webview_window("main") {
|
||
let _ = w.navigate(setup_page_url(cfg!(windows)));
|
||
}
|
||
show_main_window(app);
|
||
}
|
||
"update" => {
|
||
// `take()` verhindert Doppelklick-Downloads, der
|
||
// gesperrte Eintrag ebenso. Liegt ein Update vor,
|
||
// wird es NICHT aus dem Speicher installiert, sondern
|
||
// frisch geprueft und das Ergebnis installiert
|
||
// (`install_after`, siehe spawn_version_check). Ohne
|
||
// abgelegten Stand ("Kein Update verfügbar" /
|
||
// "Update-Prüfung fehlgeschlagen") prueft der Klick
|
||
// nur erneut. Der Browser-Weg (`open_download_page`)
|
||
// bleibt Rueckfall einer fehlgeschlagenen Installation.
|
||
// Ohne gespeicherte Adresse gibt es nichts zu pruefen.
|
||
// Waehrend einer Installation tut der Klick nichts
|
||
// (`UpdateFlow`). War vorher schon ein Update
|
||
// angeboten (Stand nach fehlgeschlagener Pruefung
|
||
// weg), installiert der Klick trotzdem direkt.
|
||
let flow = app.state::<UpdateFlow>();
|
||
if flow.is_installing() {
|
||
return;
|
||
}
|
||
let had_pending = app
|
||
.state::<PendingUpdate>()
|
||
.0
|
||
.lock()
|
||
.ok()
|
||
.and_then(|mut guard| guard.take())
|
||
.is_some();
|
||
if let Some(url) = stored_server_url(app) {
|
||
let install_after = flow.install_on_click(had_pending);
|
||
spawn_version_check(app.clone(), url, install_after);
|
||
}
|
||
}
|
||
"autostart" => {
|
||
let mgr = app.autolaunch();
|
||
let currently_on = mgr.is_enabled().unwrap_or(false);
|
||
let toggled = if currently_on {
|
||
mgr.disable()
|
||
} else {
|
||
mgr.enable()
|
||
};
|
||
match toggled {
|
||
Ok(()) => {
|
||
let _ = autostart_for_menu.set_checked(!currently_on);
|
||
}
|
||
Err(_) => {
|
||
let _ = autostart_for_menu.set_checked(currently_on);
|
||
}
|
||
}
|
||
}
|
||
"quit" => {
|
||
app.exit(0);
|
||
}
|
||
_ => {}
|
||
})
|
||
.on_tray_icon_event(|tray, event| {
|
||
if let TrayIconEvent::Click {
|
||
button: MouseButton::Left,
|
||
button_state: MouseButtonState::Up,
|
||
..
|
||
} = event
|
||
{
|
||
show_main_window(tray.app_handle());
|
||
}
|
||
})
|
||
.build(app)?;
|
||
|
||
apply_server(app.handle(), server_url.as_deref());
|
||
|
||
if let Some(url) = server_url {
|
||
spawn_version_check(app.handle().clone(), url, false);
|
||
}
|
||
|
||
// Wiederholte Pruefung alle UPDATE_CHECK_INTERVAL. Bewusst ein
|
||
// schlichter Betriebssystem-Thread mit `sleep` statt
|
||
// `tokio::time`: das Tauri-Async-Runtime braeuchte dafuer das
|
||
// `time`-Feature bzw. ein eigenes Crate, und ein schlafender
|
||
// Thread kostet nichts. Je Durchlauf wird die Adresse frisch
|
||
// gelesen (Serverwechsel zaehlt). Haelt `PendingUpdate` bereits
|
||
// ein Update, wird der Durchlauf uebersprungen: die Pruefung
|
||
// wuerde den Stand leeren und "Neuer Beta-Stand" erneut melden.
|
||
// Waehrend einer Installation ist der Stand per `take()` leer;
|
||
// darum prueft die Schleife zusaetzlich `UpdateFlow::installing`
|
||
// und ueberspringt den Durchlauf (vorher: Pruefung mitten im
|
||
// Download legte ein neues Update ab und schaltete den Eintrag
|
||
// wieder aktiv -- zweiter paralleler Download moeglich).
|
||
let handle = app.handle().clone();
|
||
std::thread::spawn(move || loop {
|
||
std::thread::sleep(UPDATE_CHECK_INTERVAL);
|
||
if handle.state::<UpdateFlow>().is_installing() {
|
||
continue;
|
||
}
|
||
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, false);
|
||
}
|
||
});
|
||
|
||
Ok(())
|
||
})
|
||
.on_window_event(|window, event| {
|
||
if let WindowEvent::CloseRequested { api, .. } = event {
|
||
let _ = window.hide();
|
||
api.prevent_close();
|
||
}
|
||
})
|
||
.build(tauri::generate_context!())
|
||
.expect("error while building Tessera desktop application");
|
||
|
||
app.run(|_app, event| {
|
||
// Fenster schliessen setzt code auf None -- App laeuft dann im
|
||
// Infobereich weiter (siehe on_window_event). Der Tray-Eintrag
|
||
// "Beenden" ruft dagegen app.exit(0), das liefert code: Some --
|
||
// dieser Fall muss durchgelassen werden, sonst bleibt der Prozess
|
||
// samt Tray-Symbol haengen.
|
||
if let RunEvent::ExitRequested {
|
||
code: None, api, ..
|
||
} = event
|
||
{
|
||
api.prevent_exit();
|
||
}
|
||
});
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn with_client_marker_ohne_pfad_haengt_query_an() {
|
||
let url = tauri::Url::parse("https://tessera.example.com").unwrap();
|
||
let marked = with_client_marker(&url, "1.2.0", "a6d1a64", "windows");
|
||
assert_eq!(
|
||
marked.as_str(),
|
||
"https://tessera.example.com/?desktop=1&dv=1.2.0&dc=a6d1a64&dos=windows"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn with_client_marker_mit_vorhandenem_query_haengt_an() {
|
||
let url = tauri::Url::parse("https://host/app?x=1").unwrap();
|
||
let marked = with_client_marker(&url, "1.2.0", "a6d1a64", "windows");
|
||
assert_eq!(
|
||
marked.as_str(),
|
||
"https://host/app?x=1&desktop=1&dv=1.2.0&dc=a6d1a64&dos=windows"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn with_client_marker_laesst_original_unveraendert() {
|
||
let url = tauri::Url::parse("https://host/app").unwrap();
|
||
let _ = with_client_marker(&url, "1.2.0", "a6d1a64", "windows");
|
||
assert_eq!(url.query(), None);
|
||
}
|
||
|
||
#[test]
|
||
fn with_client_marker_leerer_commit_bleibt_als_leeres_paar_erhalten() {
|
||
let url = tauri::Url::parse("https://host/app").unwrap();
|
||
let marked = with_client_marker(&url, "1.2.0", "", "linux");
|
||
assert_eq!(
|
||
marked.as_str(),
|
||
"https://host/app?desktop=1&dv=1.2.0&dc=&dos=linux"
|
||
);
|
||
|
||
let marked_whitespace = with_client_marker(&url, "1.2.0", " ", "linux");
|
||
assert_eq!(
|
||
marked_whitespace.as_str(),
|
||
"https://host/app?desktop=1&dv=1.2.0&dc=&dos=linux"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn with_desktop_marker_haengt_alle_vier_paare_an() {
|
||
let url = tauri::Url::parse("https://host/app").unwrap();
|
||
let marked = with_desktop_marker(&url);
|
||
let pairs: Vec<(String, String)> = marked
|
||
.query_pairs()
|
||
.map(|(k, v)| (k.to_string(), v.to_string()))
|
||
.collect();
|
||
assert!(pairs.contains(&("desktop".to_string(), "1".to_string())));
|
||
assert!(pairs.contains(&("dv".to_string(), env!("CARGO_PKG_VERSION").to_string())));
|
||
assert!(pairs.contains(&("dos".to_string(), std::env::consts::OS.to_string())));
|
||
assert!(pairs.iter().any(|(k, _)| k == "dc"));
|
||
}
|
||
|
||
#[test]
|
||
fn update_labels_bei_versionswechsel() {
|
||
let (menu_text, body) = update_labels(true, "1.2.0", "abc1234");
|
||
assert_eq!(menu_text, "Auf Version 1.2.0 aktualisieren");
|
||
assert_eq!(
|
||
body,
|
||
"Neue Version 1.2.0 verfügbar – Aktualisieren über das Symbol im Infobereich."
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn client_info_label_mit_stempel() {
|
||
assert_eq!(
|
||
client_info_label("1.2.0", "7479cb4"),
|
||
"Tessera-App 1.2.0 · Stand 7479cb4"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn client_info_label_ohne_stempel() {
|
||
assert_eq!(client_info_label("1.2.0", ""), "Tessera-App 1.2.0");
|
||
assert_eq!(client_info_label("1.2.0", " "), "Tessera-App 1.2.0");
|
||
}
|
||
|
||
#[test]
|
||
fn update_labels_bei_gleicher_version_beta_commit() {
|
||
let (menu_text, body) = update_labels(false, "1.1.0", "abc1234");
|
||
assert_eq!(menu_text, "Auf Beta-Stand abc1234 aktualisieren");
|
||
assert_eq!(
|
||
body,
|
||
"Neuer Beta-Stand abc1234 verfügbar – Aktualisieren über das Symbol im Infobereich."
|
||
);
|
||
}
|
||
|
||
fn v(s: &str) -> Version {
|
||
Version::parse(s).unwrap()
|
||
}
|
||
|
||
#[test]
|
||
fn is_update_newer_hoehere_basis_live() {
|
||
assert!(is_update_newer(&v("1.2.0"), "abc1234", &v("1.3.0")));
|
||
}
|
||
|
||
#[test]
|
||
fn is_update_newer_hoehere_basis_beta() {
|
||
assert!(is_update_newer(
|
||
&v("1.2.0"),
|
||
"abc1234",
|
||
&v("1.3.0-beta.gdef5678")
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn is_update_newer_niedrigere_basis() {
|
||
assert!(!is_update_newer(
|
||
&v("1.3.0"),
|
||
"abc1234",
|
||
&v("1.2.0-beta.g9999999")
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn is_update_newer_gleiche_basis_anderer_beta_stempel() {
|
||
assert!(is_update_newer(
|
||
&v("1.2.0"),
|
||
"abc1234",
|
||
&v("1.2.0-beta.gdef5678")
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn is_update_newer_gleiche_basis_gleicher_beta_stempel() {
|
||
assert!(!is_update_newer(
|
||
&v("1.2.0"),
|
||
"abc1234",
|
||
&v("1.2.0-beta.gabc1234")
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn is_update_newer_gleiche_basis_live_ohne_prerelease() {
|
||
assert!(!is_update_newer(&v("1.2.0"), "abc1234", &v("1.2.0")));
|
||
}
|
||
|
||
#[test]
|
||
fn is_update_newer_leerer_client_commit_nur_basisvergleich() {
|
||
assert!(!is_update_newer(&v("1.2.0"), "", &v("1.2.0-beta.gdef5678")));
|
||
}
|
||
|
||
#[test]
|
||
fn is_update_newer_fremdes_prerelease_format() {
|
||
assert!(!is_update_newer(&v("1.2.0"), "abc1234", &v("1.2.0-rc.1")));
|
||
}
|
||
|
||
#[test]
|
||
fn beta_commit_mit_praefix_g_erlaubt_fuehrende_null() {
|
||
assert!(Version::parse("1.2.0-beta.g0123456").is_ok());
|
||
assert_eq!(beta_commit(&v("1.2.0-beta.g0123456")), Some("0123456"));
|
||
}
|
||
|
||
#[test]
|
||
fn beta_commit_none_ohne_prerelease() {
|
||
assert_eq!(beta_commit(&v("1.2.0")), None);
|
||
}
|
||
|
||
#[test]
|
||
fn beta_commit_none_bei_fremdem_prerelease() {
|
||
assert_eq!(beta_commit(&v("1.2.0-rc.1")), None);
|
||
}
|
||
|
||
#[test]
|
||
fn update_endpoint_traegt_platzhalter_und_base() {
|
||
assert_eq!(
|
||
update_endpoint("https://tessera.ctl.de/").unwrap().as_str(),
|
||
"https://tessera.ctl.de/api-proxy/desktop/update?target=%7B%7Btarget%7D%7D&arch=%7B%7Barch%7D%7D¤t=%7B%7Bcurrent_version%7D%7D&base=https%3A%2F%2Ftessera.ctl.de"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn update_endpoint_none_bei_unparsbarer_adresse() {
|
||
assert!(update_endpoint("kein url").is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn release_labels_neue_basis_nennt_version_auch_bei_beta() {
|
||
let (menu_text, _) = release_labels(&v("1.2.0"), &v("1.3.0-beta.gdef5678"));
|
||
assert_eq!(menu_text, "Auf Version 1.3.0 aktualisieren");
|
||
}
|
||
|
||
#[test]
|
||
fn release_labels_gleiche_basis_nennt_beta_stempel() {
|
||
let (menu_text, _) = release_labels(&v("1.2.0"), &v("1.2.0-beta.gdef5678"));
|
||
assert_eq!(menu_text, "Auf Beta-Stand def5678 aktualisieren");
|
||
}
|
||
|
||
#[test]
|
||
fn server_host_mit_pfad_und_ohne_port() {
|
||
assert_eq!(
|
||
server_host(Some("https://tessera.ctl.de/")),
|
||
Some("tessera.ctl.de".to_string())
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn server_host_mit_nicht_standard_port() {
|
||
assert_eq!(
|
||
server_host(Some("http://localhost:3000/")),
|
||
Some("localhost:3000".to_string())
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn server_host_laesst_standardport_pfad_und_query_weg() {
|
||
assert_eq!(
|
||
server_host(Some("https://host:443/pfad?x=1")),
|
||
Some("host".to_string())
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn server_host_none_bei_none() {
|
||
assert_eq!(server_host(None), None);
|
||
}
|
||
|
||
#[test]
|
||
fn server_host_none_bei_leerem_string() {
|
||
assert_eq!(server_host(Some("")), None);
|
||
}
|
||
|
||
#[test]
|
||
fn server_host_none_bei_unparsbarer_adresse() {
|
||
assert_eq!(server_host(Some("kein url")), None);
|
||
}
|
||
|
||
#[test]
|
||
fn tray_labels_mit_host() {
|
||
let (tooltip, line) = tray_labels(Some("https://tessera.ctl.de/"));
|
||
assert_eq!(tooltip, "Tessera – tessera.ctl.de");
|
||
assert_eq!(line, "Verbunden mit tessera.ctl.de");
|
||
}
|
||
|
||
#[test]
|
||
fn tray_labels_ohne_adresse() {
|
||
let (tooltip, line) = tray_labels(None);
|
||
assert_eq!(tooltip, "Tessera – nicht verbunden");
|
||
assert_eq!(line, "Nicht verbunden");
|
||
}
|
||
|
||
#[test]
|
||
fn setup_page_url_unter_windows() {
|
||
assert_eq!(
|
||
setup_page_url(true).as_str(),
|
||
"http://tauri.localhost/setup.html"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn setup_page_url_unter_linux() {
|
||
assert_eq!(
|
||
setup_page_url(false).as_str(),
|
||
"tauri://localhost/setup.html"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn saves_in_app_nur_blob_und_data() {
|
||
for (url, expected) in [
|
||
("blob:http://localhost:3000/0c1d-11", true),
|
||
("data:application/x-pem-file;base64,QUJD", true),
|
||
("https://alpha.tessera.ctl.de/desktop/download/x.exe", false),
|
||
("http://intranet.local/datei.pdf", false),
|
||
] {
|
||
let parsed = tauri::Url::parse(url).unwrap();
|
||
assert_eq!(saves_in_app(&parsed), expected, "{url}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn download_notice_nennt_datei_und_ordner() {
|
||
let path = std::path::Path::new("/home/tessera/Downloads/www.example.de.crt");
|
||
let (title, body) = download_notice(Some(path), true);
|
||
assert_eq!(title, "Download gespeichert");
|
||
assert_eq!(body, "„www.example.de.crt“ liegt im Ordner Downloads.");
|
||
|
||
let (title, _) = download_notice(None, false);
|
||
assert_eq!(title, "Download fehlgeschlagen");
|
||
|
||
let (_, body) = download_notice(None, true);
|
||
assert_eq!(body, "Die Datei wurde gespeichert.");
|
||
}
|
||
|
||
#[test]
|
||
fn external_target_nur_http_und_https() {
|
||
let https = tauri::Url::parse("https://www.google.com/search?q=tessera").unwrap();
|
||
assert_eq!(
|
||
external_target(&https).as_deref(),
|
||
Some("https://www.google.com/search?q=tessera")
|
||
);
|
||
let http = tauri::Url::parse("http://intranet.local/").unwrap();
|
||
assert_eq!(
|
||
external_target(&http).as_deref(),
|
||
Some("http://intranet.local/")
|
||
);
|
||
for bad in [
|
||
"file:///C:/Windows/system32/calc.exe",
|
||
"javascript:alert(1)",
|
||
"ms-settings:privacy",
|
||
] {
|
||
let url = tauri::Url::parse(bad).unwrap();
|
||
assert_eq!(external_target(&url), None, "{bad}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn parse_server_url_gueltige_adresse() {
|
||
let parsed = parse_server_url("https://tessera.ctl.de").unwrap();
|
||
assert_eq!(parsed.as_str(), "https://tessera.ctl.de/");
|
||
}
|
||
|
||
#[test]
|
||
fn parse_server_url_lehnt_fremdes_schema_ab() {
|
||
assert_eq!(
|
||
parse_server_url("ftp://host"),
|
||
Err("Es sind nur Adressen mit http oder https erlaubt.".to_string())
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn parse_server_url_lehnt_unparsbare_adresse_ab() {
|
||
assert_eq!(
|
||
parse_server_url("kein url"),
|
||
Err("Diese Adresse ist ungültig.".to_string())
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn check_failure_labels_401_nennt_proxy_passwortschutz() {
|
||
let (menu_text, body) = check_failure_labels(Some(401));
|
||
assert!(menu_text.contains("HTTP 401"));
|
||
assert!(menu_text.ends_with("– erneut prüfen"));
|
||
assert!(body.contains("HTTP 401"));
|
||
assert!(body.contains("Passwortschutz"));
|
||
assert!(body.contains("Zugriffsliste"));
|
||
}
|
||
|
||
#[test]
|
||
fn check_failure_labels_403_gleiche_erklaerung() {
|
||
let (menu_text, body) = check_failure_labels(Some(403));
|
||
assert!(menu_text.contains("HTTP 403"));
|
||
assert!(body.contains("HTTP 403"));
|
||
assert!(body.contains("Passwortschutz"));
|
||
}
|
||
|
||
#[test]
|
||
fn check_failure_labels_anderer_status_ohne_proxy_erklaerung() {
|
||
let (menu_text, body) = check_failure_labels(Some(502));
|
||
assert!(menu_text.contains("HTTP 502"));
|
||
assert!(menu_text.ends_with("– erneut prüfen"));
|
||
assert!(!body.contains("Passwortschutz"));
|
||
assert!(body.contains("statt mit Paketdaten"));
|
||
}
|
||
|
||
#[test]
|
||
fn check_failure_labels_ohne_status_keine_verbindung() {
|
||
let (menu_text, body) = check_failure_labels(None);
|
||
assert!(menu_text.contains("keine Verbindung"));
|
||
assert!(menu_text.ends_with("– erneut prüfen"));
|
||
assert!(body.contains("vier Stunden"));
|
||
}
|
||
|
||
#[test]
|
||
fn diagnostic_update_url_ersetzt_platzhalter_und_kodiert_base() {
|
||
assert_eq!(
|
||
diagnostic_update_url("https://alpha.example", "windows", "x86_64", "1.2.0"),
|
||
"https://alpha.example/api-proxy/desktop/update?target=windows&arch=x86_64¤t=1.2.0&base=https%3A%2F%2Falpha.example"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn diagnostic_update_url_trimmt_schlussstrich() {
|
||
assert_eq!(
|
||
diagnostic_update_url("https://alpha.example/", "linux", "x86_64", "1.2.0"),
|
||
"https://alpha.example/api-proxy/desktop/update?target=linux&arch=x86_64¤t=1.2.0&base=https%3A%2F%2Falpha.example"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn update_item_konstanten() {
|
||
assert_eq!(UPDATE_ITEM_CHECKING, "Suche nach Updates…");
|
||
assert_eq!(UPDATE_ITEM_NONE, "Kein Update verfügbar – erneut prüfen");
|
||
assert_eq!(UPDATE_ITEM_INSECURE, "Update nur über https möglich");
|
||
assert_eq!(UPDATE_CHECK_INTERVAL, Duration::from_secs(4 * 3600));
|
||
}
|
||
|
||
// --- UpdateFlow ---
|
||
|
||
#[test]
|
||
fn update_flow_nur_eine_installation_gleichzeitig() {
|
||
let flow = UpdateFlow::default();
|
||
assert!(!flow.is_installing());
|
||
assert!(flow.try_begin_install());
|
||
assert!(flow.is_installing());
|
||
assert!(
|
||
!flow.try_begin_install(),
|
||
"zweite Installation muss abgewiesen werden"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn update_flow_fehler_gibt_installation_frei() {
|
||
let flow = UpdateFlow::default();
|
||
assert!(flow.try_begin_install());
|
||
flow.install_failed();
|
||
assert!(!flow.is_installing());
|
||
assert!(flow.try_begin_install());
|
||
}
|
||
|
||
#[test]
|
||
fn update_flow_klick_installiert_bei_abgelegtem_stand() {
|
||
let flow = UpdateFlow::default();
|
||
assert!(flow.install_on_click(true));
|
||
assert!(!flow.install_on_click(false));
|
||
}
|
||
|
||
#[test]
|
||
fn update_flow_klick_nach_angebot_und_fehlgeschlagener_pruefung_installiert() {
|
||
let flow = UpdateFlow::default();
|
||
flow.set_offered(true);
|
||
// Stand wurde vom ersten Klick entnommen, frische Pruefung schlug fehl.
|
||
assert!(flow.install_on_click(false));
|
||
// "Kein Update" oder Serverwechsel leert das Angebot.
|
||
flow.set_offered(false);
|
||
assert!(!flow.install_on_click(false));
|
||
}
|
||
|
||
// --- grant_origin_once ---
|
||
|
||
#[test]
|
||
fn grant_origin_once_vermerkt_erst_nach_erfolg() {
|
||
let granted = Mutex::new(Vec::new());
|
||
let r: Result<(), &str> = grant_origin_once(&granted, "https://a", |_| Err("kaputt"));
|
||
assert_eq!(r, Err("kaputt"));
|
||
assert!(granted.lock().unwrap().is_empty());
|
||
|
||
let mut calls = 0;
|
||
let r: Result<(), &str> = grant_origin_once(&granted, "https://a", |p| {
|
||
calls += 1;
|
||
assert_eq!(p, "https://a");
|
||
Ok(())
|
||
});
|
||
assert_eq!(r, Ok(()));
|
||
assert_eq!(calls, 1, "nach einem Fehler muss ein neuer Versuch laufen");
|
||
assert_eq!(*granted.lock().unwrap(), vec!["https://a".to_string()]);
|
||
}
|
||
|
||
#[test]
|
||
fn grant_origin_once_ruft_bei_vermerktem_ursprung_nicht_erneut() {
|
||
let granted = Mutex::new(vec!["https://a".to_string()]);
|
||
let r: Result<(), &str> =
|
||
grant_origin_once(&granted, "https://a", |_| panic!("darf nicht laufen"));
|
||
assert_eq!(r, Ok(()));
|
||
let r: Result<(), &str> = grant_origin_once(&granted, "https://b", |_| Ok(()));
|
||
assert_eq!(r, Ok(()));
|
||
assert_eq!(granted.lock().unwrap().len(), 2);
|
||
}
|
||
// --- server_origin_* (quick-260929-if2, E-01) ---
|
||
|
||
fn muster(p: &str) -> tauri::utils::acl::RemoteUrlPattern {
|
||
p.parse().expect("Muster muss zerlegbar sein")
|
||
}
|
||
|
||
fn adresse(s: &str) -> tauri::Url {
|
||
tauri::Url::parse(s).expect("Testadresse muss gueltig sein")
|
||
}
|
||
|
||
#[test]
|
||
fn server_origin_entfernt_pfad_query_und_fragment() {
|
||
assert_eq!(
|
||
server_origin_pattern("https://alpha.tessera.ctl.de/dashboard?x=1#h").as_deref(),
|
||
Some("https://alpha.tessera.ctl.de")
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn server_origin_behaelt_nicht_standard_port() {
|
||
assert_eq!(
|
||
server_origin_pattern("http://192.168.13.12:8080/").as_deref(),
|
||
Some("http://192.168.13.12:8080")
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn server_origin_laesst_standardport_weg() {
|
||
assert_eq!(
|
||
server_origin_pattern("https://alpha.tessera.ctl.de:443/").as_deref(),
|
||
Some("https://alpha.tessera.ctl.de")
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn server_origin_mit_und_ohne_schlussstrich_gleich() {
|
||
assert_eq!(
|
||
server_origin_pattern("https://tessera.example.de/"),
|
||
server_origin_pattern("https://tessera.example.de")
|
||
);
|
||
assert!(server_origin_pattern("https://tessera.example.de").is_some());
|
||
}
|
||
|
||
#[test]
|
||
fn server_origin_none_bei_fremdem_schema_und_unparsbarer_eingabe() {
|
||
assert_eq!(server_origin_pattern("ftp://tessera.example.de/"), None);
|
||
assert_eq!(server_origin_pattern("das ist keine adresse"), None);
|
||
assert_eq!(server_origin_pattern(""), None);
|
||
}
|
||
|
||
#[test]
|
||
fn server_origin_ipv6_wird_maskiert() {
|
||
assert_eq!(
|
||
server_origin_pattern("http://[::1]:8080/").as_deref(),
|
||
Some(r"http://\[\:\:1\]:8080")
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn server_origin_platzhalter_host_wird_maskiert() {
|
||
assert_eq!(
|
||
server_origin_pattern("http://*.example.com/").as_deref(),
|
||
Some(r"http://\*.example.com")
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn server_origin_trifft_eigenen_ursprung_mit_anderem_pfad_und_query() {
|
||
let p = muster(&server_origin_pattern("https://alpha.tessera.ctl.de/x").unwrap());
|
||
assert!(p.test(&adresse("https://alpha.tessera.ctl.de/")));
|
||
assert!(p.test(&adresse("https://alpha.tessera.ctl.de/dashboard?a=b#c")));
|
||
}
|
||
|
||
#[test]
|
||
fn server_origin_trifft_kein_anderes_schema_port_subdomain_oder_host() {
|
||
let p = muster(&server_origin_pattern("https://alpha.tessera.ctl.de/").unwrap());
|
||
assert!(!p.test(&adresse("http://alpha.tessera.ctl.de/")));
|
||
assert!(!p.test(&adresse("https://alpha.tessera.ctl.de:8443/")));
|
||
assert!(!p.test(&adresse("https://x.alpha.tessera.ctl.de/")));
|
||
assert!(!p.test(&adresse("https://beta.tessera.ctl.de/")));
|
||
|
||
let mit_port = muster(&server_origin_pattern("http://192.168.13.12:8080/").unwrap());
|
||
assert!(mit_port.test(&adresse("http://192.168.13.12:8080/a")));
|
||
assert!(!mit_port.test(&adresse("http://192.168.13.12:8081/")));
|
||
assert!(!mit_port.test(&adresse("http://192.168.13.12/")));
|
||
}
|
||
|
||
#[test]
|
||
fn server_origin_platzhalter_host_trifft_keine_subdomain() {
|
||
let p = muster(&server_origin_pattern("http://*.example.com/").unwrap());
|
||
assert!(!p.test(&adresse("http://a.example.com/")));
|
||
}
|
||
|
||
#[test]
|
||
fn server_origin_ipv6_trifft_nur_die_eigene_adresse() {
|
||
let p = muster(&server_origin_pattern("http://[::1]:8080/").unwrap());
|
||
assert!(p.test(&adresse("http://[::1]:8080/dashboard")));
|
||
assert!(!p.test(&adresse("http://[::2]:8080/")));
|
||
assert!(!p.test(&adresse("http://[::1]/")));
|
||
}
|
||
|
||
#[test]
|
||
fn server_origin_berechtigungen_sind_genau_diese_drei() {
|
||
// Pinnt die Menge (T-IF2-03): ein zusaetzliches `notification:default`
|
||
// oder eine Platzhalter-Berechtigung laesst den Test rot werden.
|
||
assert_eq!(
|
||
SERVER_NOTIFICATION_PERMISSIONS,
|
||
[
|
||
"notification:allow-notify",
|
||
"notification:allow-is-permission-granted",
|
||
"notification:allow-request-permission",
|
||
]
|
||
);
|
||
}
|
||
}
|