feat(desktop): Update in der App — Herunterladen, Installieren und Neustart per Klick im Infobereich (tauri-plugin-updater)

- tauri-plugin-updater 2.11 + semver eingebunden; pubkey, installMode passive
  und createUpdaterArtifacts in tauri.conf.json (kein endpoints, keine
  dangerous*-Schalter)
- Versionspruefung laeuft ueber updater_builder().check() gegen
  /api-proxy/desktop/update mit eigenem version_comparator (is_update_newer:
  hoehere Basis oder anderer Beta-Stempel beta.g<sha7>; gleiche Basis Live
  → kein Update); 15 s Pruefung, 600 s Download-Timeout
- Tray-Eintrag "Update installieren" laedt mit Fortschritt im Menuetext,
  prueft die minisign-Signatur, installiert (Windows NSIS passiv, Linux
  AppImage an Ort und Stelle) und startet neu; Fehler → Benachrichtigung
  und Rueckfall auf die Einstellungsseite im Browser
- http-Server im Release: Menuetext "Update nur über https möglich", gesperrt
- 15 neue Tests, 2 umgestellt (33 gesamt); gen/schemas vom Bau nachgezogen

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-17 15:30:28 +02:00
parent 5a444ec8f2
commit 678ba51725
7 changed files with 806 additions and 68 deletions
+376 -61
View File
@@ -1,3 +1,5 @@
use semver::Version;
use std::sync::Mutex;
use std::time::Duration;
use tauri::{
menu::{CheckMenuItemBuilder, MenuBuilder, MenuItem, MenuItemBuilder},
@@ -8,24 +10,32 @@ 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};
/// Standardtext des Update-Eintrags im Tray-Menü, bevor eine neuere Version
/// gefunden wurde. `spawn_version_check` setzt den Eintrag nach einem
/// Serverwechsel hierauf zurück, damit kein Hinweis des alten Servers stehen
/// bleibt.
const UPDATE_ITEM_DEFAULT: &str = "Update herunterladen";
/// gefunden wurde. Der Eintrag installiert das Update seit quick-260917-kgc
/// direkt in der App (Download, Signaturpruefung, Installation, Neustart);
/// den Browser oeffnet er nur noch als Rueckfall. `spawn_version_check`
/// setzt den Eintrag nach einem Serverwechsel hierauf zurück, damit kein
/// Hinweis des alten Servers stehen bleibt.
const UPDATE_ITEM_DEFAULT: &str = "Update installieren";
/// 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,
}
#[derive(serde::Deserialize)]
struct DesktopLatest {
version: String,
channel: String,
commit: 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)>>);
/// Baut die Adresse eines API-Pfads aus der gespeicherten Server-Adresse.
/// Die API ist vom Client nur ueber den Web-Ursprung erreichbar
@@ -41,10 +51,10 @@ fn api_url(server: &str, path: &str) -> String {
/// NUR in die Navigation, niemals in den gespeicherten Server-Wert
/// (`server_url` im Store bleibt die reine Adresse); daraus liest die
/// Next.js-Middleware (apps/web/src/middleware.ts) das Cookie
/// `tessera_desktop`, an dem der Web-Client den Desktop-Client erkennt. Die
/// Tray-URL "Update herunterladen" bekommt den Parameter bewusst NICHT --
/// sie oeffnet im System-Browser, dort muessen die Download-Links sichtbar
/// bleiben.
/// `tessera_desktop`, an dem der Web-Client den Desktop-Client erkennt. Der
/// Browser-Rueckfall des Tray-Eintrags "Update installieren"
/// (`open_download_page`) bekommt den Parameter bewusst NICHT -- er oeffnet
/// im System-Browser, dort muessen die Download-Links sichtbar bleiben.
fn with_desktop_marker(url: &tauri::Url) -> tauri::Url {
let mut marked = url.clone();
marked.query_pairs_mut().append_pair("desktop", "1");
@@ -54,29 +64,105 @@ fn with_desktop_marker(url: &tauri::Url) -> tauri::Url {
/// 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 ("Version 1.1.0 herunterladen") verwirrend, weil der Client
/// bereits genau diese Version hat. Der zweite Zweig nennt darum den
/// Commit-Stempel statt der unveraenderten Version.
/// 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!("Version {} herunterladen", version),
format!("Auf Version {} aktualisieren", version),
format!(
"Neue Version {} verfügbar – Download über das Symbol im Infobereich.",
"Neue Version {} verfügbar – Aktualisieren über das Symbol im Infobereich.",
version
),
)
} else {
(
"Neuen Beta-Stand herunterladen".to_string(),
format!("Auf Beta-Stand {} aktualisieren", commit),
format!(
"Neuer Beta-Stand {} verfügbar – Download über das Symbol im Infobereich.",
"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)
}
/// 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)
}
/// 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;
@@ -174,43 +260,159 @@ fn apply_server(app: &AppHandle, url: Option<&str>) {
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
}
/// Versionsprüfung, aus `setup` herausgezogen, damit sie nach einem
/// Serverwechsel erneut laufen kann (async, non-blocking wie bisher). Setzt
/// den Update-Eintrag zuerst auf den Standardtext zurück -- nach einem
/// Wechsel darf kein Hinweis des alten Servers stehen bleiben.
/// den Update-Eintrag zuerst auf den Standardtext zurück und leert den
/// abgelegten Update-Stand -- nach einem Wechsel darf kein Hinweis des alten
/// Servers stehen bleiben.
fn spawn_version_check(app: AppHandle, server_url: String) {
let update_item = app.state::<TrayItems>().update.clone();
let _ = update_item.set_text(UPDATE_ITEM_DEFAULT);
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");
let app_version = env!("CARGO_PKG_VERSION").to_string();
let app_commit = env!("APP_COMMIT").to_string();
tauri::async_runtime::spawn(async move {
let url = api_url(&server_url, "/desktop/latest");
if let Ok(resp) = reqwest::get(&url).await {
if let Ok(info) = resp.json::<DesktopLatest>().await {
// Beta-Kanal (main) vergibt jedem Commit dieselbe X.Y.Z-Version
// (D-07, desktop-collect.sh) -- ohne den Commit-Vergleich saehe
// ein Beta-Client zwischen zwei Freigabe-Tags nie einen neueren
// Bau (WR-02, Code-Review Phase 18). Fuer den Live-Kanal bleibt
// es beim reinen Versionsvergleich. Die Texte fuer Menue und
// Benachrichtigung liefert `update_labels`.
let version_changed = info.version != app_version;
let is_newer =
version_changed || (info.channel == "beta" && info.commit != app_commit);
if is_newer {
let (menu_text, body) =
update_labels(version_changed, &info.version, &info.commit);
let _ = app
.notification()
.builder()
.title("Tessera-Update")
.body(body)
.show();
let _ = update_item.set_text(menu_text);
let _ = update_item.set_enabled(true);
match check_for_update(&app, endpoint).await {
Ok(Some(mut update)) => {
let (menu_text, body) = Version::parse(&update.version)
.map(|release| release_labels(&current, &release))
.unwrap_or_else(|_| update_labels(true, &update.version, ""));
// 15 s reichen fuer die Pruefung, nicht fuer den ~100-MB-Download:
// das Plugin wendet `Update.timeout` als Gesamt-Timeout des
// Downloads an (updater.rs Z. 698-700).
update.timeout = Some(Duration::from_secs(600));
let _ = app
.notification()
.builder()
.title("Tessera-Update")
.body(body)
.show();
let _ = update_item.set_text(&menu_text);
let _ = update_item.set_enabled(true);
if let Ok(mut pending) = app.state::<PendingUpdate>().0.lock() {
*pending = Some((update, menu_text));
}
}
Ok(None) => {}
// 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);
}
Err(_) => {}
}
});
}
/// Oeffnet die Seite Einstellungen → Desktop-App im System-Browser -- der
/// bisherige Weg des Tray-Eintrags, jetzt Rueckfall, wenn kein Update
/// abgelegt ist oder die Installation in der App fehlschlaegt. Bewusst ohne
/// `desktop=1` (siehe `with_desktop_marker`).
fn open_download_page(app: &AppHandle) {
if let Some(server) = stored_server_url(app) {
let target = format!("{}/settings/general/desktop", server.trim_end_matches('/'));
let _ = app.opener().open_url(target, None::<&str>);
}
}
/// Laedt das abgelegte Update, prueft die Signatur, installiert es und
/// startet die App neu. Fortschritt und Zustand stehen im Menuetext des
/// gesperrten Eintrags. Fehler: Menuetext und Stand zurueck, Eintrag wieder
/// aktiv, Benachrichtigung mit Grund, Einstellungsseite im Browser als
/// Rueckfall.
fn spawn_update_install(app: AppHandle, update: Update, menu_text: String) {
let item = app.state::<TrayItems>().update.clone();
let _ = item.set_enabled(false);
let _ = item.set_text("Update wird heruntergeladen…");
let _ = app
.notification()
.builder()
.title("Tessera-Update")
.body("Update wird heruntergeladen…")
.show();
tauri::async_runtime::spawn(async move {
let item_progress = item.clone();
let item_finish = item.clone();
let mut received: u64 = 0;
let mut last_percent: Option<u64> = None;
let on_chunk = move |chunk: usize, total: Option<u64>| {
received += chunk as u64;
if let Some(total) = total.filter(|t| *t > 0) {
let percent = (received * 100 / total).min(100);
if last_percent != Some(percent) {
last_percent = Some(percent);
let _ = item_progress.set_text(format!("Lädt … {} %", percent));
}
}
};
let on_finish = move || {
let _ = item_finish.set_text("Wird installiert…");
};
match update.download_and_install(on_chunk, on_finish).await {
Ok(()) => {
// Windows: nie erreicht -- das Plugin startet den NSIS-Installer
// mit `/P /UPDATE /R /ARGS` und beendet den Prozess per
// `std::process::exit(0)`; der Run-Handler wird dabei nicht
// durchlaufen. Linux: die AppImage ist ersetzt; `restart()` aus
// dem Nebenthread laeuft ueber RESTART_EXIT_CODE
// (`code: Some(..)`) am `prevent_exit` des Run-Handlers vorbei
// (tauri app.rs Z. 588-611).
app.restart();
}
Err(e) => {
let _ = item.set_text(&menu_text);
if let Ok(mut pending) = app.state::<PendingUpdate>().0.lock() {
*pending = Some((update, menu_text.clone()));
}
let _ = item.set_enabled(true);
let _ = app
.notification()
.builder()
.title("Tessera-Update")
.body(format!(
"Update fehlgeschlagen: {}. Die Download-Seite wird im Browser geöffnet.",
e
))
.show();
open_download_page(&app);
}
}
});
}
@@ -298,6 +500,7 @@ pub fn run() {
None,
))
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_updater::Builder::new().build())
.invoke_handler(tauri::generate_handler![
check_server,
save_server_url,
@@ -316,11 +519,13 @@ pub fn run() {
}
// Tray menu: Verbunden mit ... (gesperrt) · — · Öffnen ·
// Server-Adresse ändern... · Update herunterladen · — ·
// Server-Adresse ändern... · Update installieren · — ·
// Autostart-Haken · — · Beenden.
// "connected" ist nicht anklickbar; "update" bleibt gesperrt, bis
// die Versionspruefung eine neuere Version findet; "autostart"
// spiegelt den tatsaechlichen Systemzustand beim Start.
// die Versionspruefung eine neuere Version findet, und heisst dann
// "Auf Version X.Y.Z aktualisieren" bzw. "Auf Beta-Stand ...
// aktualisieren"; "autostart" spiegelt den tatsaechlichen
// Systemzustand beim Start.
let connected =
MenuItemBuilder::with_id("connected", tray_labels(server_url.as_deref()).1)
.enabled(false)
@@ -357,6 +562,7 @@ pub fn run() {
connected: connected.clone(),
update: update.clone(),
});
app.manage(PendingUpdate(Mutex::new(None)));
let autostart_for_menu = autostart.clone();
@@ -383,12 +589,20 @@ pub fn run() {
}
}
"update" => {
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>);
// `take()` verhindert Doppelklick-Downloads, der
// gesperrte Eintrag ebenso; ohne abgelegten Stand
// bleibt der Browser-Weg (Einstellungen → Desktop-App).
let pending = app
.state::<PendingUpdate>()
.0
.lock()
.ok()
.and_then(|mut guard| guard.take());
match pending {
Some((update, menu_text)) => {
spawn_update_install(app.clone(), update, menu_text)
}
None => open_download_page(app),
}
}
"autostart" => {
@@ -489,23 +703,124 @@ mod tests {
#[test]
fn update_labels_bei_versionswechsel() {
let (menu_text, body) = update_labels(true, "1.2.0", "abc1234");
assert_eq!(menu_text, "Version 1.2.0 herunterladen");
assert_eq!(menu_text, "Auf Version 1.2.0 aktualisieren");
assert_eq!(
body,
"Neue Version 1.2.0 verfügbar – Download über das Symbol im Infobereich."
"Neue Version 1.2.0 verfügbar – Aktualisieren über das Symbol im Infobereich."
);
}
#[test]
fn update_labels_bei_gleicher_version_beta_commit() {
let (menu_text, body) = update_labels(false, "1.1.0", "abc1234");
assert_eq!(menu_text, "Neuen Beta-Stand herunterladen");
assert_eq!(menu_text, "Auf Beta-Stand abc1234 aktualisieren");
assert_eq!(
body,
"Neuer Beta-Stand abc1234 verfügbar – Download über das Symbol im Infobereich."
"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!(