fix(desktop,favorites): keine zweite Update-Installation, Lesegrenzen bei der Symbolsuche

- Desktop: Merker "Installation laeuft" sperrt Pruefschleife und Klick; ein angebotenes Update bleibt nach fehlgeschlagener Pruefung per Klick installierbar
- Desktop: Benachrichtigungsrecht erst nach erfolgreichem add_capability vermerken
- Favoriten: HTML nur bis MAX_HTML_CHARS und hoechstens 4 s lesen, Nicht-HTML-Antworten verwerfen

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-30 03:20:16 +02:00
parent c2e4467dd8
commit 071082983b
3 changed files with 450 additions and 91 deletions
+202 -21
View File
@@ -1,4 +1,5 @@
use semver::Version;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex;
use std::time::Duration;
use tauri::{
@@ -48,6 +49,59 @@ struct VersionResponse {
/// `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
@@ -429,28 +483,44 @@ fn grant_server_notifications(app: &AppHandle, url: &str) {
);
return;
};
{
let mut granted = match GRANTED_SERVER_ORIGINS.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
if granted.contains(&pattern) {
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);
}
granted.push(pattern.clone());
}
let mut capability = tauri::ipc::CapabilityBuilder::new("server-notifications")
.remote(pattern)
.local(false)
.window("main");
for permission in SERVER_NOTIFICATION_PERMISSIONS {
capability = capability.permission(permission);
}
if let Err(e) = app.add_capability(capability) {
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`.
@@ -596,7 +666,13 @@ fn clear_check_notice(app: &AppHandle) {
/// `/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);
@@ -615,7 +691,14 @@ fn spawn_version_check(app: AppHandle, server_url: String, install_after: bool)
.expect("CARGO_PKG_VERSION muss eine gueltige SemVer-Version sein");
tauri::async_runtime::spawn(async move {
match check_for_update(&app, endpoint).await {
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))
@@ -640,10 +723,12 @@ fn spawn_version_check(app: AppHandle, server_url: String, install_after: bool)
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
@@ -692,7 +777,13 @@ fn open_download_page(app: &AppHandle) {
/// 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…");
@@ -738,6 +829,7 @@ fn spawn_update_install(app: AppHandle, update: Update, menu_text: String) {
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()
@@ -796,6 +888,8 @@ fn save_server_url(app: AppHandle, url: String) -> Result<(), 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") {
@@ -993,6 +1087,7 @@ pub fn run() {
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();
@@ -1021,6 +1116,14 @@ pub fn run() {
// 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
@@ -1029,7 +1132,8 @@ pub fn run() {
.and_then(|mut guard| guard.take())
.is_some();
if let Some(url) = stored_server_url(app) {
spawn_version_check(app.clone(), url, had_pending);
let install_after = flow.install_on_click(had_pending);
spawn_version_check(app.clone(), url, install_after);
}
}
"autostart" => {
@@ -1081,11 +1185,16 @@ pub fn run() {
// ein Update, wird der Durchlauf uebersprungen: die Pruefung
// wuerde den Stand leeren und "Neuer Beta-Stand" erneut melden.
// Waehrend einer Installation ist der Stand per `take()` leer;
// faellt die 4-h-Marke genau in den Download, wird nur der
// Fortschrittstext ueberschrieben (T-FRG-04, akzeptiert).
// 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
@@ -1493,6 +1602,78 @@ mod tests {
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 {