From 0ab07a04111f2237c11740d3fd00d169da6ceeb5 Mon Sep 17 00:00:00 2001 From: Ewan Croft Date: Tue, 3 Mar 2026 16:15:11 +0000 Subject: [PATCH] feat: widen scope of icons allowed to be used (highest to lowest quality) --- src/state/queries/pds-label.ts | 132 ++++++++++++++++++++++++--------- 1 file changed, 97 insertions(+), 35 deletions(-) diff --git a/src/state/queries/pds-label.ts b/src/state/queries/pds-label.ts index 6e546ad43..363f7a4d1 100644 --- a/src/state/queries/pds-label.ts +++ b/src/state/queries/pds-label.ts @@ -27,6 +27,34 @@ export function isBridgedPdsUrl(url: string): boolean { } } +// Ordered from highest to lowest quality. The web path probes these via Image +// (CORS-safe) and returns the first that successfully loads. The native path +// uses these as a fallback chain when no icon is found in the HTML. +const ICON_CANDIDATE_PATHS = [ + '/apple-touch-icon.png', // 180 × 180, very common + '/apple-icon-180x180.png', + '/favicon-256x256.png', + '/favicon-96x96.png', + '/favicon-32x32.png', + '/favicon-16x16.png', + '/favicon.ico', +] + +/** Returns the pixel size for a `sizes` attribute value like "180x180", or 0. */ +function parseSizeAttr(sizes: string | null | undefined): number { + if (!sizes) return 0 + const match = sizes.match(/(\d+)x\d+/i) + return match ? parseInt(match[1], 10) : 0 +} + +/** Resolves an href found in a tag to an absolute URL. */ +function resolveHref(href: string, origin: string): string { + if (href.startsWith('http')) return href + if (href.startsWith('//')) return `https:${href}` + if (href.startsWith('/')) return `${origin}${href}` + return `${origin}/${href}` +} + async function fetchFaviconUrl(pdsUrl: string): Promise { let origin = '' try { @@ -37,44 +65,78 @@ async function fetchFaviconUrl(pdsUrl: string): Promise { if (IS_WEB) { // fetch() is blocked by CORS for third-party origins on web. - // Use the browser Image constructor instead — it loads cross-origin without CORS. - // Only resolve with the URL once the image confirms it loaded. - return new Promise(resolve => { - const url = `${origin}/favicon.ico` - const img = new Image() - img.onload = () => resolve(url) - img.onerror = () => resolve(undefined) - img.src = url + // Probe candidate URLs in parallel using the Image constructor (CORS-safe). + // Return whichever high-quality candidate loads first, in priority order. + const results = await Promise.all( + ICON_CANDIDATE_PATHS.map( + path => + new Promise(resolve => { + const url = `${origin}${path}` + const img = new Image() + img.onload = () => resolve(url) + img.onerror = () => resolve(undefined) + img.src = url + }), + ), + ) + // Return the first (highest-priority) candidate that loaded. + return results.find(Boolean) + } + + // Native path: parse the page HTML for all / tags, pick the one with the largest declared size, + // then fall back to probing the candidate paths in order. + const htmlIconUrl = await fetch(origin, {headers: {Accept: 'text/html'}}) + .then(async res => { + if (!res.ok) return undefined + const html = await res.text() + + // Collect every tag that looks like an icon. + const linkTagRe = /]+)>/gi + let best: {url: string; size: number} | undefined + + let tagMatch: RegExpExecArray | null + while ((tagMatch = linkTagRe.exec(html)) !== null) { + const attrs = tagMatch[1] + const relMatch = attrs.match(/rel=["']([^"']+)["']/i) + if (!relMatch) continue + const rel = relMatch[1].toLowerCase() + if (!rel.includes('icon')) continue + + const hrefMatch = + attrs.match(/href=["']([^"']+)["']/i) || + attrs.match(/href=([^\s>]+)/i) + if (!hrefMatch) continue + + const sizesMatch = attrs.match(/sizes=["']([^"']+)["']/i) + const size = parseSizeAttr(sizesMatch?.[1]) + const url = resolveHref(hrefMatch[1], origin) + + // apple-touch-icon gets a size bonus so it beats a generic icon of the + // same declared dimensions. + const effectiveSize = rel.includes('apple-touch-icon') ? size + 1 : size + + if (!best || effectiveSize > best.size) { + best = {url, size: effectiveSize} + } + } + + return best?.url }) + .catch(() => undefined) + + if (htmlIconUrl) return htmlIconUrl + + // Fall back to probing known high-quality paths in order. + for (const path of ICON_CANDIDATE_PATHS) { + const url = `${origin}${path}` + const ok = await fetch(url, {method: 'HEAD'}) + .then(res => res.ok) + .catch(() => false) + if (ok) return url } - const [linkIconUrl, faviconIcoUrl] = await Promise.all([ - fetch(origin, {headers: {Accept: 'text/html'}}) - .then(async res => { - if (!res.ok) return undefined - const html = await res.text() - // Match or in either attribute order - const match = - html.match( - /]+rel=["'][^"']*\bicon\b[^"']*["'][^>]*href=["']([^"']+)["']/i, - ) || - html.match( - /]+href=["']([^"']+)["'][^>]*rel=["'][^"']*\bicon\b[^"']*["']/i, - ) - if (!match) return undefined - const href = match[1] - if (href.startsWith('http')) return href - if (href.startsWith('//')) return `https:${href}` - if (href.startsWith('/')) return `${origin}${href}` - return `${origin}/${href}` - }) - .catch(() => undefined), - fetch(`${origin}/favicon.ico`) - .then(res => (res.ok ? `${origin}/favicon.ico` : undefined)) - .catch(() => undefined), - ]) - - return faviconIcoUrl ?? linkIconUrl + return undefined } export const RQKEY_ROOT = 'pds-label' -- 2.51.2