1import { isPublicFaviconHost } from "./hostClassification.ts";
2
3/**
4 * Mirrors Codex's generic Browser Use fallback: ask the page origin for its
5 * conventional favicon and let the image element fall back to a browser glyph.
6 * Chrome-backed tools can pass their tab's explicit favicon URL separately.
7 */
8function faviconUrlForPage(rawUrl: string | null | undefined, _size = 32): string | null {
9 if (!rawUrl || rawUrl.length > 4096) return null;
10 try {
11 const pageUrl = new URL(rawUrl);
12 if (pageUrl.protocol !== "http:" && pageUrl.protocol !== "https:") return null;
13 return new URL("/favicon.ico", pageUrl.origin).href;
14 } catch {
15 return null;
16 }
17}
18
19const THEMED_FAVICON_BY_HOSTNAME: Readonly<
20 Record<string, Readonly<{ light: string; dark: string }>>
21> = {
22 "github.com": {
23 light: "https://github.githubassets.com/favicons/favicon.svg",
24 dark: "https://github.githubassets.com/favicons/favicon-dark.svg",
25 },
26};
27
28function themedFaviconUrlForPage(
29 rawUrl: string | null | undefined,
30 appearance: "light" | "dark",
31): string | null {
32 if (!rawUrl || rawUrl.length > 4096) return null;
33 try {
34 const pageUrl = new URL(rawUrl);
35 if (pageUrl.protocol !== "http:" && pageUrl.protocol !== "https:") return null;
36 return THEMED_FAVICON_BY_HOSTNAME[pageUrl.hostname.toLowerCase()]?.[appearance] ?? null;
37 } catch {
38 return null;
39 }
40}
41
42/** Accepts image URLs supplied by a trusted provider event. */
43function explicitFaviconUrl(rawUrl: string | null | undefined): string | null {
44 if (!rawUrl || rawUrl.length > 4096) return null;
45 try {
46 const url = new URL(rawUrl);
47 return url.protocol === "http:" || url.protocol === "https:" || url.protocol === "data:"
48 ? url.href
49 : null;
50 } catch {
51 return null;
52 }
53}
54
55/**
56 * Chooses a website icon for the app's resolved theme. Provider-supplied
57 * variants mirror Codex's Chrome-selected favicon path. A small site-owned
58 * fallback table covers websites whose conventional favicon is illegible in
59 * one appearance without recoloring full-color icons.
60 */
61export function toolActivityFaviconUrl(
62 icon: {
63 readonly pageUrl: string;
64 readonly faviconUrl?: string | undefined;
65 readonly faviconUrlDark?: string | undefined;
66 },
67 appearance: "light" | "dark",
68 size = 32,
69): string | null {
70 if (appearance === "dark") {
71 return (
72 explicitFaviconUrl(icon.faviconUrlDark) ??
73 themedFaviconUrlForPage(icon.pageUrl, "dark") ??
74 explicitFaviconUrl(icon.faviconUrl) ??
75 faviconUrlForPage(icon.pageUrl, size)
76 );
77 }
78 return (
79 explicitFaviconUrl(icon.faviconUrl) ??
80 themedFaviconUrlForPage(icon.pageUrl, "light") ??
81 faviconUrlForPage(icon.pageUrl, size)
82 );
83}
84
85/**
86 * Return a public favicon URL that discloses only a public hostname, never the
87 * port, path, or a private or internal-looking host. Callers show a generic
88 * icon when this returns null.
89 */
90export function faviconUrlForOrigin(rawUrl: string | null | undefined, size = 32): string | null {
91 if (!rawUrl) return null;
92 try {
93 const url = new URL(rawUrl);
94 if (!url.hostname) return null;
95 if (url.protocol !== "http:" && url.protocol !== "https:") return null;
96 if (!isPublicFaviconHost(url.hostname)) return null;
97 return `https://www.google.com/s2/favicons?domain=${encodeURIComponent(url.hostname)}&sz=${size}`;
98 } catch {
99 return null;
100 }
101}