From 7afb496eb016a535e83906001f51f4b2a0c2fcfd Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Sat, 9 May 2026 23:00:56 +0800 Subject: [PATCH] feat: enhance website icon loading mechanism; implement icon loading state management and error handling --- src/router-public.ts | 77 +++++++++++++++------ webapp/src/components/vault/WebsiteIcon.tsx | 27 ++++---- webapp/src/lib/website-icon-cache.ts | 56 ++------------- 3 files changed, 74 insertions(+), 86 deletions(-) diff --git a/src/router-public.ts b/src/router-public.ts index dbd9455..076e780 100644 --- a/src/router-public.ts +++ b/src/router-public.ts @@ -142,6 +142,17 @@ function normalizeIconHost(rawHost: string): string | null { } const ICON_UPSTREAM_TIMEOUT_MS = 2500; +const BITWARDEN_DEFAULT_GLOBE_ICON_BYTES = 500; +const BITWARDEN_DEFAULT_GLOBE_ICON_SHA256 = 'aaa64871332ad5b7d28fe8874efb19c2d9cc2f1e6de75d52b080b438225a0783'; + +type IconSource = { + url: string; + rejectImage?: { + byteLength: number; + sha256: string; + }; + headers?: HeadersInit; +}; async function fetchIconSource(source: { url: string; headers?: HeadersInit }): Promise { const controller = new AbortController(); @@ -161,48 +172,70 @@ async function fetchIconSource(source: { url: string; headers?: HeadersInit }): } } +async function sha256Hex(bytes: ArrayBuffer): Promise { + const digest = await crypto.subtle.digest('SHA-256', bytes); + return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join(''); +} + +function iconResponse(body: BodyInit | null, contentType: string | null): Response { + return new Response(body, { + status: 200, + headers: { + 'Content-Type': contentType || 'image/png', + 'Cache-Control': `public, max-age=${LIMITS.cache.iconTtlSeconds}, immutable`, + }, + }); +} + async function handleWebsiteIcon(host: string, fallbackMode: 'default' | 'not-found' = 'default'): Promise { const normalizedHost = normalizeIconHost(host); if (!normalizedHost) return fallbackMode === 'not-found' ? handleMissingWebsiteIcon() : handleNwFavicon(); const encodedHost = encodeURIComponent(normalizedHost); const requestHeaders = { 'User-Agent': 'NodeWarden/1.0' }; - const upstreamSources: Array<{ url: string; headers?: HeadersInit }> = [ + const upstreamSources: IconSource[] = [ + { + url: `https://favicon.im/zh/${encodedHost}?larger=true&throw-error-on-404=true`, + headers: requestHeaders, + }, { url: `https://icons.bitwarden.net/${encodedHost}/icon.png`, - headers: requestHeaders, - }, - { - url: `https://favicon.im/${encodedHost}`, - headers: requestHeaders, - }, - { - url: `https://icons.duckduckgo.com/ip3/${encodedHost}.ico`, + rejectImage: { + byteLength: BITWARDEN_DEFAULT_GLOBE_ICON_BYTES, + sha256: BITWARDEN_DEFAULT_GLOBE_ICON_SHA256, + }, headers: requestHeaders, }, ]; - try { - for (const source of upstreamSources) { + for (const source of upstreamSources) { + try { const resp = await fetchIconSource(source); if (!resp.ok) continue; const contentType = String(resp.headers.get('Content-Type') || '').toLowerCase(); if (!contentType.startsWith('image/')) continue; - return new Response(resp.body, { - status: 200, - headers: { - 'Content-Type': resp.headers.get('Content-Type') || 'image/png', - 'Cache-Control': `public, max-age=${LIMITS.cache.iconTtlSeconds}, immutable`, - }, - }); - } + if (!source.rejectImage) { + return iconResponse(resp.body, resp.headers.get('Content-Type')); + } - return fallbackMode === 'not-found' ? handleMissingWebsiteIcon() : handleNwFavicon(); - } catch { - return fallbackMode === 'not-found' ? handleMissingWebsiteIcon() : handleNwFavicon(); + const contentLength = Number(resp.headers.get('Content-Length') || ''); + if (Number.isFinite(contentLength) && contentLength > 0 && contentLength !== source.rejectImage.byteLength) { + return iconResponse(resp.body, resp.headers.get('Content-Type')); + } + + const bytes = await resp.arrayBuffer(); + if (bytes.byteLength === 0) continue; + if (bytes.byteLength === source.rejectImage.byteLength && (await sha256Hex(bytes)) === source.rejectImage.sha256) continue; + + return iconResponse(bytes, resp.headers.get('Content-Type')); + } catch { + continue; + } } + + return fallbackMode === 'not-found' ? handleMissingWebsiteIcon() : handleNwFavicon(); } export function buildWebBootstrapResponse(env: Env): WebBootstrapResponse { diff --git a/webapp/src/components/vault/WebsiteIcon.tsx b/webapp/src/components/vault/WebsiteIcon.tsx index e15f731..73a826d 100644 --- a/webapp/src/components/vault/WebsiteIcon.tsx +++ b/webapp/src/components/vault/WebsiteIcon.tsx @@ -3,9 +3,11 @@ import type { ComponentChildren } from 'preact'; import { Globe } from 'lucide-preact'; import type { Cipher } from '@/lib/types'; import { + beginWebsiteIconLoad, getWebsiteIconImageUrl, getWebsiteIconStatus, - preloadWebsiteIcon, + markWebsiteIconErrored, + markWebsiteIconLoaded, subscribeWebsiteIconStatus, } from '@/lib/website-icon-cache'; import { demoBrandIconUrl } from '@/lib/demo-brand-icons'; @@ -26,6 +28,7 @@ export default function WebsiteIcon(props: WebsiteIconProps) { const [shouldLoad, setShouldLoad] = useState(() => (host ? getWebsiteIconStatus(host) === 'loaded' : true)); const [status, setStatus] = useState(() => (host ? getWebsiteIconStatus(host) : 'idle')); const [imageUrl, setImageUrl] = useState(() => (host ? getWebsiteIconImageUrl(host) : '')); + const [isLoadOwner, setIsLoadOwner] = useState(false); const demoIconUrl = SHOULD_LOAD_DEMO_BRAND_ICONS && host ? demoBrandIconUrl(host) : ''; useEffect(() => { @@ -33,12 +36,14 @@ export default function WebsiteIcon(props: WebsiteIconProps) { setShouldLoad(true); setStatus('idle'); setImageUrl(''); + setIsLoadOwner(false); return; } const nextStatus = getWebsiteIconStatus(host); setShouldLoad(nextStatus === 'loaded'); setStatus(nextStatus); setImageUrl(getWebsiteIconImageUrl(host)); + setIsLoadOwner(false); return subscribeWebsiteIconStatus(host, (next) => { setStatus(next); setImageUrl(getWebsiteIconImageUrl(host)); @@ -77,16 +82,8 @@ export default function WebsiteIcon(props: WebsiteIconProps) { useEffect(() => { if (SHOULD_LOAD_DEMO_BRAND_ICONS) return; if (demoIconUrl) return; - if (!host || !src || !shouldLoad || status === 'loaded' || status === 'error') return; - let disposed = false; - void preloadWebsiteIcon(host, src).then((nextStatus) => { - if (disposed) return; - setStatus(nextStatus); - setImageUrl(getWebsiteIconImageUrl(host)); - }); - return () => { - disposed = true; - }; + if (!host || !src || !shouldLoad || status !== 'idle') return; + setIsLoadOwner(beginWebsiteIconLoad(host, src)); }, [demoIconUrl, host, src, shouldLoad, status]); if (demoIconUrl) { @@ -107,16 +104,20 @@ export default function WebsiteIcon(props: WebsiteIconProps) { return {props.fallback ?? }; } + const shouldRenderIconImage = !!imageUrl && (status === 'loaded' || (status === 'loading' && isLoadOwner)); + return ( {status !== 'loaded' && {props.fallback ?? }} - {status === 'loaded' && imageUrl && ( + {shouldRenderIconImage && ( markWebsiteIconLoaded(host, imageUrl)} + onError={() => markWebsiteIconErrored(host)} /> )} diff --git a/webapp/src/lib/website-icon-cache.ts b/webapp/src/lib/website-icon-cache.ts index 04bbcba..6201518 100644 --- a/webapp/src/lib/website-icon-cache.ts +++ b/webapp/src/lib/website-icon-cache.ts @@ -1,10 +1,7 @@ type WebsiteIconStatus = 'idle' | 'loading' | 'loaded' | 'error'; -const ICON_LOAD_TIMEOUT_MS = 5000; - interface WebsiteIconRecord { status: WebsiteIconStatus; - promise: Promise | null; imageUrl: string | null; listeners: Set<(status: WebsiteIconStatus) => void>; } @@ -16,7 +13,6 @@ function ensureRecord(host: string): WebsiteIconRecord { if (!record) { record = { status: 'idle', - promise: null, imageUrl: null, listeners: new Set(), }; @@ -55,7 +51,6 @@ export function subscribeWebsiteIconStatus(host: string, listener: (status: Webs export function markWebsiteIconLoaded(host: string, imageUrl?: string): void { if (!host) return; const record = ensureRecord(host); - record.promise = null; if (imageUrl) { record.imageUrl = imageUrl; } @@ -65,56 +60,15 @@ export function markWebsiteIconLoaded(host: string, imageUrl?: string): void { export function markWebsiteIconErrored(host: string): void { if (!host) return; const record = ensureRecord(host); - record.promise = null; record.imageUrl = null; notifyRecord(host, 'error'); } -function blobToDataUrl(blob: Blob): Promise { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => resolve(typeof reader.result === 'string' ? reader.result : ''); - reader.onerror = () => reject(reader.error || new Error('Failed to read icon')); - reader.readAsDataURL(blob); - }); -} - -export function preloadWebsiteIcon(host: string, src: string): Promise { - if (!host) return Promise.resolve('error'); - +export function beginWebsiteIconLoad(host: string, src: string): boolean { + if (!host || !src) return false; const record = ensureRecord(host); - if (record.status === 'loaded' || record.status === 'error') { - return Promise.resolve(record.status); - } - if (record.promise) { - return record.promise; - } - + if (record.status !== 'idle') return false; + record.imageUrl = src; notifyRecord(host, 'loading'); - record.promise = (async () => { - const controller = new AbortController(); - const timeout = window.setTimeout(() => controller.abort(), ICON_LOAD_TIMEOUT_MS); - try { - const resp = await fetch(src, { - cache: 'force-cache', - signal: controller.signal, - }); - if (!resp.ok) throw new Error('Icon unavailable'); - const contentType = String(resp.headers.get('Content-Type') || '').toLowerCase(); - if (!contentType.startsWith('image/')) throw new Error('Icon response is not an image'); - const blob = await resp.blob(); - if (!blob.size) throw new Error('Icon response is empty'); - const imageUrl = await blobToDataUrl(blob); - if (!imageUrl) throw new Error('Icon response is empty'); - markWebsiteIconLoaded(host, imageUrl); - return 'loaded'; - } catch { - markWebsiteIconErrored(host); - return 'error'; - } finally { - window.clearTimeout(timeout); - } - })(); - - return record.promise; + return true; }