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:
@@ -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 <origin>/favicon.ico (og:image einer Fehlerseite zaehlt nicht)', async () => {
|
||||
const html = '<html><head><meta property="og:image" content="https://cdn.invalid/x.png"></head></html>';
|
||||
const html =
|
||||
'<html><head><meta property="og:image" content="https://cdn.invalid/x.png"></head></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<Uint8Array>({
|
||||
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<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode('<link rel="icon">'));
|
||||
// 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('<link rel="icon">');
|
||||
expect(cancelled).toBe(true);
|
||||
});
|
||||
|
||||
it('liest kurze Seiten vollstaendig, auch Mehrbyte-Zeichen ueber Chunk-Grenzen', async () => {
|
||||
const bytes = new TextEncoder().encode('<p>Grüße</p>');
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
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('<p>Grüße</p>');
|
||||
});
|
||||
|
||||
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 = '<html><head><link rel="icon" href="/klein.png" /></head><body>';
|
||||
const encoder = new TextEncoder();
|
||||
const state = { pulled: 0, cancelled: false };
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<boolean> {
|
||||
@@ -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(/<link\b[^>]*>/gi) ?? [];
|
||||
const metaTags = html.match(/<meta\b[^>]*>/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<UndiciResponse, 'body' | 'text'>;
|
||||
|
||||
/**
|
||||
* Verwirft den Body einer nicht gebrauchten Antwort. Fehler (bereits
|
||||
* gelesen/abgebrochen) sind egal.
|
||||
*/
|
||||
export function discardBody(response: Pick<UndiciResponse, 'body'>): 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<string> {
|
||||
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<FetchHtmlResult | null> {
|
||||
const result = await fetchWithRedirectGuard(pageUrl, {
|
||||
accept: 'text/html,application/xhtml+xml,*/*',
|
||||
@@ -340,12 +384,18 @@ async function fetchHtml(pageUrl: URL): Promise<FetchHtmlResult | null> {
|
||||
|
||||
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, {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
granted.push(pattern.clone());
|
||||
}
|
||||
let result = grant_origin_once(&GRANTED_SERVER_ORIGINS, &pattern, |pattern| {
|
||||
let mut capability = tauri::ipc::CapabilityBuilder::new("server-notifications")
|
||||
.remote(pattern)
|
||||
.remote(pattern.to_string())
|
||||
.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(¤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::<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 {
|
||||
|
||||
Reference in New Issue
Block a user