From 071082983b764331d53fca4be64badc83471fcc4 Mon Sep 17 00:00:00 2001 From: Schalli Date: Wed, 30 Sep 2026 03:20:16 +0200 Subject: [PATCH] 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) --- .../favorites/icon-discovery.service.spec.ts | 191 ++++++++++++--- .../src/favorites/icon-discovery.service.ts | 127 ++++++---- apps/desktop/src-tauri/src/lib.rs | 223 ++++++++++++++++-- 3 files changed, 450 insertions(+), 91 deletions(-) diff --git a/apps/api/src/favorites/icon-discovery.service.spec.ts b/apps/api/src/favorites/icon-discovery.service.spec.ts index 005e052..2ec30af 100644 --- a/apps/api/src/favorites/icon-discovery.service.spec.ts +++ b/apps/api/src/favorites/icon-discovery.service.spec.ts @@ -18,24 +18,21 @@ vi.mock('undici', () => ({ import { Agent } from 'undici'; import { + discardBody, IconDiscoveryService, isPublicHttpUrl, normalizeUrl, + readTextCapped, } from './icon-discovery.service'; -function mockResponse(options: { - contentType?: string; - body?: ArrayBuffer; -}): Response { +function mockResponse(options: { contentType?: string; body?: ArrayBuffer }): Response { const body = options.body ?? new ArrayBuffer(10); return { ok: true, status: 200, headers: { get: (name: string) => - name.toLowerCase() === 'content-type' - ? (options.contentType ?? 'image/png') - : null, + name.toLowerCase() === 'content-type' ? (options.contentType ?? 'image/png') : null, }, arrayBuffer: async () => body, } as unknown as Response; @@ -106,9 +103,7 @@ describe('IconDiscoveryService.discoverFavoriteIconUrl', () => { status: 200, headers: { get: (n: string) => - n.toLowerCase() === 'content-type' - ? 'text/html; charset=utf-8' - : null, + n.toLowerCase() === 'content-type' ? 'text/html; charset=utf-8' : null, }, text: async () => html, }), @@ -164,7 +159,8 @@ describe('IconDiscoveryService.discoverFavoriteIconUrl — Seite mit Fehlerstatu }); it('Fehlerseite ohne Symbol-Verweis, nur og:image -> Rueckfall /favicon.ico (og:image einer Fehlerseite zaehlt nicht)', async () => { - const html = ''; + const html = + ''; vi.stubGlobal('fetch', vi.fn().mockResolvedValue(htmlResponse(404, html))); const icon = await new IconDiscoveryService().discoverFavoriteIconUrl('http://8.8.8.8/x'); @@ -173,7 +169,10 @@ describe('IconDiscoveryService.discoverFavoriteIconUrl — Seite mit Fehlerstatu }); it('fetchIconBytes bleibt streng: Fehlerstatus -> wirft (kein allowErrorStatus fuer Bilder)', async () => { - vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ...htmlResponse(404, ''), headers: { get: () => 'text/html' } })); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ ...htmlResponse(404, ''), headers: { get: () => 'text/html' } }), + ); await expect( new IconDiscoveryService().fetchIconBytes('http://8.8.8.8/favicon.ico'), @@ -203,16 +202,13 @@ describe('IconDiscoveryService.fetchIconBytes', () => { }); it('rejects when Content-Type is not an image', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue(mockResponse({ contentType: 'text/html' })), - ); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({ contentType: 'text/html' }))); const service = new IconDiscoveryService(); - await expect( - service.fetchIconBytes('http://8.8.8.8/favicon.ico'), - ).rejects.toThrow(/not an image/); + await expect(service.fetchIconBytes('http://8.8.8.8/favicon.ico')).rejects.toThrow( + /not an image/, + ); }); it('rejects when the SSRF guard blocks the target', async () => { @@ -221,9 +217,9 @@ describe('IconDiscoveryService.fetchIconBytes', () => { const service = new IconDiscoveryService(); - await expect( - service.fetchIconBytes('http://127.0.0.1/favicon.ico'), - ).rejects.toThrow(/blocked or failed/); + await expect(service.fetchIconBytes('http://127.0.0.1/favicon.ico')).rejects.toThrow( + /blocked or failed/, + ); expect(fetchSpy).not.toHaveBeenCalled(); }); @@ -236,9 +232,9 @@ describe('IconDiscoveryService.fetchIconBytes', () => { const service = new IconDiscoveryService(); - await expect( - service.fetchIconBytes('http://8.8.8.8/favicon.ico'), - ).rejects.toThrow(/size limit/); + await expect(service.fetchIconBytes('http://8.8.8.8/favicon.ico')).rejects.toThrow( + /size limit/, + ); }); }); @@ -258,10 +254,7 @@ describe('IconDiscoveryService.discoverFavoriteIconUrl (unchanged behaviour)', ( }); it('still returns a URL string', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue(mockResponse({ contentType: 'text/html' })), - ); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({ contentType: 'text/html' }))); const service = new IconDiscoveryService(); const result = await service.discoverFavoriteIconUrl('http://8.8.8.8/page'); @@ -335,3 +328,143 @@ describe('IconDiscoveryService — Dispatcher (260917-jdd)', () => { expect(calls[0][1].dispatcher).toBe(calls[1][1].dispatcher); }); }); + +describe('readTextCapped / discardBody — Groessendeckel beim Lesen (T-08-09)', () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + /** Stream aus `chunks` Stuecken je `chunkChars` ASCII-Zeichen; zaehlt gelesene Stuecke und Abbruch. */ + function countingStream(chunks: number, chunkChars: number) { + const state = { pulled: 0, cancelled: false }; + const encoder = new TextEncoder(); + const body = new ReadableStream({ + pull(controller) { + if (state.pulled >= chunks) { + controller.close(); + return; + } + state.pulled += 1; + controller.enqueue(encoder.encode('a'.repeat(chunkChars))); + }, + cancel() { + state.cancelled = true; + }, + }); + return { body, state }; + } + + it('bricht den Stream nach der Grenze ab statt alles zu lesen', async () => { + const { body, state } = countingStream(1000, 1000); + const text = await readTextCapped({ body, text: async () => 'unbenutzt' } as never, 2500); + + expect(text).toHaveLength(2500); + expect(state.pulled).toBeLessThan(10); + expect(state.cancelled).toBe(true); + }); + + it('gibt nach der Zeitgrenze zurueck, was bis dahin da ist (tropfender Server)', async () => { + let cancelled = false; + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('')); + // danach kommt nichts mehr, der Stream bleibt offen + }, + cancel() { + cancelled = true; + }, + }); + + const text = await readTextCapped({ body, text: async () => '' } as never, 200000, 50); + + expect(text).toBe(''); + expect(cancelled).toBe(true); + }); + + it('liest kurze Seiten vollstaendig, auch Mehrbyte-Zeichen ueber Chunk-Grenzen', async () => { + const bytes = new TextEncoder().encode('

Grüße

'); + const body = new ReadableStream({ + start(controller) { + // Das "ü" (2 Bytes) wird absichtlich zerteilt. + controller.enqueue(bytes.slice(0, 5)); + controller.enqueue(bytes.slice(5)); + controller.close(); + }, + }); + + const text = await readTextCapped({ body, text: async () => '' } as never, 200000); + + expect(text).toBe('

Grüße

'); + }); + + it('ohne Stream: Rueckfall auf text() mit Deckel', async () => { + const text = await readTextCapped( + { body: null, text: async () => 'x'.repeat(50) } as never, + 10, + ); + + expect(text).toBe('x'.repeat(10)); + }); + + it('discardBody bricht einen offenen Body ab und vertraegt fehlenden Body', () => { + const { body, state } = countingStream(5, 10); + discardBody({ body } as never); + expect(state.cancelled).toBe(true); + expect(() => discardBody({ body: null } as never)).not.toThrow(); + }); + + it('Discovery: Fehlerstatus ohne HTML-Typ -> Body wird verworfen, Rueckfall favicon.ico', async () => { + const { body, state } = countingStream(5, 10); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: false, + status: 500, + headers: { + get: (n: string) => (n.toLowerCase() === 'content-type' ? 'application/json' : null), + }, + body, + }), + ); + + const icon = await new IconDiscoveryService().discoverFavoriteIconUrl('http://8.8.8.8/x'); + + expect(icon).toBe('http://8.8.8.8/favicon.ico'); + expect(state.cancelled).toBe(true); + }); + + it('Discovery: riesige HTML-Seite wird nur bis zur Grenze gelesen, Symbol am Anfang gefunden', async () => { + const head = ''; + const encoder = new TextEncoder(); + const state = { pulled: 0, cancelled: false }; + const body = new ReadableStream({ + pull(controller) { + state.pulled += 1; + controller.enqueue(encoder.encode(state.pulled === 1 ? head : 'a'.repeat(64 * 1024))); + }, + cancel() { + state.cancelled = true; + }, + }); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + status: 200, + headers: { get: (n: string) => (n.toLowerCase() === 'content-type' ? 'text/html' : null) }, + body, + text: async () => { + throw new Error('text() darf bei vorhandenem Stream nicht laufen'); + }, + }), + ); + + const icon = await new IconDiscoveryService().discoverFavoriteIconUrl('http://8.8.8.8/'); + + expect(icon).toBe('http://8.8.8.8/klein.png'); + expect(state.cancelled).toBe(true); + // 200 000 Zeichen bei 64-KiB-Stuecken: hoechstens eine Handvoll gelesen. + expect(state.pulled).toBeLessThan(10); + }); +}); diff --git a/apps/api/src/favorites/icon-discovery.service.ts b/apps/api/src/favorites/icon-discovery.service.ts index c499991..cdbe491 100644 --- a/apps/api/src/favorites/icon-discovery.service.ts +++ b/apps/api/src/favorites/icon-discovery.service.ts @@ -1,7 +1,7 @@ -import { Injectable } from '@nestjs/common'; import { lookup } from 'node:dns/promises'; import { isIP } from 'node:net'; -import { Agent, fetch as undiciFetch, type Response as UndiciResponse } from 'undici'; +import { Injectable } from '@nestjs/common'; +import { Agent, type Response as UndiciResponse, fetch as undiciFetch } from 'undici'; /** * Server-side favicon / icon discovery with SSRF protection (T-08-05). @@ -58,9 +58,7 @@ function isPrivateIpv4(address: string): boolean { if ( parts.length !== 4 || - parts.some( - (part) => !Number.isInteger(part) || part < 0 || part > 255, - ) + parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255) ) { return true; } @@ -117,12 +115,7 @@ function isPrivateIpAddress(address: string): boolean { function isBlockedHostname(hostname: string): boolean { const h = hostname.trim().toLowerCase(); - return ( - h === 'localhost' || - h.endsWith('.localhost') || - h.endsWith('.local') || - h === '0.0.0.0' - ); + return h === 'localhost' || h.endsWith('.localhost') || h.endsWith('.local') || h === '0.0.0.0'; } export async function isPublicHttpUrl(url: URL): Promise { @@ -204,11 +197,7 @@ function toAbsoluteUrl(value: string | undefined, base: string): string | null { } } -function extractIconFromHtml( - html: string, - baseUrl: string, - linkTagsOnly = false, -): string | null { +function extractIconFromHtml(html: string, baseUrl: string, linkTagsOnly = false): string | null { const linkTags = html.match(/]*>/gi) ?? []; const metaTags = html.match(/]*>/gi) ?? []; @@ -220,27 +209,19 @@ function extractIconFromHtml( })) .filter((c) => c.href); - const appleTouchIcon = linkCandidates.find((c) => - c.rel.includes('apple-touch-icon'), - )?.href; + const appleTouchIcon = linkCandidates.find((c) => c.rel.includes('apple-touch-icon'))?.href; if (appleTouchIcon) return appleTouchIcon; - const icon = linkCandidates.find((c) => - c.rel.split(/\s+/).includes('icon'), - )?.href; + const icon = linkCandidates.find((c) => c.rel.split(/\s+/).includes('icon'))?.href; if (icon) return icon; - const shortcutIcon = linkCandidates.find((c) => - c.rel.includes('shortcut icon'), - )?.href; + const shortcutIcon = linkCandidates.find((c) => c.rel.includes('shortcut icon'))?.href; if (shortcutIcon) return shortcutIcon; - const imageSrc = linkCandidates.find((c) => - c.rel.includes('image_src'), - )?.href; + const imageSrc = linkCandidates.find((c) => c.rel.includes('image_src'))?.href; if (imageSrc) return imageSrc; @@ -257,9 +238,7 @@ function extractIconFromHtml( .find( (c) => c.content && - (c.property === 'og:image' || - c.property === 'og:logo' || - c.property === 'twitter:image'), + (c.property === 'og:image' || c.property === 'og:logo' || c.property === 'twitter:image'), )?.content; return metaImage ?? null; @@ -327,6 +306,71 @@ async function fetchWithRedirectGuard( return null; } +/** Minimaler Ausschnitt einer Antwort, den die beiden Helfer brauchen. */ +type BodyResponse = Pick; + +/** + * Verwirft den Body einer nicht gebrauchten Antwort. Fehler (bereits + * gelesen/abgebrochen) sind egal. + */ +export function discardBody(response: Pick): void { + try { + response.body?.cancel().catch(() => {}); + } catch { + // Body gesperrt oder schon verbraucht — nichts zu tun. + } +} + +/** + * Liest den Antworttext hoechstens bis `maxChars` Zeichen und bricht den + * Stream danach ab (T-08-09). Vorher wurde der komplette Body gelesen und + * erst danach abgeschnitten — eine riesige Seite landete ganz im Speicher. + * Dekodiert wird UTF-8 wie bei `Response.text()`; da jedes Zeichen aus + * mindestens einem Byte entsteht, bleibt der Speicher bei ~maxChars plus + * einem Chunk. Ohne Stream (`body === null`) wie bisher ueber `text()`. + * `timeoutMs` begrenzt zusaetzlich die Lesedauer: die Zeitgrenze von + * `fetchWithRedirectGuard` endet mit den Kopfzeilen, ein Server, der den + * Body tropfenweise liefert, hielte die Anfrage sonst beliebig lange auf. + * Nach Ablauf zaehlt, was bis dahin gelesen ist. + */ +export async function readTextCapped( + response: BodyResponse, + maxChars: number, + timeoutMs = HTML_FETCH_TIMEOUT_MS, +): Promise { + if (!response.body) { + return (await response.text()).slice(0, maxChars); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let text = ''; + // cancel() beendet ein haengendes read() mit done: true. + const deadline = setTimeout(() => void reader.cancel().catch(() => {}), timeoutMs); + + try { + while (true) { + const { done, value } = await reader.read(); + + if (done) { + text += decoder.decode(); + break; + } + + text += decoder.decode(value, { stream: true }); + + if (text.length >= maxChars) { + await reader.cancel().catch(() => {}); + break; + } + } + } finally { + clearTimeout(deadline); + } + + return text.slice(0, maxChars); +} + async function fetchHtml(pageUrl: URL): Promise { const result = await fetchWithRedirectGuard(pageUrl, { accept: 'text/html,application/xhtml+xml,*/*', @@ -340,12 +384,18 @@ async function fetchHtml(pageUrl: URL): Promise { const contentType = result.response.headers.get('content-type') ?? ''; - if (!contentType.toLowerCase().includes('text/html')) return null; + if (!contentType.toLowerCase().includes('text/html')) { + // Kein HTML (auch bei Fehlerstatus dank allowErrorStatus hier moeglich): + // Body verwerfen, sonst haelt undici die Verbindung bis zum Timeout offen. + discardBody(result.response); + return null; + } - const html = await result.response.text(); + // T-08-09: HTML cap — schon beim Lesen, nicht erst nach dem kompletten Body. + const html = await readTextCapped(result.response, MAX_HTML_CHARS); return { - html: html.slice(0, MAX_HTML_CHARS), // T-08-09: HTML cap + html, finalUrl: result.finalUrl.toString(), ok: result.response.ok, }; @@ -370,10 +420,7 @@ export class IconDiscoveryService { if (!htmlResult) return fallback; - return ( - extractIconFromHtml(htmlResult.html, htmlResult.finalUrl, !htmlResult.ok) ?? - fallback - ); + return extractIconFromHtml(htmlResult.html, htmlResult.finalUrl, !htmlResult.ok) ?? fallback; } catch { return fallback; } @@ -388,9 +435,7 @@ export class IconDiscoveryService { * or an oversized body. Callers must not return a placeholder image; let * the caller map the failure to an HTTP error status instead. */ - async fetchIconBytes( - iconUrl: string, - ): Promise<{ contentType: string; body: Buffer }> { + async fetchIconBytes(iconUrl: string): Promise<{ contentType: string; body: Buffer }> { const url = new URL(iconUrl); const result = await fetchWithRedirectGuard(url, { diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 25de13f..1b69cab 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -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>); +/// 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( + granted: &Mutex>, + 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/` 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::().is_installing() { + return; + } let update_item = app.state::().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::().is_installing() { + return; + } + match result { Ok(Some(mut update)) => { let (menu_text, body) = Version::parse(&update.version) .map(|release| release_labels(¤t, &release)) @@ -640,10 +723,12 @@ fn spawn_version_check(app: AppHandle, server_url: String, install_after: bool) if let Ok(mut pending) = app.state::().0.lock() { *pending = Some((update, menu_text)); } + app.state::().set_offered(true); } Ok(None) => { let _ = update_item.set_text(UPDATE_ITEM_NONE); let _ = update_item.set_enabled(true); + app.state::().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::().try_begin_install() { + return; + } let item = app.state::().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::().0.lock() { *pending = Some((update, menu_text.clone())); } + app.state::().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::().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::(); + if flow.is_installing() { + return; + } let had_pending = app .state::() .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::().is_installing() { + continue; + } let pending = handle .state::() .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 {