diff --git a/components/utils/iconService.ts b/components/utils/iconService.ts index 0fd25d1..cbf3a96 100644 --- a/components/utils/iconService.ts +++ b/components/utils/iconService.ts @@ -47,39 +47,72 @@ const blobToDataUrl = (blob: Blob): Promise => reader.readAsDataURL(blob); }); +const TRUSTED_TLDS = new Set([ + 'com', + 'org', + 'net', + 'gov', + 'edu', + 'io', + 'co', + 'dev', + 'app', + 'me', + 'ai', + 'info', + 'br', + 'uk', + 'de', +]); + +const isTrustedTld = (hostname: string): boolean => { + if (!hostname || !hostname.includes('.')) return false; + const parts = hostname.toLowerCase().split('.'); + const lastPart = parts[parts.length - 1]; + return TRUSTED_TLDS.has(lastPart); +}; + +const getIconFetchSource = (iconUrl: string): string | null => { + try { + const url = new URL(iconUrl); + if (url.protocol !== 'http:' && url.protocol !== 'https:') return null; + if ( + (url.hostname === 'www.google.com' || url.hostname === 'google.com') && + url.pathname.startsWith('/s2/favicons') + ) { + const domain = url.searchParams.get('domain'); + if (domain) { + let cleanHost = domain.trim().replace(/^https?:\/\//i, ''); + try { + cleanHost = new URL(`https://${cleanHost}`).hostname; + } catch { + // ignore parsing error + } + if (cleanHost && isTrustedTld(cleanHost)) { + return `https://icon.horse/icon/${encodeURIComponent(cleanHost)}`; + } + } + } + return null; + } catch { + return null; + } +}; + async function getWebsiteIcon(rawUrl: string): Promise { - let targetUrl = rawUrl.trim(); - if (targetUrl && !/^https?:\/\//i.test(targetUrl)) { - targetUrl = `https://${targetUrl}`; - } + const trimmed = rawUrl.trim(); + const hasProtocol = /^https?:\/\//i.test(trimmed); + const targetUrl = hasProtocol ? trimmed : `https://${trimmed}`; try { - const response = await fetch(targetUrl); - const html = await response.text(); - const doc = new DOMParser().parseFromString(html, 'text/html'); - - const appleTouchIcon = doc.querySelector('link[rel="apple-touch-icon"]'); - if (appleTouchIcon) { - const href = appleTouchIcon.getAttribute('href'); - if (href) { - return new URL(href, targetUrl).href; + const parsed = new URL(targetUrl); + if (!isTrustedTld(parsed.hostname)) { + if (!hasProtocol) { + return `http://${parsed.host}/favicon.ico`; } + return `${parsed.origin}/favicon.ico`; } - - const iconLink = doc.querySelector('link[rel="icon"][type="image/png"]') || doc.querySelector('link[rel="icon"]'); - if (iconLink) { - const href = iconLink.getAttribute('href'); - if (href) { - return new URL(href, targetUrl).href; - } - } - } catch (error) { - console.error('Error fetching and parsing HTML for icon:', error); - } - - try { - const hostname = new URL(targetUrl).hostname; - return `https://www.google.com/s2/favicons?domain=${hostname}&sz=128`; + return `https://www.google.com/s2/favicons?domain=${parsed.hostname}&sz=128`; } catch { return `https://www.google.com/s2/favicons?domain=${encodeURIComponent(rawUrl)}&sz=128`; } @@ -125,8 +158,11 @@ async function cacheWebsiteIcon(iconUrl: string): Promise { const cachedIcon = await getCachedWebsiteIcon(iconUrl); if (cachedIcon) return cachedIcon; + const fetchSource = getIconFetchSource(iconUrl); + if (!fetchSource) return null; + try { - const response = await fetch(iconUrl, { mode: 'cors' }); + const response = await fetch(fetchSource); if (!response.ok || response.type === 'opaque') return null; const blob = await response.blob(); diff --git a/project-context.md b/project-context.md index 49b0944..fed9a19 100644 --- a/project-context.md +++ b/project-context.md @@ -58,13 +58,13 @@ The startpage is composed of widgets and a configuration panel: - **Wallpaper background** — Fullscreen background image with adjustable blur, brightness, and opacity, rendered behind a soft readability layer for the liquid-glass UI. Randomly rotates through multiple wallpapers at an hourly cadence selected by slider (`1h`–`48h`). - Built-in wallpapers: Abstract, Abstract Red, Beach, Dark, Mountain, Waves (`components/utils/baseWallpapers.ts`). - User wallpapers: upload from a file (≤4MB, ≤4.5MB base64) or add by URL. Remote URLs remain lightweight entries in the `userWallpapers` index; uploaded image data is stored in `chrome.storage.local` when available. -- **Icon library, auto-fetch & cache** — Website icons can be picked from the [Dashboard Icons](https://dashboardicons.com/) library (metadata pre-downloaded to `public/icon-metadata.json`) or auto-fetched from the target site's `apple-touch-icon`/`icon` link tags, with a fallback to Google's S2 favicon service. Tiles opportunistically cache CORS-readable icon responses as data URLs in `chrome.storage.local` and retain the original URL when caching is unavailable. +- **Icon library, auto-fetch & cache** — Website icons can be picked from the [Dashboard Icons](https://dashboardicons.com/) library (metadata pre-downloaded to `public/icon-metadata.json`) or auto-fetched for trusted public TLDs as a Google S2 favicon URL derived from the site's hostname (no HTML fetching — the S2 URL is used purely as an `` source). Non-trusted TLDs (e.g. local TLDs, IP addresses) fetch `${origin}/favicon.ico` directly. Tiles opportunistically cache CORS-readable icon responses as data URLs in `chrome.storage.local` and retain the original URL when caching is unavailable. Cache population fetches trusted TLDs through a CORS-open favicon service (`icon.horse`) because Google's S2 endpoint 301-redirects without `Access-Control-Allow-Origin`, which would block cross-origin `fetch`. - **Configuration panel** — Slide-in right-side modal with four tabs: General, Theme, Clock, Server Widget. Includes **Export** (downloads a JSON bundle of selected `localStorage` keys) and **Import** (restores from JSON and reloads the page). - **Edit mode** — Toggle via the top-left pencil button; reveals per-tile glass action toolbars, per-category edit buttons, and ghost glass "add" tiles. - **Liquid glass design language** — Soft translucent surfaces, restrained edge highlights, moderate backdrop blur, soft shadows, cyan focus states, and iOS-like easing tokens (`ease-ios`, `ease-spring`, `ease-liquid`) defined in `index.css`. Performance notes: -- Website tile icons check a deterministic `vision-start:icon:` entry in `chrome.storage.local` before loading the external URL. Cache population uses ordinary CORS-enabled `fetch`, deduplicates in-flight requests, stores only `image/*` responses up to 256KiB, and never intercepts or proxies outside requests. +- Website tile icons check a deterministic `vision-start:icon:` entry in `chrome.storage.local` before loading the external URL. Cache population fetches Google S2 favicon URLs for trusted public TLDs through `https://icon.horse/icon/` (a CORS-open favicon service), while non-trusted TLDs and direct icon URLs render directly in `` tags without triggering background `fetch` calls. It deduplicates in-flight requests, stores only `image/*` responses up to 256KiB, and never intercepts or proxies outside requests. - Modals (`ConfigurationModal`, `WebsiteEditModal`, `CategoryEditModal`) are code-split via `React.lazy` + `Suspense` and only loaded when opened. `ConfigurationModal` is the heaviest chunk (it pulls in `@hello-pangea/dnd` via `ServerWidgetTab`); the rest of `@hello-pangea/dnd` is isolated from the initial load. `WebsiteEditModal` and `CategoryEditModal` share a common `ModalShell` component. - `WebsiteTile` and `CategoryGroup` are wrapped in `React.memo`; `App.tsx` handlers are `useCallback`-stabilized and pure alignment helpers are hoisted to module scope, so opening a modal / toggling edit no longer re-renders every tile. - `Clock` updates on the minute boundary (one `setTimeout` → `setInterval(60_000)`) instead of every second. @@ -245,7 +245,7 @@ External assets fetched at build time by `scripts/prepare_release.sh`: - **Chrome storage is optional.** `StorageLocalManager` checks availability once (`checkChromeStorageLocalAvailable`) and caches it. Remote wallpaper URLs work without it because their URLs live in `userWallpapers`; file upload is gated off when unavailable, and `addWallpaperToChromeStorageLocal` throws if called without it. - **Wallpaper rotation** is time-based, evaluated on render/mount rather than via a timer. It reads `wallpaperState` from `localStorage`, selects a random non-current wallpaper when the frequency window has elapsed, and writes its index back; the frequency is clamped to 1–48 hours, while older saved values like `1d`/`2d` still resolve to their hour equivalents. The renderer clamps `currentIndex` to the valid range of the current selection and walks the list forward to find a wallpaper whose data actually resolves (so deleting the currently-displayed wallpaper, or shrinking the selection, never leaves the background blank); if no wallpaper resolves, the background layer is hidden. When the selection becomes empty, `wallpaperState` is reset and the background is hidden. A manual "Random Wallpaper" button in the Theme tab also picks a random non-current wallpaper and bumps a `wallpaperVersion` nonce in `App.tsx` that retriggers the renderer. - **Icon picker** in `WebsiteEditModal` loads `/icon-metadata.json` at runtime and expands each icon's `colors` into duplicate-name entries so color variants are searchable. -- **Website icon cache** is persistent only in `chrome.storage.local`; it is rebuilt from website icon URLs after configuration import. Only CORS-readable `http`/`https` image responses no larger than 256KiB are cached. +- **Website icon cache** is persistent only in `chrome.storage.local`; it is rebuilt from website icon URLs after configuration import. Only `image/*` responses no larger than 256KiB are cached. Trusted public TLDs use Google S2 and the CORS-open `icon.horse` service for favicon caching, while non-trusted TLDs (local TLDs, IP addresses, custom domains) fetch `${origin}/favicon.ico` directly. No site HTML is ever fetched for icon discovery. - **`tsconfig.json` does not emit JS** (`noEmit: true`, bundler resolution); Vite handles all transpilation. - **`tailwind.config.js` safelists** a set of `w-[Npx]/h-[Npx]` classes because `WebsiteTile` generates tailwind classes dynamically from `tileSize` (`w-[42px]`, etc.). - **Project guidance note** in `PROJECT.md`: do not use `npm run dev` for real verification — use `npm run build`.