Files
tessera-ctl/apps/desktop/src-tauri/src/lib.rs
T
schalli dfc4e9b781
Tessera CI/CD / Lint & Type Check (push) Successful in 2m38s
Tessera CI/CD / Tests (push) Failing after 1m26s
Tessera CI/CD / Desktop-Pakete bauen (push) Has been skipped
Tessera CI/CD / Build & Publish Images (push) Has been skipped
fix(desktop): Downloads aus der Seite (blob/data) in der App speichern
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>
2026-10-01 15:28:59 +02:00

1875 lines
76 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use semver::Version;
use std::sync::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(&current, current_commit, &release.version)
})
.build()?
.check()
.await
}
/// Meldet eine fehlgeschlagene Update-Pruefung: Menuetext mit Grund, Eintrag
/// wieder anklickbar (Klick = erneut pruefen), Benachrichtigung nur, wenn
/// der Text vom zuletzt gemeldeten abweicht (`LastCheckNotice`, T-FRG-02).
fn report_check_failure(app: &AppHandle, update_item: &MenuItem<Wry>, status: Option<u16>) {
let (menu_text, body) = check_failure_labels(status);
let _ = update_item.set_text(&menu_text);
let _ = update_item.set_enabled(true);
let changed = app
.state::<LastCheckNotice>()
.0
.lock()
.map(|mut last| {
if *last == body {
false
} else {
*last = body.clone();
true
}
})
.unwrap_or(true);
if changed {
let _ = app
.notification()
.builder()
.title("Tessera-Update")
.body(body)
.show();
}
}
/// Leert den zuletzt gemeldeten Fehlertext -- nach einer gelungenen Pruefung
/// soll ein spaeterer Fehler wieder gemeldet werden.
fn clear_check_notice(app: &AppHandle) {
if let Ok(mut last) = app.state::<LastCheckNotice>().0.lock() {
last.clear();
}
}
/// Versionsprüfung, aus `setup` herausgezogen, damit sie nach einem
/// Serverwechsel, beim Klick auf den Eintrag und alle
/// `UPDATE_CHECK_INTERVAL` erneut laufen kann (async, non-blocking wie
/// bisher). Setzt den Update-Eintrag zuerst auf "Suche nach Updates…"
/// (gesperrt) und leert den abgelegten Update-Stand -- nach einem Wechsel
/// darf kein Hinweis des alten Servers stehen bleiben. Jeder Ausgang der
/// Pruefung endet in einem sichtbaren, anklickbaren Zustand (Update gefunden
/// / kein Update / fehlgeschlagen mit Grund); nur der http-Fall bleibt
/// gesperrt. Ein fehlgeschlagener Check war bisher vom Zustand "kein Update"
/// nicht unterscheidbar (quick-260922-frg).
///
/// `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(&current, &release))
.unwrap_or_else(|_| update_labels(true, &update.version, ""));
// 15 s reichen fuer die Pruefung, nicht fuer den ~100-MB-Download:
// das Plugin wendet `Update.timeout` als Gesamt-Timeout des
// Downloads an (updater.rs Z. 698-700).
update.timeout = Some(Duration::from_secs(600));
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&current=%7B%7Bcurrent_version%7D%7D&base=https%3A%2F%2Ftessera.ctl.de"
);
}
#[test]
fn update_endpoint_none_bei_unparsbarer_adresse() {
assert!(update_endpoint("kein url").is_none());
}
#[test]
fn release_labels_neue_basis_nennt_version_auch_bei_beta() {
let (menu_text, _) = release_labels(&v("1.2.0"), &v("1.3.0-beta.gdef5678"));
assert_eq!(menu_text, "Auf Version 1.3.0 aktualisieren");
}
#[test]
fn release_labels_gleiche_basis_nennt_beta_stempel() {
let (menu_text, _) = release_labels(&v("1.2.0"), &v("1.2.0-beta.gdef5678"));
assert_eq!(menu_text, "Auf Beta-Stand def5678 aktualisieren");
}
#[test]
fn server_host_mit_pfad_und_ohne_port() {
assert_eq!(
server_host(Some("https://tessera.ctl.de/")),
Some("tessera.ctl.de".to_string())
);
}
#[test]
fn server_host_mit_nicht_standard_port() {
assert_eq!(
server_host(Some("http://localhost:3000/")),
Some("localhost:3000".to_string())
);
}
#[test]
fn server_host_laesst_standardport_pfad_und_query_weg() {
assert_eq!(
server_host(Some("https://host:443/pfad?x=1")),
Some("host".to_string())
);
}
#[test]
fn server_host_none_bei_none() {
assert_eq!(server_host(None), None);
}
#[test]
fn server_host_none_bei_leerem_string() {
assert_eq!(server_host(Some("")), None);
}
#[test]
fn server_host_none_bei_unparsbarer_adresse() {
assert_eq!(server_host(Some("kein url")), None);
}
#[test]
fn tray_labels_mit_host() {
let (tooltip, line) = tray_labels(Some("https://tessera.ctl.de/"));
assert_eq!(tooltip, "Tessera – tessera.ctl.de");
assert_eq!(line, "Verbunden mit tessera.ctl.de");
}
#[test]
fn tray_labels_ohne_adresse() {
let (tooltip, line) = tray_labels(None);
assert_eq!(tooltip, "Tessera – nicht verbunden");
assert_eq!(line, "Nicht verbunden");
}
#[test]
fn setup_page_url_unter_windows() {
assert_eq!(
setup_page_url(true).as_str(),
"http://tauri.localhost/setup.html"
);
}
#[test]
fn setup_page_url_unter_linux() {
assert_eq!(
setup_page_url(false).as_str(),
"tauri://localhost/setup.html"
);
}
#[test]
fn 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&current=1.2.0&base=https%3A%2F%2Falpha.example"
);
}
#[test]
fn diagnostic_update_url_trimmt_schlussstrich() {
assert_eq!(
diagnostic_update_url("https://alpha.example/", "linux", "x86_64", "1.2.0"),
"https://alpha.example/api-proxy/desktop/update?target=linux&arch=x86_64&current=1.2.0&base=https%3A%2F%2Falpha.example"
);
}
#[test]
fn update_item_konstanten() {
assert_eq!(UPDATE_ITEM_CHECKING, "Suche nach Updates…");
assert_eq!(UPDATE_ITEM_NONE, "Kein Update verfügbar – erneut prüfen");
assert_eq!(UPDATE_ITEM_INSECURE, "Update nur über https möglich");
assert_eq!(UPDATE_CHECK_INTERVAL, Duration::from_secs(4 * 3600));
}
// --- 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",
]
);
}
}