History

feat(desktop): add favicons to the Browser panel (#5644)

Chris Deeming committed 3 months ago

Commit 710fd0e1 parent e15f65528 files · +4189 −71Browse files

Unified · Split

28 files
  1. apps/desktop/src/preview/FaviconCapture.test.ts +999 −0
  2. apps/desktop/src/preview/FaviconCapture.ts +679 −0
  3. apps/desktop/src/preview/Manager.test.ts +604 −0
  4. apps/desktop/src/preview/Manager.ts +204 −21
  5. apps/web/src/browser/browserTargetResolver.test.ts +131 −0
  6. apps/web/src/browser/browserTargetResolver.ts +121 −13
  7. apps/web/src/browserFaviconLogic.test.ts +124 −0
  8. apps/web/src/browserFaviconLogic.ts +201 −0
  9. apps/web/src/browserFaviconStore.test.ts +316 −0
  10. apps/web/src/browserFaviconStore.ts +342 −0
  11. apps/web/src/components/ChatView.tsx +12 −3
  12. apps/web/src/components/RightPanelTabs.test.tsx +115 −0
  13. apps/web/src/components/RightPanelTabs.tsx +24 −12
  14. apps/web/src/components/preview/PreviewEmptyState.test.tsx +6 −1
  15. apps/web/src/components/preview/PreviewEmptyState.tsx +5 −1
  16. apps/web/src/components/preview/PreviewFaviconIcon.test.tsx +51 −0
  17. apps/web/src/components/preview/PreviewFaviconIcon.tsx +66 −0
  18. apps/web/src/components/preview/PreviewLocalServerCard.tsx +6 −3
  19. apps/web/src/components/preview/PreviewRecentUrlCard.tsx +5 −3
  20. apps/web/src/components/preview/PreviewView.tsx +1 −0
  21. apps/web/src/components/preview/usePreviewBridge.test.ts +48 −0
  22. apps/web/src/components/preview/usePreviewBridge.ts +52 −14
  23. apps/web/src/lib/favicon.test.ts +42 −0
  24. apps/web/src/lib/favicon.ts +3 −0
  25. apps/web/src/previewStateStore.test.ts +4 −0
  26. apps/web/src/previewStateStore.ts +2 −0
  27. apps/web/src/routes/_chat.pull-requests.tsx +2 −0
  28. packages/contracts/src/ipc.ts +24 −0
apps/desktop/src/preview/FaviconCapture.test.ts+999−0
@@ -0,0 +1,999 @@
1+import { describe, expect, it, vi } from "vite-plus/test";
2+
3+import {
4+ MAX_FAVICON_CANDIDATES,
5+ MAX_FAVICON_RESPONSE_BYTES,
6+ captureFavicon,
7+ selectFaviconCandidates,
8+} from "./FaviconCapture.ts";
9+
10+const PNG = "data:image/png;base64,cG5n";
11+const SOURCE_PNG = Buffer.alloc(24);
12+Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(SOURCE_PNG);
13+SOURCE_PNG.writeUInt32BE(1, 16);
14+SOURCE_PNG.writeUInt32BE(1, 20);
15+const SOURCE_PNG_URL = `data:image/png;base64,${SOURCE_PNG.toString("base64")}`;
16+
17+function sourceGif(
18+ width: number,
19+ height: number,
20+ frameWidth = width,
21+ frameHeight = height,
22+ additionalFrames: ReadonlyArray<{
23+ readonly left?: number;
24+ readonly top?: number;
25+ readonly width: number;
26+ readonly height: number;
27+ }> = [],
28+): Buffer {
29+ const frames = [{ width: frameWidth, height: frameHeight }, ...additionalFrames];
30+ const buffer = Buffer.alloc(13 + frames.length * 12 + 1);
31+ buffer.write("GIF89a", 0, "ascii");
32+ buffer.writeUInt16LE(width, 6);
33+ buffer.writeUInt16LE(height, 8);
34+ let offset = 13;
35+ for (const frame of frames) {
36+ buffer[offset] = 0x2c;
37+ buffer.writeUInt16LE(frame.left ?? 0, offset + 1);
38+ buffer.writeUInt16LE(frame.top ?? 0, offset + 3);
39+ buffer.writeUInt16LE(frame.width, offset + 5);
40+ buffer.writeUInt16LE(frame.height, offset + 7);
41+ offset += 10;
42+ buffer[offset] = 2;
43+ buffer[offset + 1] = 0;
44+ offset += 2;
45+ }
46+ buffer[offset] = 0x3b;
47+ return buffer;
48+}
49+
50+function sourceJpeg(
51+ width: number,
52+ height: number,
53+ orientations: number | ReadonlyArray<number> = [],
54+): Buffer {
55+ const frame = Buffer.from([
56+ 0xff,
57+ 0xd8,
58+ 0xff,
59+ 0xc0,
60+ 0x00,
61+ 0x07,
62+ 0x08,
63+ height >>> 8,
64+ height & 0xff,
65+ width >>> 8,
66+ width & 0xff,
67+ ]);
68+ const app1Segments = (typeof orientations === "number" ? [orientations] : orientations).map(
69+ (orientation) => sourceJpegExifSegment([orientation]),
70+ );
71+ return Buffer.concat([frame.subarray(0, 2), ...app1Segments, frame.subarray(2)]);
72+}
73+
74+function sourceJpegApp1Segment(payload: Buffer): Buffer {
75+ const app1 = Buffer.alloc(4 + payload.byteLength);
76+ app1[0] = 0xff;
77+ app1[1] = 0xe1;
78+ app1.writeUInt16BE(payload.byteLength + 2, 2);
79+ payload.copy(app1, 4);
80+ return app1;
81+}
82+
83+function sourceJpegExifSegment(
84+ orientations: ReadonlyArray<number>,
85+ options?: {
86+ readonly byteOrder?: "II" | "MM";
87+ readonly magic?: number;
88+ readonly padding?: number;
89+ },
90+): Buffer {
91+ const exif = Buffer.alloc(20 + orientations.length * 12);
92+ exif.write("Exif\0\0", 0, "binary");
93+ exif[5] = options?.padding ?? 0;
94+ const littleEndian = options?.byteOrder !== "MM";
95+ exif.write(littleEndian ? "II" : "MM", 6, "ascii");
96+ const writeUInt16 = (value: number, offset: number) =>
97+ littleEndian ? exif.writeUInt16LE(value, offset) : exif.writeUInt16BE(value, offset);
98+ const writeUInt32 = (value: number, offset: number) =>
99+ littleEndian ? exif.writeUInt32LE(value, offset) : exif.writeUInt32BE(value, offset);
100+ writeUInt16(options?.magic ?? 42, 8);
101+ writeUInt32(8, 10);
102+ writeUInt16(orientations.length, 14);
103+ orientations.forEach((orientation, index) => {
104+ const entryOffset = 16 + index * 12;
105+ writeUInt16(0x0112, entryOffset);
106+ writeUInt16(3, entryOffset + 2);
107+ writeUInt32(1, entryOffset + 4);
108+ writeUInt16(orientation, entryOffset + 8);
109+ });
110+ return sourceJpegApp1Segment(exif);
111+}
112+
113+function sourceJpegWithApp1Segments(
114+ width: number,
115+ height: number,
116+ segments: ReadonlyArray<Buffer>,
117+): Buffer {
118+ const frame = sourceJpeg(width, height);
119+ return Buffer.concat([frame.subarray(0, 2), ...segments, frame.subarray(2)]);
120+}
121+
122+function sourceJpegWithOrientationEntries(
123+ width: number,
124+ height: number,
125+ orientations: ReadonlyArray<number>,
126+): Buffer {
127+ return sourceJpegWithApp1Segments(width, height, [sourceJpegExifSegment(orientations)]);
128+}
129+
130+function sourceJpegWithEndianAlias(alias: number, byteOrder: "II" | "MM"): Buffer {
131+ const exif = sourceJpegExifSegment([6], { byteOrder });
132+ exif[10] = alias;
133+ exif[11] = alias;
134+ return sourceJpegWithApp1Segments(64, 32, [exif]);
135+}
136+
137+function sourceJpegExifWithSubIfd(options: {
138+ readonly rootOrientation?: number;
139+ readonly subIfdFirst?: boolean;
140+ readonly subIfdOrientation: number;
141+}): Buffer {
142+ const rootEntries = options.rootOrientation === undefined ? 1 : 2;
143+ const rootIfdOffset = 14;
144+ const subIfdOffset = rootIfdOffset + 2 + rootEntries * 12 + 4;
145+ const exif = Buffer.alloc(subIfdOffset + 2 + 12 + 4);
146+ exif.write("Exif\0\0", 0, "binary");
147+ exif.write("II", 6, "ascii");
148+ exif.writeUInt16LE(42, 8);
149+ exif.writeUInt32LE(8, 10);
150+ exif.writeUInt16LE(rootEntries, rootIfdOffset);
151+
152+ const writeOrientation = (offset: number, orientation: number) => {
153+ exif.writeUInt16LE(0x0112, offset);
154+ exif.writeUInt16LE(3, offset + 2);
155+ exif.writeUInt32LE(1, offset + 4);
156+ exif.writeUInt16LE(orientation, offset + 8);
157+ };
158+ const writeSubIfdPointer = (offset: number) => {
159+ exif.writeUInt16LE(0x8769, offset);
160+ exif.writeUInt16LE(4, offset + 2);
161+ exif.writeUInt32LE(1, offset + 4);
162+ exif.writeUInt32LE(subIfdOffset - 6, offset + 8);
163+ };
164+
165+ const firstRootEntryOffset = rootIfdOffset + 2;
166+ if (options.rootOrientation === undefined) {
167+ writeSubIfdPointer(firstRootEntryOffset);
168+ } else if (options.subIfdFirst) {
169+ writeSubIfdPointer(firstRootEntryOffset);
170+ writeOrientation(firstRootEntryOffset + 12, options.rootOrientation);
171+ } else {
172+ writeOrientation(firstRootEntryOffset, options.rootOrientation);
173+ writeSubIfdPointer(firstRootEntryOffset + 12);
174+ }
175+
176+ exif.writeUInt16LE(1, subIfdOffset);
177+ writeOrientation(subIfdOffset + 2, options.subIfdOrientation);
178+ return sourceJpegApp1Segment(exif);
179+}
180+
181+function sourceJpegExifWithSubIfdPointers(options: {
182+ readonly pointerCount: number;
183+ readonly subIfdEntries: number;
184+}): Buffer {
185+ const { pointerCount, subIfdEntries } = options;
186+ const rootIfdOffset = 14;
187+ const rootEntries = pointerCount + 1;
188+ const subIfdOffset = rootIfdOffset + 2 + rootEntries * 12 + 4;
189+ const exif = Buffer.alloc(subIfdOffset + 2 + subIfdEntries * 12 + 4);
190+ exif.write("Exif\0\0", 0, "binary");
191+ exif.write("II", 6, "ascii");
192+ exif.writeUInt16LE(42, 8);
193+ exif.writeUInt32LE(8, 10);
194+ exif.writeUInt16LE(rootEntries, rootIfdOffset);
195+ for (let index = 0; index < pointerCount; index += 1) {
196+ const entryOffset = rootIfdOffset + 2 + index * 12;
197+ exif.writeUInt16LE(0x8769, entryOffset);
198+ exif.writeUInt16LE(4, entryOffset + 2);
199+ exif.writeUInt32LE(1, entryOffset + 4);
200+ exif.writeUInt32LE(subIfdOffset - 6, entryOffset + 8);
201+ }
202+ const orientationOffset = rootIfdOffset + 2 + pointerCount * 12;
203+ exif.writeUInt16LE(0x0112, orientationOffset);
204+ exif.writeUInt16LE(3, orientationOffset + 2);
205+ exif.writeUInt32LE(1, orientationOffset + 4);
206+ exif.writeUInt16LE(6, orientationOffset + 8);
207+
208+ exif.writeUInt16LE(subIfdEntries, subIfdOffset);
209+ for (let index = 0; index < subIfdEntries; index += 1) {
210+ const entryOffset = subIfdOffset + 2 + index * 12;
211+ exif.writeUInt16LE(1, entryOffset);
212+ exif.writeUInt16LE(3, entryOffset + 2);
213+ exif.writeUInt32LE(1, entryOffset + 4);
214+ }
215+ return sourceJpegApp1Segment(exif);
216+}
217+
218+function sourceJpegExifWithOverlappingSubIfds(pointerCount: number, subIfdEntries: number): Buffer {
219+ const rootIfdOffset = 14;
220+ const rootEntries = pointerCount + 1;
221+ const subIfdOffset = rootIfdOffset + 2 + rootEntries * 12 + 4;
222+ const exif = Buffer.alloc(subIfdOffset + pointerCount * 2 + 2 + subIfdEntries * 12);
223+ exif.write("Exif\0\0", 0, "binary");
224+ exif.write("II", 6, "ascii");
225+ exif.writeUInt32LE(8, 10);
226+ exif.writeUInt16LE(rootEntries, rootIfdOffset);
227+ for (let index = 0; index < pointerCount; index += 1) {
228+ const entryOffset = rootIfdOffset + 2 + index * 12;
229+ exif.writeUInt16LE(0x8769, entryOffset);
230+ exif.writeUInt16LE(4, entryOffset + 2);
231+ exif.writeUInt32LE(1, entryOffset + 4);
232+ exif.writeUInt32LE(subIfdOffset + index * 2 - 6, entryOffset + 8);
233+ exif.writeUInt16LE(subIfdEntries, subIfdOffset + index * 2);
234+ }
235+ const orientationOffset = rootIfdOffset + 2 + pointerCount * 12;
236+ exif.writeUInt16LE(0x0112, orientationOffset);
237+ exif.writeUInt16LE(3, orientationOffset + 2);
238+ exif.writeUInt32LE(1, orientationOffset + 4);
239+ exif.writeUInt16LE(6, orientationOffset + 8);
240+ return sourceJpegApp1Segment(exif);
241+}
242+
243+function sourceWebp(width: number, height: number): Buffer {
244+ const buffer = Buffer.alloc(30);
245+ buffer.write("RIFF", 0, "ascii");
246+ buffer.write("WEBP", 8, "ascii");
247+ buffer.write("VP8X", 12, "ascii");
248+ buffer.writeUIntLE(width - 1, 24, 3);
249+ buffer.writeUIntLE(height - 1, 27, 3);
250+ return buffer;
251+}
252+
253+function sourceIco(embedded: Buffer): Buffer {
254+ const buffer = Buffer.alloc(22 + embedded.byteLength);
255+ buffer.writeUInt16LE(1, 2);
256+ buffer.writeUInt16LE(1, 4);
257+ buffer.writeUInt32LE(embedded.byteLength, 14);
258+ buffer.writeUInt32LE(22, 18);
259+ embedded.copy(buffer, 22);
260+ return buffer;
261+}
262+
263+function makeUnsafePng(): Buffer {
264+ const buffer = Buffer.from(SOURCE_PNG);
265+ buffer.writeUInt32BE(4096, 16);
266+ buffer.writeUInt32BE(4096, 20);
267+ return buffer;
268+}
269+
270+function sourcePng(width: number, height: number): Buffer {
271+ const buffer = Buffer.from(SOURCE_PNG);
272+ buffer.writeUInt32BE(width, 16);
273+ buffer.writeUInt32BE(height, 20);
274+ return buffer;
275+}
276+
277+function makeUnsafeDib(): Buffer {
278+ const buffer = Buffer.alloc(40);
279+ buffer.writeUInt32LE(40, 0);
280+ buffer.writeInt32LE(4096, 4);
281+ buffer.writeInt32LE(4096, 8);
282+ return buffer;
283+}
284+
285+function makeWebContents(options?: {
286+ readonly fetch?: (url: string, init?: RequestInit) => Promise<Response>;
287+ readonly rasterize?: (code: string) => Promise<unknown>;
288+}) {
289+ const fetch = vi.fn(
290+ options?.fetch ??
291+ (async () =>
292+ new Response(new Uint8Array(SOURCE_PNG), {
293+ headers: { "content-type": "image/png" },
294+ })),
295+ );
296+ const executeJavaScriptInIsolatedWorld = vi.fn(
297+ async (_worldId: number, scripts: ReadonlyArray<{ readonly code: string }>) =>
298+ options?.rasterize ? options.rasterize(scripts[0]?.code ?? "") : PNG,
299+ );
300+ return {
301+ webContents: {
302+ session: { fetch },
303+ executeJavaScriptInIsolatedWorld,
304+ } as never,
305+ executeJavaScriptInIsolatedWorld,
306+ fetch,
307+ };
308+}
309+
310+const JPEG_LANDSCAPE_LAYOUT = {
311+ draw: "context.drawImage(bitmap, 0, 8, 32, 16)",
312+ resizeHeight: 16,
313+ resizeWidth: 32,
314+} as const;
315+const JPEG_PORTRAIT_LAYOUT = {
316+ draw: "context.drawImage(bitmap, 8, 0, 16, 32)",
317+ resizeHeight: 32,
318+ resizeWidth: 16,
319+} as const;
320+
321+async function expectJpegLayout(
322+ source: Buffer,
323+ layout: typeof JPEG_LANDSCAPE_LAYOUT | typeof JPEG_PORTRAIT_LAYOUT,
324+): Promise<void> {
325+ const { webContents } = makeWebContents({
326+ rasterize: async (code) => {
327+ expect(code).toContain(`resizeWidth: ${layout.resizeWidth}`);
328+ expect(code).toContain(`resizeHeight: ${layout.resizeHeight}`);
329+ expect(code).toContain(layout.draw);
330+ return PNG;
331+ },
332+ });
333+
334+ expect(
335+ await captureFavicon({
336+ webContents,
337+ pageUrl: "https://example.com/page",
338+ candidates: [`data:image/jpeg;base64,${source.toString("base64")}`],
339+ signal: new AbortController().signal,
340+ }),
341+ ).toEqual({ kind: "captured", dataUrl: PNG });
342+}
343+
344+describe("selectFaviconCandidates", () => {
345+ it("filters and deduplicates before applying the candidate cap", () => {
346+ const valid = Array.from(
347+ { length: MAX_FAVICON_CANDIDATES + 2 },
348+ (_, index) => `https://example.com/favicon-${index}.png`,
349+ );
350+ expect(
351+ selectFaviconCandidates([
352+ ...Array.from({ length: 64 }, () => "javascript:alert(1)"),
353+ valid[0]!,
354+ valid[0]!,
355+ ...valid.slice(1),
356+ ]),
357+ ).toEqual(valid.slice(0, MAX_FAVICON_CANDIDATES));
358+ });
359+
360+ it("bounds raw candidate scanning independently of the usable-candidate cap", () => {
361+ const oversizedInvalid = `javascript:${"x".repeat(2_048)}`;
362+ expect(
363+ selectFaviconCandidates([
364+ ...Array.from({ length: 128 }, () => oversizedInvalid),
365+ "https://example.com/too-late.png",
366+ ]),
367+ ).toEqual([]);
368+ });
369+});
370+
371+describe("captureFavicon", () => {
372+ it.each([
373+ {
374+ label: "same-origin",
375+ pageUrl: "https://example.com/page",
376+ faviconUrl: "https://example.com/favicon.png",
377+ credentials: "include",
378+ },
379+ {
380+ label: "cross-origin",
381+ pageUrl: "https://example.com/page",
382+ faviconUrl: "https://cdn.example.net/favicon.png",
383+ credentials: "omit",
384+ },
385+ ])("uses the explicit credential policy for $label requests", async (testCase) => {
386+ const { webContents, fetch } = makeWebContents();
387+ const result = await captureFavicon({
388+ webContents,
389+ pageUrl: testCase.pageUrl,
390+ candidates: [testCase.faviconUrl],
391+ signal: new AbortController().signal,
392+ });
393+
394+ expect(result).toEqual({ kind: "captured", dataUrl: PNG });
395+ expect(fetch).toHaveBeenCalledWith(
396+ testCase.faviconUrl,
397+ expect.objectContaining({ credentials: testCase.credentials, redirect: "error" }),
398+ );
399+ });
400+
401+ it("decodes base64 and percent-encoded inline images without fetching", async () => {
402+ const { webContents, fetch, executeJavaScriptInIsolatedWorld } = makeWebContents();
403+ const percentEncodedPng = [...SOURCE_PNG]
404+ .map((byte) => `%${byte.toString(16).padStart(2, "0")}`)
405+ .join("");
406+
407+ for (const candidate of [SOURCE_PNG_URL, `data:image/png,${percentEncodedPng}`]) {
408+ expect(
409+ await captureFavicon({
410+ webContents,
411+ pageUrl: "https://example.com/page",
412+ candidates: [candidate],
413+ signal: new AbortController().signal,
414+ }),
415+ ).toEqual({ kind: "captured", dataUrl: PNG });
416+ }
417+
418+ expect(fetch).not.toHaveBeenCalled();
419+ expect(executeJavaScriptInIsolatedWorld).toHaveBeenCalledTimes(2);
420+ });
421+
422+ it("tries the next candidate after an ordinary rejection", async () => {
423+ const { webContents, fetch } = makeWebContents({
424+ fetch: async (url) =>
425+ url.endsWith("first.png")
426+ ? new Response(null, { status: 404 })
427+ : new Response(new Uint8Array(SOURCE_PNG), {
428+ headers: { "content-type": "image/png" },
429+ }),
430+ });
431+
432+ expect(
433+ await captureFavicon({
434+ webContents,
435+ pageUrl: "https://example.com/page",
436+ candidates: ["https://example.com/first.png", "https://example.com/second.png"],
437+ signal: new AbortController().signal,
438+ }),
439+ ).toEqual({ kind: "captured", dataUrl: PNG });
440+ expect(fetch).toHaveBeenCalledTimes(2);
441+ });
442+
443+ it("cancels a rejected response body before trying the next candidate", async () => {
444+ const cancel = vi.fn();
445+ const body = new ReadableStream<Uint8Array>({
446+ start(controller) {
447+ controller.enqueue(new Uint8Array(1));
448+ },
449+ cancel,
450+ });
451+ const { webContents, fetch } = makeWebContents({
452+ fetch: async (url) =>
453+ url.endsWith("first.png")
454+ ? new Response(body, { status: 404 })
455+ : new Response(new Uint8Array(SOURCE_PNG), {
456+ headers: { "content-type": "image/png" },
457+ }),
458+ });
459+
460+ expect(
461+ await captureFavicon({
462+ webContents,
463+ pageUrl: "https://example.com/page",
464+ candidates: ["https://example.com/first.png", "https://example.com/second.png"],
465+ signal: new AbortController().signal,
466+ }),
467+ ).toEqual({ kind: "captured", dataUrl: PNG });
468+ expect(cancel).toHaveBeenCalledOnce();
469+ expect(fetch).toHaveBeenCalledTimes(2);
470+ });
471+
472+ it("stops a pending fetch when its capture is aborted", async () => {
473+ const controller = new AbortController();
474+ const { webContents } = makeWebContents({
475+ fetch: (_url, init) =>
476+ new Promise<Response>((_resolve, reject) => {
477+ init?.signal?.addEventListener("abort", () => reject(init.signal?.reason), {
478+ once: true,
479+ });
480+ }),
481+ });
482+ const capture = captureFavicon({
483+ webContents,
484+ pageUrl: "https://example.com/page",
485+ candidates: ["https://example.com/favicon.png"],
486+ signal: controller.signal,
487+ });
488+ controller.abort();
489+ expect(await capture).toEqual({ kind: "none" });
490+ });
491+
492+ it("ends candidate fallback when the overall capture deadline expires", async () => {
493+ const timeoutController = new AbortController();
494+ const timeout = vi.spyOn(AbortSignal, "timeout").mockReturnValue(timeoutController.signal);
495+ const { webContents, fetch } = makeWebContents({
496+ fetch: (url, init) => {
497+ if (url.endsWith("first.png")) return Promise.resolve(new Response(null, { status: 404 }));
498+ return new Promise<Response>((_resolve, reject) => {
499+ init?.signal?.addEventListener("abort", () => reject(init.signal?.reason), {
500+ once: true,
501+ });
502+ });
503+ },
504+ });
505+ try {
506+ const capture = captureFavicon({
507+ webContents,
508+ pageUrl: "https://example.com/page",
509+ candidates: [
510+ "https://example.com/first.png",
511+ "https://example.com/second.png",
512+ "https://example.com/third.png",
513+ ],
514+ signal: new AbortController().signal,
515+ });
516+ await vi.waitFor(() => expect(fetch).toHaveBeenCalledTimes(2));
517+ timeoutController.abort(new DOMException("capture timed out", "TimeoutError"));
518+
519+ expect(await capture).toEqual({ kind: "timed-out" });
520+ expect(fetch).toHaveBeenCalledTimes(2);
521+ expect(timeout).toHaveBeenCalledOnce();
522+ } finally {
523+ timeout.mockRestore();
524+ }
525+ });
526+
527+ it("does not publish a rasterization that completes after the capture deadline", async () => {
528+ const captureTimeoutController = new AbortController();
529+ const rasterTimeoutController = new AbortController();
530+ const timeout = vi
531+ .spyOn(AbortSignal, "timeout")
532+ .mockImplementation((milliseconds) =>
533+ milliseconds === 5_000 ? captureTimeoutController.signal : rasterTimeoutController.signal,
534+ );
535+ let resolveRasterization!: (value: unknown) => void;
536+ const { webContents, executeJavaScriptInIsolatedWorld } = makeWebContents({
537+ rasterize: () =>
538+ new Promise((resolve) => {
539+ resolveRasterization = resolve;
540+ }),
541+ });
542+ try {
543+ const capture = captureFavicon({
544+ webContents,
545+ pageUrl: "https://example.com/page",
546+ candidates: [SOURCE_PNG_URL],
547+ signal: new AbortController().signal,
548+ });
549+ await vi.waitFor(() => expect(executeJavaScriptInIsolatedWorld).toHaveBeenCalledOnce());
550+ captureTimeoutController.abort(new DOMException("capture timed out", "TimeoutError"));
551+
552+ expect(await capture).toEqual({ kind: "timed-out" });
553+ resolveRasterization(PNG);
554+ } finally {
555+ timeout.mockRestore();
556+ }
557+ });
558+
559+ it("cancels a stalled response body when the capture deadline expires", async () => {
560+ const timeoutController = new AbortController();
561+ const timeout = vi.spyOn(AbortSignal, "timeout").mockReturnValue(timeoutController.signal);
562+ const cancel = vi.fn();
563+ const { webContents } = makeWebContents({
564+ fetch: async () =>
565+ new Response(
566+ new ReadableStream<Uint8Array>({
567+ cancel,
568+ }),
569+ { headers: { "content-type": "image/png" } },
570+ ),
571+ });
572+ try {
573+ const capture = captureFavicon({
574+ webContents,
575+ pageUrl: "https://example.com/page",
576+ candidates: ["https://example.com/favicon.png"],
577+ signal: new AbortController().signal,
578+ });
579+ timeoutController.abort(new DOMException("capture timed out", "TimeoutError"));
580+
581+ expect(await capture).toEqual({ kind: "timed-out" });
582+ expect(cancel).toHaveBeenCalledOnce();
583+ } finally {
584+ timeout.mockRestore();
585+ }
586+ });
587+
588+ it("rejects and cancels an oversized streamed response", async () => {
589+ const cancel = vi.fn();
590+ const body = new ReadableStream<Uint8Array>({
591+ start(controller) {
592+ controller.enqueue(new Uint8Array(MAX_FAVICON_RESPONSE_BYTES));
593+ controller.enqueue(new Uint8Array(1));
594+ },
595+ cancel,
596+ });
597+ const { webContents, executeJavaScriptInIsolatedWorld } = makeWebContents({
598+ fetch: async () => new Response(body, { headers: { "content-type": "image/png" } }),
599+ });
600+
601+ expect(
602+ await captureFavicon({
603+ webContents,
604+ pageUrl: "https://example.com/page",
605+ candidates: ["https://example.com/favicon.png"],
606+ signal: new AbortController().signal,
607+ }),
608+ ).toEqual({ kind: "none" });
609+ expect(cancel).toHaveBeenCalledOnce();
610+ expect(executeJavaScriptInIsolatedWorld).not.toHaveBeenCalled();
611+ });
612+
613+ it("retains bounded compatibility with common favicon formats", async () => {
614+ const { webContents, executeJavaScriptInIsolatedWorld } = makeWebContents();
615+ for (const [mime, buffer] of [
616+ ["image/gif", sourceGif(32, 32)],
617+ ["image/jpeg", sourceJpeg(32, 32)],
618+ ["image/webp", sourceWebp(32, 32)],
619+ ["image/x-icon", sourceIco(SOURCE_PNG)],
620+ ] as const) {
621+ expect(
622+ await captureFavicon({
623+ webContents,
624+ pageUrl: "https://example.com/page",
625+ candidates: [`data:${mime};base64,${buffer.toString("base64")}`],
626+ signal: new AbortController().signal,
627+ }),
628+ ).toEqual({ kind: "captured", dataUrl: PNG });
629+ }
630+ expect(executeJavaScriptInIsolatedWorld).toHaveBeenCalledTimes(4);
631+ });
632+
633+ it.each([
634+ {
635+ label: "landscape",
636+ source: sourcePng(64, 32),
637+ resizeWidth: 32,
638+ resizeHeight: 16,
639+ draw: "context.drawImage(bitmap, 0, 8, 32, 16)",
640+ },
641+ {
642+ label: "portrait",
643+ source: sourcePng(32, 64),
644+ resizeWidth: 16,
645+ resizeHeight: 32,
646+ draw: "context.drawImage(bitmap, 8, 0, 16, 32)",
647+ },
648+ ])("preserves $label aspect ratio within the 32x32 output", async (testCase) => {
649+ const { webContents } = makeWebContents({
650+ rasterize: async (code) => {
651+ expect(code).toContain(`resizeWidth: ${testCase.resizeWidth}`);
652+ expect(code).toContain(`resizeHeight: ${testCase.resizeHeight}`);
653+ expect(code).toContain('resizeQuality: "high"');
654+ expect(code).toContain(testCase.draw);
655+ return PNG;
656+ },
657+ });
658+
659+ expect(
660+ await captureFavicon({
661+ webContents,
662+ pageUrl: "https://example.com/page",
663+ candidates: [`data:image/png;base64,${testCase.source.toString("base64")}`],
664+ signal: new AbortController().signal,
665+ }),
666+ ).toEqual({ kind: "captured", dataUrl: PNG });
667+ });
668+
669+ it.each([
670+ ...[1, 2, 3, 4].map((orientation) => ({
671+ label: `keeps stored dimensions for orientation ${orientation}`,
672+ layout: JPEG_LANDSCAPE_LAYOUT,
673+ source: sourceJpeg(64, 32, orientation),
674+ })),
675+ ...[5, 6, 7, 8].map((orientation) => ({
676+ label: `uses display dimensions for orientation ${orientation}`,
677+ layout: JPEG_PORTRAIT_LAYOUT,
678+ source: sourceJpeg(64, 32, orientation),
679+ })),
680+ {
681+ label: "uses the first separate EXIF segment when it is transposed",
682+ layout: JPEG_PORTRAIT_LAYOUT,
683+ source: sourceJpeg(64, 32, [6, 1]),
684+ },
685+ {
686+ label: "uses the first separate EXIF segment when it is untransposed",
687+ layout: JPEG_LANDSCAPE_LAYOUT,
688+ source: sourceJpeg(64, 32, [1, 6]),
689+ },
690+ {
691+ label: "does not consult a later EXIF segment after an invalid orientation",
692+ layout: JPEG_LANDSCAPE_LAYOUT,
693+ source: sourceJpeg(64, 32, [9, 6]),
694+ },
695+ {
696+ label: "uses a later valid orientation in the same IFD",
697+ layout: JPEG_PORTRAIT_LAYOUT,
698+ source: sourceJpegWithOrientationEntries(64, 32, [9, 6]),
699+ },
700+ {
701+ label: "skips a non-EXIF APP1 segment",
702+ layout: JPEG_PORTRAIT_LAYOUT,
703+ source: sourceJpegWithApp1Segments(64, 32, [
704+ sourceJpegApp1Segment(Buffer.from("not-exif")),
705+ sourceJpegExifSegment([6]),
706+ ]),
707+ },
708+ {
709+ label: "skips an empty EXIF APP1 segment",
710+ layout: JPEG_PORTRAIT_LAYOUT,
711+ source: sourceJpegWithApp1Segments(64, 32, [
712+ sourceJpegApp1Segment(Buffer.from("Exif\0\0", "binary")),
713+ sourceJpegExifSegment([6]),
714+ ]),
715+ },
716+ {
717+ label: "stops after a malformed qualifying EXIF APP1 segment",
718+ layout: JPEG_LANDSCAPE_LAYOUT,
719+ source: sourceJpegWithApp1Segments(64, 32, [
720+ sourceJpegApp1Segment(Buffer.from("Exif\0\0broken", "binary")),
721+ sourceJpegExifSegment([6]),
722+ ]),
723+ },
724+ {
725+ label: "ignores the EXIF padding byte",
726+ layout: JPEG_PORTRAIT_LAYOUT,
727+ source: sourceJpegWithApp1Segments(64, 32, [sourceJpegExifSegment([6], { padding: 0xff })]),
728+ },
729+ {
730+ label: "reads big-endian EXIF",
731+ layout: JPEG_PORTRAIT_LAYOUT,
732+ source: sourceJpegWithApp1Segments(64, 32, [sourceJpegExifSegment([6], { byteOrder: "MM" })]),
733+ },
734+ {
735+ label: "matches Chromium for a nonstandard TIFF magic field",
736+ layout: JPEG_PORTRAIT_LAYOUT,
737+ source: sourceJpegWithApp1Segments(64, 32, [sourceJpegExifSegment([6], { magic: 0 })]),
738+ },
739+ {
740+ label: "rejects a high-bit little-endian alias",
741+ layout: JPEG_LANDSCAPE_LAYOUT,
742+ source: sourceJpegWithEndianAlias(0xc9, "II"),
743+ },
744+ {
745+ label: "rejects a high-bit big-endian alias",
746+ layout: JPEG_LANDSCAPE_LAYOUT,
747+ source: sourceJpegWithEndianAlias(0xcd, "MM"),
748+ },
749+ {
750+ label: "reads an orientation from a SubIFD",
751+ layout: JPEG_PORTRAIT_LAYOUT,
752+ source: sourceJpegWithApp1Segments(64, 32, [
753+ sourceJpegExifWithSubIfd({ subIfdOrientation: 6 }),
754+ ]),
755+ },
756+ {
757+ label: "uses a SubIFD orientation before a later root orientation",
758+ layout: JPEG_PORTRAIT_LAYOUT,
759+ source: sourceJpegWithApp1Segments(64, 32, [
760+ sourceJpegExifWithSubIfd({
761+ rootOrientation: 1,
762+ subIfdFirst: true,
763+ subIfdOrientation: 6,
764+ }),
765+ ]),
766+ },
767+ {
768+ label: "uses a root orientation before a later SubIFD orientation",
769+ layout: JPEG_LANDSCAPE_LAYOUT,
770+ source: sourceJpegWithApp1Segments(64, 32, [
771+ sourceJpegExifWithSubIfd({ rootOrientation: 1, subIfdOrientation: 6 }),
772+ ]),
773+ },
774+ {
775+ label: "memoizes repeated aliases to the same SubIFD",
776+ layout: JPEG_PORTRAIT_LAYOUT,
777+ source: sourceJpegWithApp1Segments(64, 32, [
778+ sourceJpegExifWithSubIfdPointers({ pointerCount: 32, subIfdEntries: 32 }),
779+ ]),
780+ },
781+ ])("matches Chromium JPEG layout: $label", async ({ source, layout }) => {
782+ await expectJpegLayout(source, layout);
783+ });
784+
785+ it("rejects JPEG metadata when distinct SubIFDs exhaust the linear work budget", async () => {
786+ const source = sourceJpegWithApp1Segments(64, 32, [
787+ sourceJpegExifWithOverlappingSubIfds(32, 32),
788+ ]);
789+ const { webContents, executeJavaScriptInIsolatedWorld } = makeWebContents();
790+
791+ expect(
792+ await captureFavicon({
793+ webContents,
794+ pageUrl: "https://example.com/page",
795+ candidates: [`data:image/jpeg;base64,${source.toString("base64")}`],
796+ signal: new AbortController().signal,
797+ }),
798+ ).toEqual({ kind: "none" });
799+ expect(executeJavaScriptInIsolatedWorld).not.toHaveBeenCalled();
800+ });
801+
802+ it("rejects JPEGs with multiple frame headers before rasterization", async () => {
803+ const buffer = Buffer.concat([sourceJpeg(4096, 4096), sourceJpeg(1, 1).subarray(2)]);
804+ const { webContents, executeJavaScriptInIsolatedWorld } = makeWebContents();
805+
806+ expect(
807+ await captureFavicon({
808+ webContents,
809+ pageUrl: "https://example.com/page",
810+ candidates: [`data:image/jpeg;base64,${buffer.toString("base64")}`],
811+ signal: new AbortController().signal,
812+ }),
813+ ).toEqual({ kind: "none" });
814+ expect(executeJavaScriptInIsolatedWorld).not.toHaveBeenCalled();
815+ });
816+
817+ it("rejects an unsafe PNG size before rasterization", async () => {
818+ const buffer = makeUnsafePng();
819+ const { webContents, executeJavaScriptInIsolatedWorld } = makeWebContents({
820+ fetch: async () =>
821+ new Response(new Uint8Array(buffer), {
822+ headers: { "content-type": "image/png" },
823+ }),
824+ });
825+
826+ expect(
827+ await captureFavicon({
828+ webContents,
829+ pageUrl: "https://example.com/page",
830+ candidates: ["https://example.com/favicon.png"],
831+ signal: new AbortController().signal,
832+ }),
833+ ).toEqual({ kind: "none" });
834+ expect(executeJavaScriptInIsolatedWorld).not.toHaveBeenCalled();
835+ });
836+
837+ it.each([
838+ ["GIF", "image/gif", sourceGif(4096, 4096)],
839+ ["GIF frame", "image/gif", sourceGif(1, 1, 4096, 4096)],
840+ ["GIF later frame", "image/gif", sourceGif(1, 1, 1, 1, [{ width: 4096, height: 4096 }])],
841+ [
842+ "GIF cumulative frames",
843+ "image/gif",
844+ sourceGif(
845+ 64,
846+ 64,
847+ 64,
848+ 64,
849+ Array.from({ length: 256 }, () => ({ width: 64, height: 64 })),
850+ ),
851+ ],
852+ ["JPEG", "image/jpeg", sourceJpeg(4096, 4096)],
853+ ["WebP", "image/webp", sourceWebp(4096, 4096)],
854+ ["ICO with PNG", "image/x-icon", sourceIco(makeUnsafePng())],
855+ ["ICO with DIB", "image/x-icon", sourceIco(makeUnsafeDib())],
856+ ["SVG", "image/svg+xml", Buffer.from('<svg width="1" height="1"/>')],
857+ [
858+ "SVG with embedded bitmap",
859+ "image/svg+xml",
860+ Buffer.from(
861+ `<svg width="1" height="1"><image href="data:image/png;base64,${makeUnsafePng().toString("base64")}"/></svg>`,
862+ ),
863+ ],
864+ [
865+ "ICO invalid payload span",
866+ "image/x-icon",
867+ (() => {
868+ const buffer = Buffer.alloc(22);
869+ buffer.writeUInt16LE(1, 2);
870+ buffer.writeUInt16LE(1, 4);
871+ buffer.writeUInt32LE(100, 14);
872+ buffer.writeUInt32LE(22, 18);
873+ return buffer;
874+ })(),
875+ ],
876+ ])("rejects unsafe or unsupported %s before rasterization", async (_label, mime, buffer) => {
877+ const { webContents, executeJavaScriptInIsolatedWorld } = makeWebContents();
878+ const candidate = `data:${mime};base64,${buffer.toString("base64")}`;
879+ expect(
880+ await captureFavicon({
881+ webContents,
882+ pageUrl: "https://example.com/page",
883+ candidates: [candidate],
884+ signal: new AbortController().signal,
885+ }),
886+ ).toEqual({ kind: "none" });
887+ expect(executeJavaScriptInIsolatedWorld).not.toHaveBeenCalled();
888+ });
889+
890+ it("ignores output that is not a bounded PNG data URL", async () => {
891+ const { webContents } = makeWebContents({
892+ rasterize: async () => "data:image/svg+xml;base64,c3Zn",
893+ });
894+
895+ expect(
896+ await captureFavicon({
897+ webContents,
898+ pageUrl: "https://example.com/page",
899+ candidates: [SOURCE_PNG_URL],
900+ signal: new AbortController().signal,
901+ }),
902+ ).toEqual({ kind: "none" });
903+ });
904+
905+ it("waits for physical rasterization settlement after a logical timeout", async () => {
906+ vi.useFakeTimers();
907+ try {
908+ let resolveOld!: (value: unknown) => void;
909+ let executions = 0;
910+ const { webContents } = makeWebContents({
911+ rasterize: () => {
912+ executions += 1;
913+ return executions === 1
914+ ? new Promise((resolve) => {
915+ resolveOld = resolve;
916+ })
917+ : Promise.resolve(PNG);
918+ },
919+ });
920+ const input = {
921+ webContents,
922+ pageUrl: "https://example.com/page",
923+ candidates: [SOURCE_PNG_URL],
924+ signal: new AbortController().signal,
925+ };
926+ const timedOut = captureFavicon(input);
927+ await vi.advanceTimersByTimeAsync(1_001);
928+ expect(await timedOut).toEqual({ kind: "timed-out" });
929+
930+ const newer = captureFavicon(input);
931+ await Promise.resolve();
932+ expect(executions).toBe(1);
933+ resolveOld(PNG);
934+ expect(await newer).toEqual({ kind: "captured", dataUrl: PNG });
935+ expect(executions).toBe(2);
936+ } finally {
937+ vi.useRealTimers();
938+ }
939+ });
940+
941+ it("ends candidate fallback after a rasterization timeout", async () => {
942+ vi.useFakeTimers();
943+ try {
944+ let resolveRasterization!: (value: unknown) => void;
945+ const { webContents, fetch, executeJavaScriptInIsolatedWorld } = makeWebContents({
946+ rasterize: () =>
947+ new Promise((resolve) => {
948+ resolveRasterization = resolve;
949+ }),
950+ });
951+ const capture = captureFavicon({
952+ webContents,
953+ pageUrl: "https://example.com/page",
954+ candidates: ["https://example.com/first.png", "https://example.com/second.png"],
955+ signal: new AbortController().signal,
956+ });
957+
958+ await vi.advanceTimersByTimeAsync(1_001);
959+
960+ expect(await capture).toEqual({ kind: "timed-out" });
961+ expect(fetch).toHaveBeenCalledOnce();
962+ expect(executeJavaScriptInIsolatedWorld).toHaveBeenCalledOnce();
963+ resolveRasterization(PNG);
964+ } finally {
965+ vi.useRealTimers();
966+ }
967+ });
968+
969+ it("coalesces queued rasterizations so only the latest pending capture launches", async () => {
970+ let resolveFirst!: (value: unknown) => void;
971+ let executions = 0;
972+ const { webContents } = makeWebContents({
973+ rasterize: () => {
974+ executions += 1;
975+ return executions === 1
976+ ? new Promise((resolve) => {
977+ resolveFirst = resolve;
978+ })
979+ : Promise.resolve(PNG);
980+ },
981+ });
982+ const input = {
983+ webContents,
984+ pageUrl: "https://example.com/page",
985+ candidates: [SOURCE_PNG_URL],
986+ signal: new AbortController().signal,
987+ };
988+ const first = captureFavicon(input);
989+ const superseded = captureFavicon(input);
990+ const newest = captureFavicon(input);
991+
992+ expect(executions).toBe(1);
993+ resolveFirst(PNG);
994+ expect(await first).toEqual({ kind: "captured", dataUrl: PNG });
995+ expect(await superseded).toEqual({ kind: "none" });
996+ expect(await newest).toEqual({ kind: "captured", dataUrl: PNG });
997+ expect(executions).toBe(2);
998+ });
999+});
apps/desktop/src/preview/FaviconCapture.ts+679−0
@@ -0,0 +1,679 @@
1+import { FAVICON_DATA_URL_MAX_LENGTH } from "@t3tools/contracts";
2+
3+export const MAX_FAVICON_RESPONSE_BYTES = 100_000;
4+export const MAX_FAVICON_CANDIDATES = 8;
5+export const MAX_FAVICON_HTTP_URL_LENGTH = 2_048;
6+
7+const MAX_FAVICON_CANDIDATE_INPUT_UNITS = 262_144;
8+const MIN_FAVICON_CANDIDATE_INPUT_UNITS = 256;
9+const MAX_FAVICON_SOURCE_PIXELS = 1_048_576;
10+const MAX_FAVICON_INLINE_URL_LENGTH = Math.ceil((MAX_FAVICON_RESPONSE_BYTES * 4) / 3) + 128;
11+const FAVICON_CAPTURE_TIMEOUT_MS = 5_000;
12+const FAVICON_RASTER_WORLD_ID = 1001;
13+const FAVICON_RASTER_TIMEOUT_MS = 1_000;
14+const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
15+
16+interface RasterizationGate {
17+ generation: number;
18+ launchAllowed?: Promise<void>;
19+}
20+
21+const rasterizationGates = new WeakMap<Electron.WebContents, RasterizationGate>();
22+
23+async function waitForRasterLaunch(previous: Promise<void>, signal: AbortSignal): Promise<void> {
24+ if (signal.aborted) return;
25+ await new Promise<void>((resolve) => {
26+ const finish = () => {
27+ signal.removeEventListener("abort", finish);
28+ resolve();
29+ };
30+ signal.addEventListener("abort", finish, { once: true });
31+ void previous.then(finish);
32+ });
33+}
34+
35+export type FaviconCaptureResult =
36+ | { readonly kind: "captured"; readonly dataUrl: string }
37+ | { readonly kind: "none" }
38+ | { readonly kind: "timed-out" };
39+
40+type RasterizationResult =
41+ | { readonly kind: "completed"; readonly value: unknown }
42+ | { readonly kind: "timed-out" };
43+
44+export function safeHttpOrigin(url: string): string | null {
45+ try {
46+ const parsed = new URL(url);
47+ return parsed.protocol === "http:" || parsed.protocol === "https:" ? parsed.origin : null;
48+ } catch {
49+ return null;
50+ }
51+}
52+
53+export function selectFaviconCandidates(candidates: ReadonlyArray<string>): ReadonlyArray<string> {
54+ const selected: string[] = [];
55+ const seen = new Set<string>();
56+ let inputUnits = 0;
57+ for (const candidate of candidates) {
58+ // Charge a minimum per entry so a large array of tiny malformed values is bounded too.
59+ inputUnits += Math.max(MIN_FAVICON_CANDIDATE_INPUT_UNITS, candidate.length);
60+ if (inputUnits > MAX_FAVICON_CANDIDATE_INPUT_UNITS) break;
61+ if (!isSupportedFaviconUrl(candidate) || seen.has(candidate)) continue;
62+ seen.add(candidate);
63+ selected.push(candidate);
64+ if (selected.length === MAX_FAVICON_CANDIDATES) break;
65+ }
66+ return selected;
67+}
68+
69+export async function captureFavicon(input: {
70+ readonly webContents: Electron.WebContents;
71+ readonly pageUrl: string;
72+ readonly candidates: ReadonlyArray<string>;
73+ readonly signal: AbortSignal;
74+}): Promise<FaviconCaptureResult> {
75+ const pageOrigin = safeHttpOrigin(input.pageUrl);
76+ if (!pageOrigin) return { kind: "none" };
77+ const captureTimeout = AbortSignal.timeout(FAVICON_CAPTURE_TIMEOUT_MS);
78+ const captureSignal = AbortSignal.any([input.signal, captureTimeout]);
79+
80+ for (const candidate of selectFaviconCandidates(input.candidates)) {
81+ if (captureSignal.aborted) {
82+ return input.signal.aborted ? { kind: "none" } : { kind: "timed-out" };
83+ }
84+ const captured = await captureCandidate({
85+ webContents: input.webContents,
86+ pageOrigin,
87+ candidate,
88+ signal: captureSignal,
89+ });
90+ if (captureSignal.aborted) {
91+ return input.signal.aborted ? { kind: "none" } : { kind: "timed-out" };
92+ }
93+ if (captured.kind === "captured" || captured.kind === "timed-out") return captured;
94+ }
95+
96+ return { kind: "none" };
97+}
98+
99+async function captureCandidate(input: {
100+ readonly webContents: Electron.WebContents;
101+ readonly pageOrigin: string;
102+ readonly candidate: string;
103+ readonly signal: AbortSignal;
104+}): Promise<FaviconCaptureResult> {
105+ try {
106+ const inline = parseInlineFavicon(input.candidate);
107+ if (inline) {
108+ return await normalizeFaviconBuffer(
109+ input.webContents,
110+ inline.mime,
111+ inline.buffer,
112+ input.signal,
113+ );
114+ }
115+
116+ const candidateOrigin = safeHttpOrigin(input.candidate);
117+ if (!candidateOrigin) return { kind: "none" };
118+ const response = await input.webContents.session.fetch(input.candidate, {
119+ credentials: candidateOrigin === input.pageOrigin ? "include" : "omit",
120+ redirect: "error",
121+ signal: input.signal,
122+ });
123+ if (!response.ok) {
124+ await response.body?.cancel();
125+ return { kind: "none" };
126+ }
127+ const buffer = await readFaviconResponse(response, input.signal);
128+ if (!buffer || input.signal.aborted) return { kind: "none" };
129+ const mime = response.headers.get("content-type")?.split(";", 1)[0] ?? null;
130+ return await normalizeFaviconBuffer(input.webContents, mime, buffer, input.signal);
131+ } catch {
132+ return { kind: "none" };
133+ }
134+}
135+
136+async function readFaviconResponse(
137+ response: Response,
138+ signal: AbortSignal,
139+): Promise<Buffer | null> {
140+ const contentLength = Number(response.headers.get("content-length"));
141+ if (Number.isFinite(contentLength) && contentLength > MAX_FAVICON_RESPONSE_BYTES) {
142+ await response.body?.cancel();
143+ return null;
144+ }
145+ if (!response.body) {
146+ const buffer = Buffer.from(await response.arrayBuffer());
147+ return buffer.byteLength <= MAX_FAVICON_RESPONSE_BYTES ? buffer : null;
148+ }
149+
150+ const reader = response.body.getReader();
151+ const cancelForAbort = () => {
152+ void reader.cancel(signal.reason).catch(() => undefined);
153+ };
154+ signal.addEventListener("abort", cancelForAbort, { once: true });
155+ if (signal.aborted) cancelForAbort();
156+ const chunks: Buffer[] = [];
157+ let byteLength = 0;
158+ try {
159+ while (true) {
160+ const next = await reader.read();
161+ if (next.done) return Buffer.concat(chunks, byteLength);
162+ byteLength += next.value.byteLength;
163+ if (byteLength > MAX_FAVICON_RESPONSE_BYTES) {
164+ await reader.cancel();
165+ return null;
166+ }
167+ chunks.push(Buffer.from(next.value));
168+ }
169+ } finally {
170+ signal.removeEventListener("abort", cancelForAbort);
171+ reader.releaseLock();
172+ }
173+}
174+
175+function isSupportedFaviconUrl(url: string): boolean {
176+ if (url.length > MAX_FAVICON_INLINE_URL_LENGTH) return false;
177+ if (/^data:/i.test(url)) return /^data:image\/[a-z0-9.+-]+(?:;[^,]*)?,/i.test(url);
178+ try {
179+ const protocol = new URL(url).protocol;
180+ return (
181+ (protocol === "http:" || protocol === "https:") && url.length <= MAX_FAVICON_HTTP_URL_LENGTH
182+ );
183+ } catch {
184+ return false;
185+ }
186+}
187+
188+function decodeInlineFaviconPayload(payload: string): Buffer | null {
189+ const decoded = Buffer.allocUnsafe(Buffer.byteLength(payload));
190+ let inputOffset = 0;
191+ let outputOffset = 0;
192+ while (inputOffset < payload.length) {
193+ const escapeOffset = payload.indexOf("%", inputOffset);
194+ const literalEnd = escapeOffset === -1 ? payload.length : escapeOffset;
195+ outputOffset += decoded.write(payload.slice(inputOffset, literalEnd), outputOffset, "utf8");
196+ if (escapeOffset === -1) break;
197+ const hex = payload.slice(escapeOffset + 1, escapeOffset + 3);
198+ if (!/^[0-9a-f]{2}$/i.test(hex)) return null;
199+ decoded[outputOffset] = Number.parseInt(hex, 16);
200+ outputOffset += 1;
201+ inputOffset = escapeOffset + 3;
202+ }
203+ return decoded.subarray(0, outputOffset);
204+}
205+
206+function parseInlineFavicon(
207+ url: string,
208+): { readonly buffer: Buffer; readonly mime: string } | null {
209+ if (url.length > MAX_FAVICON_INLINE_URL_LENGTH) return null;
210+ const match = /^data:(image\/[a-z0-9.+-]+)((?:;[^,]*)?),(.*)$/is.exec(url);
211+ if (!match) return null;
212+ const mime = match[1]?.toLowerCase();
213+ const parameters = match[2]
214+ ?.split(";")
215+ .filter(Boolean)
216+ .map((parameter) => parameter.toLowerCase());
217+ const payload = match[3];
218+ if (!mime || !parameters || !payload) return null;
219+ const base64 = parameters.at(-1) === "base64";
220+ if (parameters.includes("base64") && !base64) return null;
221+
222+ let buffer: Buffer;
223+ try {
224+ if (base64) {
225+ if (!/^[a-z0-9+/]*={0,2}$/i.test(payload) || payload.length % 4 === 1) return null;
226+ buffer = Buffer.from(payload, "base64");
227+ if (buffer.toString("base64").replace(/=+$/, "") !== payload.replace(/=+$/, "")) {
228+ return null;
229+ }
230+ } else {
231+ const decoded = decodeInlineFaviconPayload(payload);
232+ if (!decoded) return null;
233+ buffer = decoded;
234+ }
235+ } catch {
236+ return null;
237+ }
238+
239+ return buffer.byteLength > 0 && buffer.byteLength <= MAX_FAVICON_RESPONSE_BYTES
240+ ? { buffer, mime }
241+ : null;
242+}
243+
244+interface ImageDimensions {
245+ readonly width: number;
246+ readonly height: number;
247+}
248+
249+function safeDimensions(dimensions: ImageDimensions | null): dimensions is ImageDimensions {
250+ return (
251+ dimensions !== null &&
252+ Number.isSafeInteger(dimensions.width) &&
253+ Number.isSafeInteger(dimensions.height) &&
254+ dimensions.width > 0 &&
255+ dimensions.height > 0 &&
256+ dimensions.width * dimensions.height <= MAX_FAVICON_SOURCE_PIXELS
257+ );
258+}
259+
260+function pngDimensions(buffer: Buffer): ImageDimensions | null {
261+ if (!buffer.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE) || buffer.byteLength < 24) {
262+ return null;
263+ }
264+ return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) };
265+}
266+
267+function skipGifSubBlocks(buffer: Buffer, startOffset: number): number | null {
268+ let offset = startOffset;
269+ while (offset < buffer.byteLength) {
270+ const blockLength = buffer[offset]!;
271+ offset += 1;
272+ if (blockLength === 0) return offset;
273+ if (offset + blockLength > buffer.byteLength) return null;
274+ offset += blockLength;
275+ }
276+ return null;
277+}
278+
279+function gifDimensions(buffer: Buffer): ImageDimensions | null {
280+ if (buffer.byteLength < 13 || !/^GIF8[79]a$/u.test(buffer.subarray(0, 6).toString("ascii"))) {
281+ return null;
282+ }
283+ const logicalWidth = buffer.readUInt16LE(6);
284+ const logicalHeight = buffer.readUInt16LE(8);
285+ if (!safeDimensions({ width: logicalWidth, height: logicalHeight })) return null;
286+ const packed = buffer[10]!;
287+ let offset = 13 + ((packed & 0x80) === 0 ? 0 : 3 * 2 ** ((packed & 0x07) + 1));
288+ if (offset > buffer.byteLength) return null;
289+ let width = logicalWidth;
290+ let height = logicalHeight;
291+ let frameCount = 0;
292+ let framePixels = 0;
293+ while (offset < buffer.byteLength) {
294+ const marker = buffer[offset];
295+ if (marker === 0x3b) return frameCount > 0 ? { width, height } : null;
296+ if (marker === 0x2c) {
297+ if (offset + 10 > buffer.byteLength) return null;
298+ const left = buffer.readUInt16LE(offset + 1);
299+ const top = buffer.readUInt16LE(offset + 3);
300+ const frameWidth = buffer.readUInt16LE(offset + 5);
301+ const frameHeight = buffer.readUInt16LE(offset + 7);
302+ if (frameWidth === 0 || frameHeight === 0) return null;
303+ framePixels += frameWidth * frameHeight;
304+ if (framePixels > MAX_FAVICON_SOURCE_PIXELS) return null;
305+ width = Math.max(width, left + frameWidth);
306+ height = Math.max(height, top + frameHeight);
307+ if (!safeDimensions({ width, height })) return null;
308+ const framePacked = buffer[offset + 9]!;
309+ offset += 10;
310+ if ((framePacked & 0x80) !== 0) {
311+ offset += 3 * 2 ** ((framePacked & 0x07) + 1);
312+ }
313+ if (offset >= buffer.byteLength) return null;
314+ const minimumCodeSize = buffer[offset]!;
315+ if (minimumCodeSize < 2 || minimumCodeSize > 8) return null;
316+ offset += 1;
317+ const nextOffset = skipGifSubBlocks(buffer, offset);
318+ if (nextOffset === null) return null;
319+ offset = nextOffset;
320+ frameCount += 1;
321+ continue;
322+ }
323+ if (marker !== 0x21 || offset + 2 > buffer.byteLength) return null;
324+ const nextOffset = skipGifSubBlocks(buffer, offset + 2);
325+ if (nextOffset === null) return null;
326+ offset = nextOffset;
327+ }
328+ return null;
329+}
330+
331+interface JpegExifMetadata {
332+ readonly complete: boolean;
333+ readonly orientation: number | null;
334+}
335+
336+function jpegExifMetadata(segment: Buffer): JpegExifMetadata | null {
337+ if (segment.byteLength <= 6 || segment.subarray(0, 5).toString("binary") !== "Exif\0") {
338+ return null;
339+ }
340+ const metadataWithoutOrientation = (): JpegExifMetadata => ({
341+ complete: true,
342+ orientation: null,
343+ });
344+ if (segment.byteLength < 14) return metadataWithoutOrientation();
345+ const tiffOffset = 6;
346+ const littleEndian = segment[tiffOffset] === 0x49 && segment[tiffOffset + 1] === 0x49;
347+ const bigEndian = segment[tiffOffset] === 0x4d && segment[tiffOffset + 1] === 0x4d;
348+ if (!littleEndian && !bigEndian) return metadataWithoutOrientation();
349+ const readUInt16 = (offset: number): number | null => {
350+ if (offset < 0 || offset + 2 > segment.byteLength) return null;
351+ return littleEndian ? segment.readUInt16LE(offset) : segment.readUInt16BE(offset);
352+ };
353+ const readUInt32 = (offset: number): number | null => {
354+ if (offset < 0 || offset + 4 > segment.byteLength) return null;
355+ return littleEndian ? segment.readUInt32LE(offset) : segment.readUInt32BE(offset);
356+ };
357+ const relativeIfdOffset = readUInt32(tiffOffset + 4);
358+ if (relativeIfdOffset === null) return metadataWithoutOrientation();
359+ // Keep untrusted metadata parsing linear even when IFD pointers overlap.
360+ let remainingIfdEntryVisits = Math.ceil(segment.byteLength / 12);
361+ const budgetExhausted = Symbol("ifd-entry-budget-exhausted");
362+ type IfdOrientation = number | null | typeof budgetExhausted;
363+ const subIfdOrientationByOffset = new Map<number, number | null>();
364+ const readIfdOrientation = (ifdOffset: number, isRoot: boolean): IfdOrientation => {
365+ if (!isRoot && subIfdOrientationByOffset.has(ifdOffset)) {
366+ return subIfdOrientationByOffset.get(ifdOffset) ?? null;
367+ }
368+ const entryCount = readUInt16(ifdOffset);
369+ if (entryCount === null) return null;
370+ let result: IfdOrientation = null;
371+ for (let index = 0; index < entryCount; index += 1) {
372+ if (remainingIfdEntryVisits === 0) return budgetExhausted;
373+ remainingIfdEntryVisits -= 1;
374+ const entryOffset = ifdOffset + 2 + index * 12;
375+ if (entryOffset + 12 > segment.byteLength) break;
376+ const tag = readUInt16(entryOffset);
377+ const type = readUInt16(entryOffset + 2);
378+ const count = readUInt32(entryOffset + 4);
379+ if (tag === 0x0112 && type === 3 && count === 1) {
380+ const orientation = readUInt16(entryOffset + 8);
381+ if (orientation !== null && orientation >= 1 && orientation <= 8) {
382+ result = orientation;
383+ break;
384+ }
385+ } else if (isRoot && tag === 0x8769 && type === 4 && count === 1) {
386+ const relativeSubIfdOffset = readUInt32(entryOffset + 8);
387+ if (relativeSubIfdOffset !== null) {
388+ const orientation = readIfdOrientation(tiffOffset + relativeSubIfdOffset, false);
389+ if (orientation === budgetExhausted) return budgetExhausted;
390+ if (orientation !== null) {
391+ result = orientation;
392+ break;
393+ }
394+ }
395+ }
396+ }
397+ if (!isRoot) subIfdOrientationByOffset.set(ifdOffset, result);
398+ return result;
399+ };
400+ const orientation = readIfdOrientation(tiffOffset + relativeIfdOffset, true);
401+ return orientation === budgetExhausted
402+ ? { complete: false, orientation: null }
403+ : { complete: true, orientation };
404+}
405+
406+function jpegDimensions(buffer: Buffer): ImageDimensions | null {
407+ if (buffer.byteLength < 4 || buffer[0] !== 0xff || buffer[1] !== 0xd8) return null;
408+ const startOfFrameMarkers = new Set([
409+ 0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf,
410+ ]);
411+ let offset = 2;
412+ let dimensions: ImageDimensions | null = null;
413+ let exifMetadata: JpegExifMetadata | null = null;
414+ while (offset + 3 < buffer.byteLength) {
415+ if (buffer[offset] !== 0xff) {
416+ offset += 1;
417+ continue;
418+ }
419+ while (buffer[offset] === 0xff) offset += 1;
420+ const marker = buffer[offset];
421+ offset += 1;
422+ if (marker === undefined || marker === 0xd9 || marker === 0xda) break;
423+ if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd8)) continue;
424+ if (offset + 1 >= buffer.byteLength) return null;
425+ const length = buffer.readUInt16BE(offset);
426+ if (length < 2 || offset + length > buffer.byteLength) return null;
427+ if (marker === 0xe1 && exifMetadata === null) {
428+ exifMetadata = jpegExifMetadata(buffer.subarray(offset + 2, offset + length));
429+ }
430+ if (startOfFrameMarkers.has(marker)) {
431+ if (length < 7) return null;
432+ if (dimensions !== null) return null;
433+ dimensions = {
434+ height: buffer.readUInt16BE(offset + 3),
435+ width: buffer.readUInt16BE(offset + 5),
436+ };
437+ }
438+ offset += length;
439+ }
440+ if (!dimensions) return null;
441+ if (exifMetadata?.complete === false) return null;
442+ const orientation = exifMetadata?.orientation;
443+ return orientation !== undefined && orientation !== null && orientation >= 5 && orientation <= 8
444+ ? { width: dimensions.height, height: dimensions.width }
445+ : dimensions;
446+}
447+
448+function webpDimensions(buffer: Buffer): ImageDimensions | null {
449+ if (
450+ buffer.byteLength < 30 ||
451+ buffer.subarray(0, 4).toString("ascii") !== "RIFF" ||
452+ buffer.subarray(8, 12).toString("ascii") !== "WEBP"
453+ ) {
454+ return null;
455+ }
456+ const kind = buffer.subarray(12, 16).toString("ascii");
457+ if (kind === "VP8X") {
458+ return {
459+ width: 1 + buffer.readUIntLE(24, 3),
460+ height: 1 + buffer.readUIntLE(27, 3),
461+ };
462+ }
463+ if (kind === "VP8 " && buffer.subarray(23, 26).equals(Buffer.from([0x9d, 0x01, 0x2a]))) {
464+ return {
465+ width: buffer.readUInt16LE(26) & 0x3fff,
466+ height: buffer.readUInt16LE(28) & 0x3fff,
467+ };
468+ }
469+ if (kind === "VP8L" && buffer[20] === 0x2f) {
470+ return {
471+ width: 1 + buffer[21]! + ((buffer[22]! & 0x3f) << 8),
472+ height: 1 + (buffer[22]! >> 6) + (buffer[23]! << 2) + ((buffer[24]! & 0x0f) << 10),
473+ };
474+ }
475+ return null;
476+}
477+
478+function dibDimensions(buffer: Buffer): ImageDimensions | null {
479+ if (buffer.byteLength < 12) return null;
480+ const headerSize = buffer.readUInt32LE(0);
481+ if (headerSize === 12) {
482+ return {
483+ width: buffer.readUInt16LE(4),
484+ height: buffer.readUInt16LE(6),
485+ };
486+ }
487+ if (headerSize < 40 || buffer.byteLength < 12) return null;
488+ return {
489+ width: Math.abs(buffer.readInt32LE(4)),
490+ height: Math.abs(buffer.readInt32LE(8)),
491+ };
492+}
493+
494+function icoDimensions(buffer: Buffer): ImageDimensions | null {
495+ if (
496+ buffer.byteLength < 22 ||
497+ buffer.readUInt16LE(0) !== 0 ||
498+ (buffer.readUInt16LE(2) !== 1 && buffer.readUInt16LE(2) !== 2)
499+ ) {
500+ return null;
501+ }
502+ const count = buffer.readUInt16LE(4);
503+ if (count === 0 || count > 256 || buffer.byteLength < 6 + count * 16) return null;
504+ let width = 0;
505+ let height = 0;
506+ for (let index = 0; index < count; index += 1) {
507+ const offset = 6 + index * 16;
508+ width = Math.max(width, buffer[offset] === 0 ? 256 : buffer[offset]!);
509+ height = Math.max(height, buffer[offset + 1] === 0 ? 256 : buffer[offset + 1]!);
510+ if (!safeDimensions({ width, height })) return null;
511+ const byteLength = buffer.readUInt32LE(offset + 8);
512+ const imageOffset = buffer.readUInt32LE(offset + 12);
513+ if (
514+ byteLength === 0 ||
515+ imageOffset < 6 + count * 16 ||
516+ imageOffset > buffer.byteLength ||
517+ byteLength > buffer.byteLength - imageOffset
518+ )
519+ return null;
520+ const embedded = buffer.subarray(imageOffset, imageOffset + byteLength);
521+ const embeddedDimensions = pngDimensions(embedded) ?? dibDimensions(embedded);
522+ if (!safeDimensions(embeddedDimensions)) return null;
523+ }
524+ return { width, height };
525+}
526+
527+function sourceDimensions(buffer: Buffer): ImageDimensions | null {
528+ return (
529+ pngDimensions(buffer) ??
530+ gifDimensions(buffer) ??
531+ jpegDimensions(buffer) ??
532+ webpDimensions(buffer) ??
533+ icoDimensions(buffer)
534+ );
535+}
536+
537+async function normalizeFaviconBuffer(
538+ webContents: Electron.WebContents,
539+ mime: string | null,
540+ buffer: Buffer,
541+ signal: AbortSignal,
542+): Promise<FaviconCaptureResult> {
543+ const declaredMime = mime?.trim().toLowerCase() || null;
544+ const normalizedMime =
545+ declaredMime === "application/x-icon"
546+ ? "image/x-icon"
547+ : declaredMime === "application/octet-stream" || declaredMime === "binary/octet-stream"
548+ ? null
549+ : declaredMime;
550+ const dimensions = sourceDimensions(buffer);
551+ if (
552+ (normalizedMime !== null && !/^image\/[a-z0-9.+-]+$/i.test(normalizedMime)) ||
553+ normalizedMime === "image/svg+xml" ||
554+ buffer.byteLength > MAX_FAVICON_RESPONSE_BYTES ||
555+ !safeDimensions(dimensions)
556+ ) {
557+ return { kind: "none" };
558+ }
559+
560+ const rasterized = await rasterizeFavicon(
561+ webContents,
562+ normalizedMime,
563+ buffer,
564+ dimensions,
565+ signal,
566+ );
567+ if (rasterized.kind === "timed-out") return rasterized;
568+ return typeof rasterized.value === "string" &&
569+ rasterized.value.startsWith("data:image/png;base64,") &&
570+ rasterized.value.length <= FAVICON_DATA_URL_MAX_LENGTH
571+ ? { kind: "captured", dataUrl: rasterized.value }
572+ : { kind: "none" };
573+}
574+
575+async function rasterizeFavicon(
576+ webContents: Electron.WebContents,
577+ mime: string | null,
578+ buffer: Buffer,
579+ dimensions: ImageDimensions,
580+ signal: AbortSignal,
581+): Promise<RasterizationResult> {
582+ const gate = rasterizationGates.get(webContents) ?? { generation: 0 };
583+ rasterizationGates.set(webContents, gate);
584+ const generation = ++gate.generation;
585+ const previousLaunchAllowed = gate.launchAllowed;
586+ if (previousLaunchAllowed) {
587+ await waitForRasterLaunch(previousLaunchAllowed, signal);
588+ }
589+ if (signal.aborted || generation !== gate.generation) {
590+ return { kind: "completed", value: null };
591+ }
592+
593+ const payload = buffer.toString("base64");
594+ const blobType = mime ?? "";
595+ const scale = Math.min(32 / dimensions.width, 32 / dimensions.height);
596+ const decodeWidth = Math.max(1, Math.round(dimensions.width * scale));
597+ const decodeHeight = Math.max(1, Math.round(dimensions.height * scale));
598+ const drawX = (32 - decodeWidth) / 2;
599+ const drawY = (32 - decodeHeight) / 2;
600+ const code = `
601+ (() => {
602+ const rasterize = async () => {
603+ try {
604+ const source = Uint8Array.from(atob("${payload}"), (char) => char.charCodeAt(0));
605+ const bitmap = await createImageBitmap(new Blob([source], { type: "${blobType}" }), {
606+ resizeWidth: ${decodeWidth},
607+ resizeHeight: ${decodeHeight},
608+ resizeQuality: "high",
609+ });
610+ try {
611+ if (bitmap.width <= 0 || bitmap.height <= 0 || bitmap.width * bitmap.height > ${MAX_FAVICON_SOURCE_PIXELS}) {
612+ return null;
613+ }
614+ const canvas = new OffscreenCanvas(32, 32);
615+ const context = canvas.getContext("2d");
616+ if (!context) return null;
617+ context.drawImage(bitmap, ${drawX}, ${drawY}, ${decodeWidth}, ${decodeHeight});
618+ const blob = await canvas.convertToBlob({ type: "image/png" });
619+ const output = new Uint8Array(await blob.arrayBuffer());
620+ let binary = "";
621+ for (const byte of output) binary += String.fromCharCode(byte);
622+ return "data:image/png;base64," + btoa(binary);
623+ } finally {
624+ bitmap.close();
625+ }
626+ } catch {
627+ return null;
628+ }
629+ };
630+ return rasterize();
631+ })()
632+ `;
633+
634+ const execution = webContents.executeJavaScriptInIsolatedWorld(FAVICON_RASTER_WORLD_ID, [
635+ { code },
636+ ]);
637+
638+ const result = new Promise<RasterizationResult>((resolve, reject) => {
639+ // Electron cannot cancel isolated-world execution. This timeout ends only
640+ // the logical attempt; renderer work may finish after a newer attempt starts.
641+ const timeout = AbortSignal.timeout(FAVICON_RASTER_TIMEOUT_MS);
642+ let settled = false;
643+ const finish = (complete: () => void) => {
644+ if (settled) return;
645+ settled = true;
646+ timeout.removeEventListener("abort", onTimeout);
647+ signal.removeEventListener("abort", onAbort);
648+ complete();
649+ };
650+ const onTimeout = () => {
651+ finish(() => resolve({ kind: "timed-out" }));
652+ };
653+ const onAbort = () => {
654+ finish(() => resolve({ kind: "completed", value: null }));
655+ };
656+ timeout.addEventListener("abort", onTimeout, { once: true });
657+ signal.addEventListener("abort", onAbort, { once: true });
658+ void execution.then(
659+ (value) => {
660+ finish(() => resolve({ kind: "completed", value }));
661+ },
662+ (cause: unknown) => {
663+ finish(() => reject(cause));
664+ },
665+ );
666+ if (signal.aborted) onAbort();
667+ });
668+ // The logical timeout does not cancel Electron's renderer work. Keep the
669+ // gate closed until that physical execution actually settles.
670+ const launchAllowed = execution.then(
671+ () => undefined,
672+ () => undefined,
673+ );
674+ gate.launchAllowed = launchAllowed;
675+ void launchAllowed.then(() => {
676+ if (gate.launchAllowed === launchAllowed) delete gate.launchAllowed;
677+ });
678+ return await result;
679+}
apps/desktop/src/preview/Manager.test.ts+604−0
@@ -186,6 +186,102 @@ const makeTestPreviewWebContents = ( · 185 unmodified lines
186186 capturePage,
187187 }) as never;
188188
189+const TEST_FAVICON = "data:image/png;base64,cG5n";
190+
191+const makeSourcePng = (width = 1, height = 1): Buffer => {
192+ const buffer = Buffer.alloc(24);
193+ Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(buffer);
194+ buffer.writeUInt32BE(width, 16);
195+ buffer.writeUInt32BE(height, 20);
196+ return buffer;
197+};
198+
199+const makeFaviconWebContents = (options?: {
200+ readonly fetch?: (url: string, init?: RequestInit) => Promise<Response>;
201+ readonly id?: number;
202+ readonly rasterize?: (code: string) => Promise<unknown>;
203+ readonly url?: string;
204+}) => {
205+ const sourcePng = makeSourcePng();
206+ const listeners = new Map<string, (...args: never[]) => void>();
207+ let currentUrl = options?.url ?? "http://localhost:3200/";
208+ let destroyed = false;
209+ let loading = false;
210+ const fetch = vi.fn(
211+ options?.fetch ??
212+ (async () =>
213+ new Response(new Uint8Array(sourcePng), {
214+ headers: { "content-type": "image/png" },
215+ })),
216+ );
217+ const executeJavaScriptInIsolatedWorld = vi.fn(
218+ async (_worldId: number, scripts: ReadonlyArray<{ readonly code: string }>) =>
219+ options?.rasterize ? options.rasterize(scripts[0]?.code ?? "") : TEST_FAVICON,
220+ );
221+ const reload = vi.fn();
222+ const loadURL = vi.fn(async (url: string) => {
223+ currentUrl = url;
224+ });
225+ const off = vi.fn();
226+ const debuggerOff = vi.fn();
227+ const webContents = {
228+ id: options?.id ?? 42,
229+ isDestroyed: () => destroyed,
230+ getType: () => "webview",
231+ getURL: () => currentUrl,
232+ getTitle: () => "Preview",
233+ isLoading: () => loading,
234+ isDevToolsOpened: () => false,
235+ getZoomFactor: () => 1,
236+ setZoomFactor: vi.fn(),
237+ reload,
238+ reloadIgnoringCache: vi.fn(),
239+ loadURL,
240+ on: vi.fn((event: string, listener: (...args: never[]) => void) => {
241+ listeners.set(event, listener);
242+ }),
243+ off,
244+ ipc: { on: vi.fn(), off: vi.fn() },
245+ send: webviewSend,
246+ session: { fetch },
247+ navigationHistory: { canGoBack: () => false, canGoForward: () => false },
248+ setWindowOpenHandler: vi.fn(),
249+ executeJavaScriptInIsolatedWorld,
250+ debugger: {
251+ isAttached: () => false,
252+ attach: vi.fn(),
253+ sendCommand: vi.fn(async () => undefined),
254+ on: vi.fn(),
255+ off: debuggerOff,
256+ },
257+ };
258+ return {
259+ executeJavaScriptInIsolatedWorld,
260+ fetch,
261+ debuggerOff,
262+ listeners,
263+ loadURL,
264+ off,
265+ reload,
266+ setDestroyed: (value: boolean) => {
267+ destroyed = value;
268+ },
269+ setLoading: (value: boolean) => {
270+ loading = value;
271+ },
272+ setUrl: (url: string) => {
273+ currentUrl = url;
274+ },
275+ webContents: webContents as never,
276+ };
277+};
278+
279+const settle = function* (until: () => boolean) {
280+ for (let attempt = 0; attempt < 30 && !until(); attempt++) {
281+ yield* Effect.promise(() => Promise.resolve());
282+ }
283+};
284+
189285 const makeTestPictureInPictureWindow = (loadURL: () => Promise<void> = async () => undefined) => {
190286 const listeners = new Map<string, () => void>();
191287 const send = vi.fn();
@@ -257,6 +353,32 @@ describe("PreviewManager", () => { · 65 unmodified lines
257353 ),
258354 );
259355
356+ effectIt.effect("rejects a destroyed webview during registration", () =>
357+ withManager((manager) =>
358+ Effect.gen(function* () {
359+ const getType = vi.fn(() => "webview" as const);
360+ fromId.mockReturnValue({
361+ id: 42,
362+ isDestroyed: () => true,
363+ getType,
364+ } as never);
365+ yield* manager.createTab("tab_destroyed_registration");
366+
367+ const exit = yield* Effect.exit(manager.registerWebview("tab_destroyed_registration", 42));
368+
369+ expect(Exit.isFailure(exit)).toBe(true);
370+ if (Exit.isFailure(exit)) {
371+ expect(Option.getOrThrow(Cause.findErrorOption(exit.cause))).toMatchObject({
372+ _tag: "PreviewWebContentsNotFoundError",
373+ tabId: "tab_destroyed_registration",
374+ webContentsId: 42,
375+ });
376+ }
377+ expect(getType).not.toHaveBeenCalled();
378+ }),
379+ ),
380+ );
381+
260382 effectIt.effect("isolates failed state listeners and continues delivery", () => {
261383 const loggedErrors: Array<unknown> = [];
262384 const logger = Logger.make(({ message }) => {
@@ -375,6 +497,488 @@ describe("PreviewManager", () => { · 112 unmodified lines
375497 ),
376498 );
377499
500+ effectIt.effect("detaches a destroyed webview instead of navigating it", () =>
501+ withManager((manager) =>
502+ Effect.gen(function* () {
503+ const preview = makeFaviconWebContents();
504+ fromId.mockReturnValue(preview.webContents);
505+ const states: PreviewManager.PreviewTabState[] = [];
506+ yield* manager.subscribeStateChanges((_tabId, state) =>
507+ Effect.sync(() => {
508+ states.push(state);
509+ }),
510+ );
511+ yield* manager.createTab("tab_destroyed_navigation");
512+ yield* manager.registerWebview("tab_destroyed_navigation", 42);
513+ yield* manager.setColorScheme("tab_destroyed_navigation", "dark");
514+ preview.setDestroyed(true);
515+
516+ yield* manager.navigate("tab_destroyed_navigation", "https://example.com/");
517+
518+ expect(preview.loadURL).not.toHaveBeenCalled();
519+ expect(preview.reload).not.toHaveBeenCalled();
520+ expect(preview.off).toHaveBeenCalled();
521+ expect(preview.debuggerOff).toHaveBeenCalled();
522+ expect(states.at(-1)).toMatchObject({
523+ webContentsId: null,
524+ navStatus: { kind: "Loading", url: "https://example.com/" },
525+ });
526+ }),
527+ ),
528+ );
529+
530+ effectIt.effect("does not let destroyed-webview cleanup detach a same-id replacement", () =>
531+ withManager((manager) =>
532+ Effect.gen(function* () {
533+ const previous = makeFaviconWebContents();
534+ const replacement = makeFaviconWebContents({ url: "https://example.com/" });
535+ let current = previous.webContents;
536+ let startReplacementRegistration: () => void = () => void 0;
537+ const replacementReady = new Promise<void>((resolve) => {
538+ startReplacementRegistration = resolve;
539+ });
540+ fromId.mockImplementation(() => current);
541+ yield* manager.createTab("tab_destroyed_replacement_race");
542+ yield* manager.registerWebview("tab_destroyed_replacement_race", 42);
543+ yield* manager.setColorScheme("tab_destroyed_replacement_race", "dark");
544+ const replacementRegistration = yield* Effect.promise(() => replacementReady).pipe(
545+ Effect.flatMap(() => manager.registerWebview("tab_destroyed_replacement_race", 42)),
546+ Effect.forkChild({ startImmediately: true }),
547+ );
548+ previous.setDestroyed(true);
549+ previous.debuggerOff.mockImplementationOnce(() => {
550+ current = replacement.webContents;
551+ startReplacementRegistration();
552+ });
553+ const states: PreviewManager.PreviewTabState[] = [];
554+ yield* manager.subscribeStateChanges((_tabId, state) =>
555+ Effect.sync(() => {
556+ states.push(state);
557+ }),
558+ );
559+
560+ yield* manager.navigate("tab_destroyed_replacement_race", "https://example.com/");
561+ const registrationExit = yield* Fiber.await(replacementRegistration);
562+
563+ expect(Exit.isSuccess(registrationExit)).toBe(true);
564+ expect(previous.off).toHaveBeenCalled();
565+ expect(replacement.off).not.toHaveBeenCalled();
566+ expect(states.at(-1)).toMatchObject({
567+ webContentsId: 42,
568+ navStatus: { kind: "Loading", url: "https://example.com/" },
569+ });
570+ }),
571+ ),
572+ );
573+
574+ effectIt.effect("publishes a canonical favicon origin while the page is loading", () =>
575+ withManager((manager) =>
576+ Effect.gen(function* () {
577+ const preview = makeFaviconWebContents({
578+ url: `http://localhost:3200/${"x".repeat(3_000)}`,
579+ });
580+ preview.setLoading(true);
581+ fromId.mockReturnValue(preview.webContents);
582+ const states: PreviewManager.PreviewTabState[] = [];
583+ yield* manager.subscribeStateChanges((_tabId, state) =>
584+ Effect.sync(() => {
585+ states.push(state);
586+ }),
587+ );
588+ yield* manager.createTab("tab_favicon_loading");
589+ yield* manager.registerWebview("tab_favicon_loading", 42);
590+
591+ preview.listeners.get("page-favicon-updated")?.(
592+ {} as never,
593+ ["http://localhost:3200/favicon.png"] as never,
594+ );
595+ yield* settle(() => states.at(-1)?.favicon !== undefined);
596+
597+ expect(states.at(-1)?.favicon).toMatchObject({
598+ dataUrl: TEST_FAVICON,
599+ pageUrl: "http://localhost:3200",
600+ });
601+ expect(states.at(-1)?.favicon?.capturedAt).toEqual(expect.any(Number));
602+ }),
603+ ),
604+ );
605+
606+ effectIt.effect("shares an identical in-flight event and lets a changed event win", () =>
607+ withManager((manager) =>
608+ Effect.gen(function* () {
609+ let resolveFirst!: (response: Response) => void;
610+ const firstResponse = new Promise<Response>((resolve) => {
611+ resolveFirst = resolve;
612+ });
613+ const preview = makeFaviconWebContents({
614+ fetch: (url) =>
615+ url.endsWith("first.png")
616+ ? firstResponse
617+ : Promise.resolve(
618+ new Response(new Uint8Array(makeSourcePng()), {
619+ headers: { "content-type": "image/png" },
620+ }),
621+ ),
622+ });
623+ fromId.mockReturnValue(preview.webContents);
624+ const states: PreviewManager.PreviewTabState[] = [];
625+ yield* manager.subscribeStateChanges((_tabId, state) =>
626+ Effect.sync(() => {
627+ states.push(state);
628+ }),
629+ );
630+ yield* manager.createTab("tab_favicon_latest");
631+ yield* manager.registerWebview("tab_favicon_latest", 42);
632+
633+ const faviconUpdated = preview.listeners.get("page-favicon-updated")!;
634+ faviconUpdated({} as never, ["http://localhost:3200/first.png"] as never);
635+ faviconUpdated({} as never, ["http://localhost:3200/first.png"] as never);
636+ yield* settle(() => preview.fetch.mock.calls.length === 1);
637+ faviconUpdated({} as never, ["http://localhost:3200/second.png"] as never);
638+ yield* settle(() => states.at(-1)?.favicon !== undefined);
639+ resolveFirst(
640+ new Response(new Uint8Array(makeSourcePng()), {
641+ headers: { "content-type": "image/png" },
642+ }),
643+ );
644+ yield* settle(() => false);
645+
646+ expect(preview.fetch).toHaveBeenCalledTimes(2);
647+ expect(states.filter((state) => state.favicon !== undefined)).toHaveLength(1);
648+ }),
649+ ),
650+ );
651+
652+ effectIt.effect("allows an identical retry after an undecodable capture", () =>
653+ withManager((manager) =>
654+ Effect.gen(function* () {
655+ let rasterizations = 0;
656+ const preview = makeFaviconWebContents({
657+ rasterize: async () => (++rasterizations === 1 ? null : TEST_FAVICON),
658+ });
659+ fromId.mockReturnValue(preview.webContents);
660+ const states: PreviewManager.PreviewTabState[] = [];
661+ yield* manager.subscribeStateChanges((_tabId, state) =>
662+ Effect.sync(() => {
663+ states.push(state);
664+ }),
665+ );
666+ yield* manager.createTab("tab_favicon_retry");
667+ yield* manager.registerWebview("tab_favicon_retry", 42);
668+ const faviconUpdated = preview.listeners.get("page-favicon-updated")!;
669+
670+ faviconUpdated({} as never, ["http://localhost:3200/favicon.png"] as never);
671+ yield* settle(() => rasterizations === 1);
672+ yield* settle(() => false);
673+ faviconUpdated({} as never, ["http://localhost:3200/favicon.png"] as never);
674+ yield* settle(() => states.at(-1)?.favicon !== undefined);
675+
676+ expect(rasterizations).toBe(2);
677+ expect(states.at(-1)?.favicon?.dataUrl).toBe(TEST_FAVICON);
678+ }),
679+ ),
680+ );
681+
682+ effectIt.effect("does not publish a capture invalidated by navigation", () =>
683+ withManager((manager) =>
684+ Effect.gen(function* () {
685+ let resolveFetch!: (response: Response) => void;
686+ const preview = makeFaviconWebContents({
687+ fetch: () =>
688+ new Promise<Response>((resolve) => {
689+ resolveFetch = resolve;
690+ }),
691+ });
692+ fromId.mockReturnValue(preview.webContents);
693+ const states: PreviewManager.PreviewTabState[] = [];
694+ yield* manager.subscribeStateChanges((_tabId, state) =>
695+ Effect.sync(() => {
696+ states.push(state);
697+ }),
698+ );
699+ yield* manager.createTab("tab_favicon_navigation");
700+ yield* manager.registerWebview("tab_favicon_navigation", 42);
701+ preview.listeners.get("page-favicon-updated")?.(
702+ {} as never,
703+ ["http://localhost:3200/favicon.png"] as never,
704+ );
705+ yield* settle(() => preview.fetch.mock.calls.length === 1);
706+ preview.listeners.get("did-start-navigation")?.({
707+ isMainFrame: true,
708+ isSameDocument: false,
709+ } as never);
710+ preview.setUrl("https://example.com/");
711+ resolveFetch(
712+ new Response(new Uint8Array(makeSourcePng()), {
713+ headers: { "content-type": "image/png" },
714+ }),
715+ );
716+ yield* settle(() => false);
717+
718+ expect(states.some((state) => state.favicon !== undefined)).toBe(false);
719+ }),
720+ ),
721+ );
722+
723+ effectIt.effect("retains a favicon when reloading the current URL without a new event", () =>
724+ withManager((manager) =>
725+ Effect.gen(function* () {
726+ const preview = makeFaviconWebContents();
727+ fromId.mockReturnValue(preview.webContents);
728+ const states: PreviewManager.PreviewTabState[] = [];
729+ yield* manager.subscribeStateChanges((_tabId, state) =>
730+ Effect.sync(() => {
731+ states.push(state);
732+ }),
733+ );
734+ yield* manager.createTab("tab_favicon_reload");
735+ yield* manager.registerWebview("tab_favicon_reload", 42);
736+ preview.listeners.get("page-favicon-updated")?.(
737+ {} as never,
738+ ["http://localhost:3200/favicon.png"] as never,
739+ );
740+ yield* settle(() => states.at(-1)?.favicon !== undefined);
741+
742+ yield* manager.navigate("tab_favicon_reload", "http://localhost:3200/");
743+
744+ expect(preview.reload).toHaveBeenCalledOnce();
745+ expect(states.at(-1)?.favicon?.dataUrl).toBe(TEST_FAVICON);
746+ }),
747+ ),
748+ );
749+
750+ effectIt.effect("clears a published favicon after a confirmed cross-origin navigation", () =>
751+ withManager((manager) =>
752+ Effect.gen(function* () {
753+ const preview = makeFaviconWebContents();
754+ fromId.mockReturnValue(preview.webContents);
755+ const states: PreviewManager.PreviewTabState[] = [];
756+ yield* manager.subscribeStateChanges((_tabId, state) =>
757+ Effect.sync(() => {
758+ states.push(state);
759+ }),
760+ );
761+ yield* manager.createTab("tab_favicon_origin");
762+ yield* manager.registerWebview("tab_favicon_origin", 42);
763+ preview.listeners.get("page-favicon-updated")?.(
764+ {} as never,
765+ ["http://localhost:3200/favicon.png"] as never,
766+ );
767+ yield* settle(() => states.at(-1)?.favicon !== undefined);
768+
769+ preview.setUrl("https://example.com/");
770+ preview.listeners.get("did-navigate")?.({} as never);
771+ yield* settle(() => states.at(-1)?.navStatus.kind === "Success");
772+
773+ expect(states.at(-1)?.favicon).toBeUndefined();
774+ }),
775+ ),
776+ );
777+
778+ effectIt.effect(
779+ "retains the previous document icon across a failed cross-origin navigation",
780+ () =>
781+ withManager((manager) =>
782+ Effect.gen(function* () {
783+ const preview = makeFaviconWebContents();
784+ fromId.mockReturnValue(preview.webContents);
785+ const states: PreviewManager.PreviewTabState[] = [];
786+ yield* manager.subscribeStateChanges((_tabId, state) =>
787+ Effect.sync(() => {
788+ states.push(state);
789+ }),
790+ );
791+ yield* manager.createTab("tab_favicon_failed_origin");
792+ yield* manager.registerWebview("tab_favicon_failed_origin", 42);
793+ preview.listeners.get("page-favicon-updated")?.(
794+ {} as never,
795+ ["http://localhost:3200/favicon.png"] as never,
796+ );
797+ yield* settle(() => states.at(-1)?.favicon !== undefined);
798+
799+ preview.listeners.get("did-fail-load")?.(
800+ {} as never,
801+ -105 as never,
802+ "Name not resolved" as never,
803+ "https://unreachable.example/" as never,
804+ true as never,
805+ );
806+ yield* settle(() => states.at(-1)?.navStatus.kind === "LoadFailed");
807+ expect(states.at(-1)?.favicon?.dataUrl).toBe(TEST_FAVICON);
808+
809+ preview.listeners.get("did-navigate")?.({} as never);
810+ yield* settle(() => states.at(-1)?.navStatus.kind === "Success");
811+ expect(states.at(-1)?.favicon?.dataUrl).toBe(TEST_FAVICON);
812+ }),
813+ ),
814+ );
815+
816+ effectIt.effect("does not resurrect an icon after a confirmed about:blank document", () =>
817+ withManager((manager) =>
818+ Effect.gen(function* () {
819+ const preview = makeFaviconWebContents();
820+ fromId.mockReturnValue(preview.webContents);
821+ const states: PreviewManager.PreviewTabState[] = [];
822+ yield* manager.subscribeStateChanges((_tabId, state) =>
823+ Effect.sync(() => {
824+ states.push(state);
825+ }),
826+ );
827+ yield* manager.createTab("tab_favicon_blank");
828+ yield* manager.registerWebview("tab_favicon_blank", 42);
829+ preview.listeners.get("page-favicon-updated")?.(
830+ {} as never,
831+ ["http://localhost:3200/favicon.png"] as never,
832+ );
833+ yield* settle(() => states.at(-1)?.favicon !== undefined);
834+
835+ preview.setUrl("about:blank");
836+ preview.listeners.get("did-navigate")?.({} as never);
837+ yield* settle(() => states.at(-1)?.navStatus.kind === "Idle");
838+ expect(states.at(-1)?.favicon).toBeUndefined();
839+
840+ preview.setUrl("http://localhost:3200/");
841+ preview.listeners.get("did-navigate")?.({} as never);
842+ yield* settle(() => states.at(-1)?.navStatus.kind === "Success");
843+ expect(states.at(-1)?.favicon).toBeUndefined();
844+ }),
845+ ),
846+ );
847+
848+ effectIt.effect("clears a published favicon when a replacement webview attaches", () =>
849+ withManager((manager) =>
850+ Effect.gen(function* () {
851+ const initial = makeFaviconWebContents({ id: 42 });
852+ const replacement = makeFaviconWebContents({ id: 43 });
853+ fromId.mockImplementation((id?: number) => {
854+ if (id === 42) return initial.webContents;
855+ if (id === 43) return replacement.webContents;
856+ return null;
857+ });
858+ const states: PreviewManager.PreviewTabState[] = [];
859+ yield* manager.subscribeStateChanges((_tabId, state) =>
860+ Effect.sync(() => {
861+ states.push(state);
862+ }),
863+ );
864+ yield* manager.createTab("tab_favicon_replace");
865+ yield* manager.registerWebview("tab_favicon_replace", 42);
866+ initial.listeners.get("page-favicon-updated")?.(
867+ {} as never,
868+ ["http://localhost:3200/favicon.png"] as never,
869+ );
870+ yield* settle(() => states.at(-1)?.favicon !== undefined);
871+
872+ yield* manager.registerWebview("tab_favicon_replace", 43);
873+
874+ expect(states.at(-1)?.webContentsId).toBe(43);
875+ expect(states.at(-1)?.favicon).toBeUndefined();
876+ }),
877+ ),
878+ );
879+
880+ effectIt.effect("ignores an old capture that completes after webview replacement", () =>
881+ withManager((manager) =>
882+ Effect.gen(function* () {
883+ let resolveFetch!: (response: Response) => void;
884+ const initial = makeFaviconWebContents({
885+ id: 42,
886+ fetch: () =>
887+ new Promise<Response>((resolve) => {
888+ resolveFetch = resolve;
889+ }),
890+ });
891+ const replacement = makeFaviconWebContents({ id: 43 });
892+ fromId.mockImplementation((id?: number) =>
893+ id === 42 ? initial.webContents : id === 43 ? replacement.webContents : null,
894+ );
895+ const states: PreviewManager.PreviewTabState[] = [];
896+ yield* manager.subscribeStateChanges((_tabId, state) =>
897+ Effect.sync(() => {
898+ states.push(state);
899+ }),
900+ );
901+ yield* manager.createTab("tab_favicon_late_replace");
902+ yield* manager.registerWebview("tab_favicon_late_replace", 42);
903+ initial.listeners.get("page-favicon-updated")?.(
904+ {} as never,
905+ ["http://localhost:3200/favicon.png"] as never,
906+ );
907+ yield* settle(() => initial.fetch.mock.calls.length === 1);
908+
909+ yield* manager.registerWebview("tab_favicon_late_replace", 43);
910+ resolveFetch(
911+ new Response(new Uint8Array(makeSourcePng()), {
912+ headers: { "content-type": "image/png" },
913+ }),
914+ );
915+ yield* settle(() => false);
916+
917+ expect(states.at(-1)?.webContentsId).toBe(43);
918+ expect(
919+ states.some((state) => state.webContentsId === 43 && state.favicon !== undefined),
920+ ).toBe(false);
921+ }),
922+ ),
923+ );
924+
925+ effectIt.effect("treats a reused WebContents id as a new attachment", () =>
926+ withManager((manager) =>
927+ Effect.gen(function* () {
928+ const initial = makeFaviconWebContents({ id: 42 });
929+ const replacement = makeFaviconWebContents({ id: 42 });
930+ let active = initial.webContents;
931+ fromId.mockImplementation(() => active);
932+ const states: PreviewManager.PreviewTabState[] = [];
933+ yield* manager.subscribeStateChanges((_tabId, state) =>
934+ Effect.sync(() => {
935+ states.push(state);
936+ }),
937+ );
938+ yield* manager.createTab("tab_favicon_reused_id");
939+ yield* manager.registerWebview("tab_favicon_reused_id", 42);
940+ initial.listeners.get("page-favicon-updated")?.(
941+ {} as never,
942+ ["http://localhost:3200/favicon.png"] as never,
943+ );
944+ yield* settle(() => states.at(-1)?.favicon !== undefined);
945+
946+ active = replacement.webContents;
947+ yield* manager.registerWebview("tab_favicon_reused_id", 42);
948+
949+ expect(states.at(-1)?.favicon).toBeUndefined();
950+ expect(initial.off).toHaveBeenCalled();
951+ expect(replacement.listeners.has("page-favicon-updated")).toBe(true);
952+ }),
953+ ),
954+ );
955+
956+ effectIt.effect("preserves a favicon when the active attachment registers again", () =>
957+ withManager((manager) =>
958+ Effect.gen(function* () {
959+ const preview = makeFaviconWebContents();
960+ fromId.mockReturnValue(preview.webContents);
961+ const states: PreviewManager.PreviewTabState[] = [];
962+ yield* manager.subscribeStateChanges((_tabId, state) =>
963+ Effect.sync(() => {
964+ states.push(state);
965+ }),
966+ );
967+ yield* manager.createTab("tab_favicon_reregister");
968+ yield* manager.registerWebview("tab_favicon_reregister", 42);
969+ preview.listeners.get("page-favicon-updated")?.(
970+ {} as never,
971+ ["http://localhost:3200/favicon.png"] as never,
972+ );
973+ yield* settle(() => states.at(-1)?.favicon !== undefined);
974+
975+ yield* manager.registerWebview("tab_favicon_reregister", 42);
976+
977+ expect(states.at(-1)?.favicon?.dataUrl).toBe(TEST_FAVICON);
978+ }),
979+ ),
980+ );
981+
378982 effectIt.effect("mirrors Electron's effective zoom across registration and navigation", () =>
379983 withManager((manager) =>
380984 Effect.gen(function* () {
apps/desktop/src/preview/Manager.ts+204−21
@@ -8,6 +8,7 @@ · 7 unmodified lines
88 import type {
99 DesktopPreviewAnnotationTheme,
1010 DesktopPreviewColorScheme,
11+ DesktopPreviewFavicon,
1112 DesktopPreviewPointerEvent,
1213 PreviewAnnotationPayload,
1314 PreviewAnnotationRect,
@@ -62,6 +63,7 @@ import { · 48 unmodified lines
6263 import { isPreviewAnnotationPayload } from "./PickedElementPayload.ts";
6364 import { playwrightInjectedRuntimeInstallExpression } from "./PlaywrightInjectedRuntime.ts";
6465 import { makePreviewAutomationKeySequence } from "./PreviewKeyboard.ts";
66+import { captureFavicon, safeHttpOrigin, selectFaviconCandidates } from "./FaviconCapture.ts";
6567
6668 export type PreviewNavStatus =
6769 | { kind: "Idle" }
@@ -85,6 +87,7 @@ export interface PreviewTabState { · 17 unmodified lines
8587 pictureInPicture: boolean;
8688 colorScheme: DesktopPreviewColorScheme;
8789 controller: "human" | "agent" | "none";
90+ favicon?: DesktopPreviewFavicon;
8891 updatedAt: string;
8992 }
9093
@@ -346,7 +349,10 @@ type PreviewInputSignal = · 255 unmodified lines
346349 | { readonly kind: "key"; readonly key: string; readonly code: string };
347350
348351 interface ManagedListeners {
352+ readonly attachmentId: symbol;
353+ readonly cancelFaviconCapture: () => void;
349354 readonly scope: Scope.Closeable;
355+ readonly webContents: Electron.WebContents;
350356 }
351357
352358 type FrameCaptureConsumer = "picture-in-picture" | "recording";
@@ -613,6 +619,15 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function · 260 unmodified lines
613619 );
614620 });
615621
622+ const emitIfCurrent = Effect.fn("PreviewManager.emitIfCurrent")(function* (
623+ tabId: string,
624+ state: PreviewTabState,
625+ ) {
626+ if ((yield* SynchronizedRef.get(tabsRef)).get(tabId) === state) {
627+ yield* emit(tabId, state);
628+ }
629+ });
630+
616631 const update = Effect.fn("PreviewManager.update")(function* (
617632 tabId: string,
618633 patch: Partial<PreviewTabState>,
@@ -1204,7 +1219,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function · 585 unmodified lines
12041219 copy.delete(webContentsId);
12051220 }),
12061221 ]);
1207− if (managed) yield* Scope.close(managed.scope, Exit.void).pipe(Effect.ignore);
1222+ if (managed) {
1223+ managed.cancelFaviconCapture();
1224+ yield* Scope.close(managed.scope, Exit.void).pipe(Effect.ignore);
1225+ }
12081226 });
12091227
12101228 const isAppShortcut = (input: Electron.Input): boolean =>
@@ -1268,8 +1286,23 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function · 57 unmodified lines
12681286 wc: Electron.WebContents,
12691287 ) {
12701288 const scope = yield* Scope.fork(parentScope, "sequential");
1289+ const attachmentId = Symbol();
1290+ let documentId = 0;
1291+ let nextRequestId = 0;
1292+ let activeCapture: {
1293+ readonly controller: AbortController;
1294+ readonly documentId: number;
1295+ readonly eventKey: string;
1296+ readonly requestId: number;
1297+ } | null = null;
1298+ const cancelFaviconCapture = () => {
1299+ documentId += 1;
1300+ activeCapture?.controller.abort();
1301+ activeCapture = null;
1302+ };
12711303 const syncState = Effect.fn("PreviewManager.syncWebContentsState")(function* (
12721304 preserveLoadFailure: boolean,
1305+ confirmedNavigation = false,
12731306 ) {
12741307 if (wc.isDestroyed()) return;
12751308 const zoomFactor = yield* attempt(
@@ -1282,7 +1315,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function · 6 unmodified lines
12821315 const updatedAt = yield* currentIso;
12831316 const next = yield* SynchronizedRef.modify(tabsRef, (tabs) => {
12841317 const current = tabs.get(tabId);
1285− if (!current) return [Option.none<PreviewTabState>(), tabs] as const;
1318+ if (!current || current.webContentsId !== wc.id || webContents.fromId(wc.id) !== wc) {
1319+ return [Option.none<PreviewTabState>(), tabs] as const;
1320+ }
12861321 // Electron emits did-stop-loading after did-fail-load. At that point the
12871322 // failed guest is no longer "loading", but it has not successfully
12881323 // navigated anywhere. Keep the failure until a new load actually starts.
@@ -1292,8 +1327,14 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function · 3 unmodified lines
12921327 computedNavStatus.kind === "Success"
12931328 ? current.navStatus
12941329 : computedNavStatus;
1330+ const clearFavicon =
1331+ confirmedNavigation &&
1332+ current.favicon !== undefined &&
1333+ safeHttpOrigin(current.favicon.pageUrl) !==
1334+ safeHttpOrigin(navStatus.kind === "Idle" ? wc.getURL() : navStatus.url);
1335+ const { favicon: _favicon, ...currentWithoutFavicon } = current;
12951336 const state: PreviewTabState = {
1296− ...current,
1337+ ...(clearFavicon ? currentWithoutFavicon : current),
12971338 navStatus,
12981339 canGoBack,
12991340 canGoForward,
@@ -1307,10 +1348,109 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function · 7 unmodified lines
13071348 }),
13081349 ] as const;
13091350 });
1310− if (Option.isSome(next)) yield* emit(tabId, next.value);
1351+ if (Option.isSome(next)) yield* emitIfCurrent(tabId, next.value);
13111352 });
13121353 const sync = () => runFork(syncState(true));
1313− const syncNavigation = () => runFork(syncState(false));
1354+ const syncNavigation = () => runFork(syncState(false, true));
1355+ const syncInPageNavigation = () => runFork(syncState(false));
1356+ const navigationStarted = (
1357+ event: Electron.Event<Electron.WebContentsDidStartNavigationEventParams>,
1358+ ) => {
1359+ if (event.isMainFrame && !event.isSameDocument) cancelFaviconCapture();
1360+ };
1361+ const publishFavicon = Effect.fn("PreviewManager.publishFavicon")(function* (input: {
1362+ readonly captureDocumentId: number;
1363+ readonly dataUrl: string;
1364+ readonly pageUrl: string;
1365+ readonly requestId: number;
1366+ }) {
1367+ const pageOrigin = safeHttpOrigin(input.pageUrl);
1368+ const managed = (yield* Ref.get(attachedRef)).get(wc.id);
1369+ if (
1370+ !pageOrigin ||
1371+ wc.isDestroyed() ||
1372+ webContents.fromId(wc.id) !== wc ||
1373+ managed?.attachmentId !== attachmentId ||
1374+ activeCapture?.documentId !== input.captureDocumentId ||
1375+ activeCapture.requestId !== input.requestId ||
1376+ safeHttpOrigin(wc.getURL()) !== pageOrigin
1377+ ) {
1378+ return;
1379+ }
1380+ const capturedAt = yield* currentMillis;
1381+ const updatedAt = yield* currentIso;
1382+ const next = yield* SynchronizedRef.modify(tabsRef, (tabs) => {
1383+ const current = tabs.get(tabId);
1384+ if (
1385+ !current ||
1386+ current.webContentsId !== wc.id ||
1387+ webContents.fromId(wc.id) !== wc ||
1388+ activeCapture?.documentId !== input.captureDocumentId ||
1389+ activeCapture.requestId !== input.requestId
1390+ ) {
1391+ return [Option.none<PreviewTabState>(), tabs] as const;
1392+ }
1393+ const state: PreviewTabState = {
1394+ ...current,
1395+ favicon: { dataUrl: input.dataUrl, pageUrl: pageOrigin, capturedAt },
1396+ updatedAt,
1397+ };
1398+ return [
1399+ Option.some(state),
1400+ replaceMap(tabs, (copy) => {
1401+ copy.set(tabId, state);
1402+ }),
1403+ ] as const;
1404+ });
1405+ if (Option.isSome(next)) yield* emitIfCurrent(tabId, next.value);
1406+ });
1407+ const faviconUpdated = (_event: Event, rawCandidates: ReadonlyArray<string>): void => {
1408+ const pageUrl = wc.getURL();
1409+ if (!safeHttpOrigin(pageUrl)) return;
1410+ const candidates = selectFaviconCandidates(rawCandidates);
1411+ if (candidates.length === 0) return;
1412+ const eventKey = JSON.stringify([pageUrl, ...candidates]);
1413+ if (activeCapture?.eventKey === eventKey) return;
1414+ activeCapture?.controller.abort();
1415+ const captureDocumentId = documentId;
1416+ const requestId = ++nextRequestId;
1417+ const controller = new AbortController();
1418+ activeCapture = { controller, documentId: captureDocumentId, eventKey, requestId };
1419+ runFork(
1420+ Effect.tryPromise({
1421+ try: () =>
1422+ captureFavicon({ webContents: wc, pageUrl, candidates, signal: controller.signal }),
1423+ catch: (cause) =>
1424+ new PreviewOperationError({
1425+ operation: "captureFavicon",
1426+ tabId,
1427+ webContentsId: wc.id,
1428+ cause,
1429+ }),
1430+ }).pipe(
1431+ Effect.flatMap((result) =>
1432+ result.kind === "captured"
1433+ ? publishFavicon({
1434+ captureDocumentId,
1435+ dataUrl: result.dataUrl,
1436+ pageUrl,
1437+ requestId,
1438+ })
1439+ : Effect.void,
1440+ ),
1441+ Effect.catch((error) =>
1442+ controller.signal.aborted
1443+ ? Effect.void
1444+ : Effect.logDebug("Favicon capture failed.", { error, tabId, webContentsId: wc.id }),
1445+ ),
1446+ Effect.ensuring(
1447+ Effect.sync(() => {
1448+ if (activeCapture?.requestId === requestId) activeCapture = null;
1449+ }),
1450+ ),
1451+ ),
1452+ );
1453+ };
13141454 const failed = (
13151455 _event: Event,
13161456 code: number,
@@ -1387,9 +1527,12 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function · 70 unmodified lines
13871527 yield* Scope.addFinalizer(
13881528 scope,
13891529 attempt({ operation: "detachListeners", tabId, webContentsId: wc.id }, () => {
1530+ cancelFaviconCapture();
1531+ wc.off("did-start-navigation", navigationStarted);
13901532 wc.off("did-navigate", syncNavigation);
1391− wc.off("did-navigate-in-page", syncNavigation);
1533+ wc.off("did-navigate-in-page", syncInPageNavigation);
13921534 wc.off("page-title-updated", sync);
1535+ wc.off("page-favicon-updated", faviconUpdated as never);
13931536 wc.off("did-start-loading", sync);
13941537 wc.off("did-stop-loading", sync);
13951538 wc.off("did-fail-load", failed as never);
@@ -1399,9 +1542,11 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function · 3 unmodified lines
13991542 );
14001543 const install = Effect.fn("PreviewManager.installWebContentsListeners")(function* () {
14011544 yield* attempt({ operation: "attachListeners", tabId, webContentsId: wc.id }, () => {
1545+ wc.on("did-start-navigation", navigationStarted);
14021546 wc.on("did-navigate", syncNavigation);
1403− wc.on("did-navigate-in-page", syncNavigation);
1547+ wc.on("did-navigate-in-page", syncInPageNavigation);
14041548 wc.on("page-title-updated", sync);
1549+ wc.on("page-favicon-updated", faviconUpdated as never);
14051550 wc.on("did-start-loading", sync);
14061551 wc.on("did-stop-loading", sync);
14071552 wc.on("did-fail-load", failed as never);
@@ -1418,7 +1563,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function · 10 unmodified lines
14181563 });
14191564 yield* Ref.update(attachedRef, (attached) =>
14201565 replaceMap(attached, (copy) => {
1421− copy.set(wc.id, { scope });
1566+ copy.set(wc.id, { attachmentId, cancelFaviconCapture, scope, webContents: wc });
14221567 }),
14231568 );
14241569 });
@@ -1561,6 +1706,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function · 136 unmodified lines
15611706 const mainWindow = yield* Ref.get(mainWindowRef);
15621707 if (
15631708 !wc ||
1709+ wc.isDestroyed() ||
15641710 wc.getType() !== "webview" ||
15651711 (Option.isSome(mainWindow) && wc.hostWebContents !== mainWindow.value.webContents)
15661712 ) {
@@ -1568,7 +1714,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function · 1 unmodified line
15681714 }
15691715 const attached = yield* Ref.get(attachedRef);
15701716 const annotationTheme = yield* Ref.get(annotationThemeRef);
1571− if (tab.webContentsId === webContentsId && attached.has(webContentsId)) {
1717+ const currentAttachment = attached.get(webContentsId);
1718+ if (tab.webContentsId === webContentsId && currentAttachment?.webContents === wc) {
15721719 const zoomFactor = yield* attempt(
15731720 { operation: "registerWebview.getZoomFactor", tabId, webContentsId },
15741721 () => wc.getZoomFactor(),
@@ -1580,7 +1727,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function · 5 unmodified lines
15801727 return;
15811728 }
15821729 const replacedWebContentsId =
1583− tab.webContentsId != null && tab.webContentsId !== webContentsId ? tab.webContentsId : null;
1730+ tab.webContentsId != null &&
1731+ (tab.webContentsId !== webContentsId || currentAttachment?.webContents !== wc)
1732+ ? tab.webContentsId
1733+ : null;
15841734 if (replacedWebContentsId !== null) {
15851735 yield* Effect.all(
15861736 [
@@ -1627,8 +1777,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function · 40 unmodified lines
16271777 ] as const;
16281778 }
16291779 const pendingUrl = current.navStatus.kind === "Loading" ? current.navStatus.url : null;
1780+ const { favicon: _favicon, ...currentWithoutFavicon } = current;
16301781 const next: PreviewTabState = {
1631− ...current,
1782+ ...currentWithoutFavicon,
16321783 webContentsId,
16331784 navStatus: pendingUrl === null ? computeNavStatus(wc) : current.navStatus,
16341785 canGoBack: wc.navigationHistory.canGoBack(),
@@ -1707,6 +1858,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function · 72 unmodified lines
17071858 pictureInPicture: current?.pictureInPicture ?? false,
17081859 colorScheme: current?.colorScheme ?? "system",
17091860 controller: current?.controller ?? "none",
1861+ ...(current?.favicon ? { favicon: current.favicon } : {}),
17101862 updatedAt,
17111863 };
17121864 return [
@@ -1718,17 +1870,48 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function · 5 unmodified lines
17181870 });
17191871 yield* emit(tabId, pending);
17201872 if (pending.webContentsId == null) return;
1721− const wc = webContents.fromId(pending.webContentsId);
1722− if (!wc) {
1723− const detached = { ...pending, webContentsId: null };
1724− yield* SynchronizedRef.update(tabsRef, (tabs) =>
1725− tabs.get(tabId)?.webContentsId !== pending.webContentsId
1726− ? tabs
1727− : replaceMap(tabs, (copy) => {
1728− copy.set(tabId, detached);
1729− }),
1873+ const webContentsId = pending.webContentsId;
1874+ const wc = webContents.fromId(webContentsId);
1875+ if (!wc || wc.isDestroyed()) {
1876+ const expectedAttachment = (yield* Ref.get(attachedRef)).get(webContentsId);
1877+ yield* withTabLifecycleLock(
1878+ tabId,
1879+ Effect.gen(function* () {
1880+ const currentTab = (yield* SynchronizedRef.get(tabsRef)).get(tabId);
1881+ const currentAttachment = (yield* Ref.get(attachedRef)).get(webContentsId);
1882+ const currentWebContents = webContents.fromId(webContentsId);
1883+ if (
1884+ currentTab?.webContentsId !== webContentsId ||
1885+ currentAttachment !== expectedAttachment ||
1886+ (currentWebContents && !currentWebContents.isDestroyed())
1887+ ) {
1888+ return;
1889+ }
1890+ yield* Effect.all(
1891+ [
1892+ detachControlSession(webContentsId),
1893+ detachListeners(webContentsId),
1894+ cancelPickElement(tabId),
1895+ ],
1896+ { concurrency: 3, discard: true },
1897+ );
1898+ const detached = yield* SynchronizedRef.modify(tabsRef, (tabs) => {
1899+ const current = tabs.get(tabId);
1900+ if (current?.webContentsId !== webContentsId) {
1901+ return [Option.none<PreviewTabState>(), tabs] as const;
1902+ }
1903+ const { favicon: _favicon, ...currentWithoutFavicon } = current;
1904+ const next: PreviewTabState = { ...currentWithoutFavicon, webContentsId: null };
1905+ return [
1906+ Option.some(next),
1907+ replaceMap(tabs, (copy) => {
1908+ copy.set(tabId, next);
1909+ }),
1910+ ] as const;
1911+ });
1912+ if (Option.isSome(detached)) yield* emitIfCurrent(tabId, detached.value);
1913+ }),
17301914 );
1731− yield* emit(tabId, detached);
17321915 return;
17331916 }
17341917 if (wc.getURL() === url) {
apps/web/src/browser/browserTargetResolver.test.ts+131−0
@@ -180,4 +180,135 @@ describe("browser target resolver", () => { · 179 unmodified lines
180180 const { resolveDiscoveredServerUrl } = await import("./browserTargetResolver");
181181 expect(resolveDiscoveredServerUrl(EnvironmentId.make("environment-1"), " ")).toBe(" ");
182182 });
183+
184+ it("classifies exact private IPv4 and IPv6 boundaries", async () => {
185+ const { isPrivateNetworkHost } = await import("./browserTargetResolver");
186+ const privateHosts = [
187+ "0.0.0.0",
188+ "10.0.0.0",
189+ "10.255.255.255",
190+ "100.64.0.0",
191+ "100.127.255.255",
192+ "127.0.0.0",
193+ "127.255.255.255",
194+ "169.254.0.0",
195+ "169.254.255.255",
196+ "172.16.0.0",
197+ "172.31.255.255",
198+ "192.168.0.0",
199+ "192.168.255.255",
200+ "198.18.0.0",
201+ "198.19.255.255",
202+ "fc00::",
203+ "fdff:ffff:ffff:ffff:ffff:ffff:ffff:ffff",
204+ "fe80::",
205+ "febf:ffff:ffff:ffff:ffff:ffff:ffff:ffff",
206+ "::ffff:192.168.1.1",
207+ "localhost.",
208+ "localhost..",
209+ "devbox.",
210+ "devbox..",
211+ "printer.local.",
212+ "printer.local..",
213+ "printer.home.arpa.",
214+ "printer.home.arpa..",
215+ "devbox.example.ts.net.",
216+ "devbox.example.ts.net..",
217+ ];
218+ const publicHosts = [
219+ "1.0.0.0",
220+ "100.63.255.255",
221+ "100.128.0.0",
222+ "169.253.255.255",
223+ "169.255.0.0",
224+ "172.15.255.255",
225+ "172.32.0.0",
226+ "192.167.255.255",
227+ "192.169.0.0",
228+ "198.17.255.255",
229+ "198.20.0.0",
230+ "fbff:ffff::",
231+ "fec0::",
232+ "2001:4860:4860::8888",
233+ "::ffff:8.8.8.8",
234+ "example.com.",
235+ ];
236+ expect(privateHosts.filter((host) => !isPrivateNetworkHost(host))).toEqual([]);
237+ expect(publicHosts.filter(isPrivateNetworkHost)).toEqual([]);
238+ });
239+
240+ it("allows only globally routable hosts to reach a public favicon provider", async () => {
241+ const { isPublicFaviconHost } = await import("./browserTargetResolver");
242+ const nonPublic = [
243+ "192.0.0.0",
244+ "192.0.0.255",
245+ "192.0.2.0",
246+ "192.0.2.255",
247+ "192.88.99.0",
248+ "192.88.99.255",
249+ "198.51.100.0",
250+ "198.51.100.255",
251+ "203.0.113.0",
252+ "203.0.113.255",
253+ "224.0.0.0",
254+ "255.255.255.255",
255+ "::2",
256+ "100::",
257+ "100::ffff:ffff:ffff:ffff",
258+ "100:0:0:1::",
259+ "100:0:0:1:ffff:ffff:ffff:ffff",
260+ "64:ff9b:1::1",
261+ "64:ff9b::a00:1",
262+ "64:ff9b::7f00:1",
263+ "64:ff9b::c0a8:101",
264+ "64:ff9b::c000:201",
265+ "2001:5::1",
266+ "2001:2::",
267+ "2001:2:0:ffff:ffff:ffff:ffff:ffff",
268+ "2001:db8::",
269+ "2001:db8:ffff:ffff:ffff:ffff:ffff:ffff",
270+ "3fff::",
271+ "3fff:fff:ffff:ffff:ffff:ffff:ffff:ffff",
272+ "5f00::1",
273+ "fec0::",
274+ "ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff",
275+ "::ffff:192.0.2.1",
276+ "app.test",
277+ "app.test..",
278+ "printer.local..",
279+ "printer.home.arpa..",
280+ "devbox.example.ts.net..",
281+ "127.0.0.1..",
282+ "127.1..",
283+ "10.1..",
284+ "172.16.1..",
285+ "192.168.1..",
286+ "service.internal",
287+ "hidden.onion",
288+ ];
289+ const publicHosts = [
290+ "191.255.255.255",
291+ "192.0.1.255",
292+ "192.0.3.0",
293+ "198.51.99.255",
294+ "198.51.101.0",
295+ "203.0.112.255",
296+ "203.0.114.0",
297+ "223.255.255.255",
298+ "1.1.1.1",
299+ "2001:4860:4860::8888",
300+ "2606:4700:4700::1111",
301+ "64:ff9b::808:808",
302+ "2001:1::1",
303+ "2001:3::1",
304+ "2001:4:112::1",
305+ "2001:20::1",
306+ "2001:30::1",
307+ "::ffff:8.8.8.8",
308+ "example.com",
309+ "example.com.",
310+ ];
311+ expect(nonPublic.filter(isPublicFaviconHost)).toEqual([]);
312+ expect(publicHosts.filter((host) => !isPublicFaviconHost(host))).toEqual([]);
313+ });
183314 });
apps/web/src/browser/browserTargetResolver.ts+121−13
@@ -8,7 +8,10 @@ import { isLoopbackHost, normalizePreviewUrl } from "@t3tools/shared/preview"; · 7 unmodified lines
88 import { readPreparedConnection } from "~/state/session";
99
1010 export const normalizeHostname = (host: string): string =>
11− host.toLowerCase().replace(/^\[|\]$/g, "");
11+ host
12+ .toLowerCase()
13+ .replace(/^\[|\]$/g, "")
14+ .replace(/\.+$/u, "");
1215
1316 const parseIpv4Address = (host: string): readonly number[] | null => {
1417 const parts = normalizeHostname(host).split(".").map(Number);
@@ -18,28 +21,91 @@ const parseIpv4Address = (host: string): readonly number[] | null => { · 3 unmodified lines
1821 : null;
1922 };
2023
24+const parseIpv4MappedIpv6Address = (host: string): readonly number[] | null => {
25+ const normalized = normalizeHostname(host);
26+ if (!normalized.startsWith("::ffff:")) return null;
27+ const suffix = normalized.slice("::ffff:".length);
28+ const dotted = parseIpv4Address(suffix);
29+ if (dotted) return dotted;
30+ const hextets = suffix.split(":");
31+ if (hextets.length !== 2 || hextets.some((part) => !/^[\da-f]{1,4}$/u.test(part))) return null;
32+ const high = Number.parseInt(hextets[0]!, 16);
33+ const low = Number.parseInt(hextets[1]!, 16);
34+ return [high >>> 8, high & 0xff, low >>> 8, low & 0xff];
35+};
36+
37+const parseIpv6Address = (host: string): readonly number[] | null => {
38+ const normalized = normalizeHostname(host);
39+ if (!normalized.includes(":")) return null;
40+ const halves = normalized.split("::");
41+ if (halves.length > 2) return null;
42+ const head = halves[0] ? halves[0].split(":") : [];
43+ const tail = halves[1] ? halves[1].split(":") : [];
44+ if ([...head, ...tail].some((part) => !/^[\da-f]{1,4}$/u.test(part))) return null;
45+ const missing = 8 - head.length - tail.length;
46+ if ((halves.length === 1 && missing !== 0) || (halves.length === 2 && missing < 1)) return null;
47+ return [...head, ...Array.from({ length: missing }, () => "0"), ...tail].map((part) =>
48+ Number.parseInt(part, 16),
49+ );
50+};
51+
52+const ipv6PrefixMatches = (
53+ address: readonly number[],
54+ prefix: readonly number[],
55+ prefixLength: number,
56+): boolean => {
57+ const fullHextets = Math.floor(prefixLength / 16);
58+ if (address.slice(0, fullHextets).some((part, index) => part !== prefix[index])) return false;
59+ const remainingBits = prefixLength % 16;
60+ if (remainingBits === 0) return true;
61+ const mask = (0xffff << (16 - remainingBits)) & 0xffff;
62+ return (address[fullHextets]! & mask) === (prefix[fullHextets]! & mask);
63+};
64+
65+const isPrivateIpv4Address = (parts: readonly number[]): boolean =>
66+ parts[0] === 0 ||
67+ parts[0] === 10 ||
68+ parts[0] === 127 ||
69+ (parts[0] === 100 && parts[1]! >= 64 && parts[1]! <= 127) ||
70+ (parts[0] === 172 && parts[1]! >= 16 && parts[1]! <= 31) ||
71+ (parts[0] === 192 && parts[1] === 168) ||
72+ (parts[0] === 169 && parts[1] === 254) ||
73+ (parts[0] === 198 && parts[1]! >= 18 && parts[1]! <= 19);
74+
75+const isSpecialPurposeIpv4Address = (parts: readonly number[]): boolean =>
76+ isPrivateIpv4Address(parts) ||
77+ parts[0]! >= 224 ||
78+ // Deliberately suppress the whole protocol-assignment block. IANA marks
79+ // .9 and .10 globally reachable, but privacy-safe false negatives are
80+ // preferable to disclosing another special-purpose address by mistake.
81+ (parts[0] === 192 && parts[1] === 0 && parts[2] === 0) ||
82+ (parts[0] === 192 && parts[1] === 0 && parts[2] === 2) ||
83+ (parts[0] === 192 && parts[1] === 88 && parts[2] === 99) ||
84+ (parts[0] === 198 && parts[1] === 51 && parts[2] === 100) ||
85+ (parts[0] === 203 && parts[1] === 0 && parts[2] === 113);
86+
2187 export const isLocalLoopbackHost = (host: string): boolean => {
2288 const normalized = normalizeHostname(host);
2389 if (normalized === "localhost" || normalized === "::1") return true;
2490 return parseIpv4Address(normalized)?.[0] === 127;
2591 };
2692
27−const isPrivateNetworkHost = (host: string): boolean => {
93+export const isPrivateNetworkHost = (host: string): boolean => {
2894 const normalized = normalizeHostname(host);
29− if (isLocalLoopbackHost(normalized) || normalized.endsWith(".local")) {
95+ if (
96+ normalized === "::" ||
97+ isLocalLoopbackHost(normalized) ||
98+ normalized.endsWith(".localhost") ||
99+ normalized.endsWith(".local") ||
100+ normalized === "home.arpa" ||
101+ normalized.endsWith(".home.arpa") ||
102+ (!normalized.includes(".") && !normalized.includes(":"))
103+ ) {
30104 return true;
31105 }
32106 if (normalized.endsWith(".ts.net")) return true;
33− const parts = parseIpv4Address(normalized);
34− if (parts) {
35− return (
36− parts[0] === 10 ||
37− (parts[0] === 100 && parts[1]! >= 64 && parts[1]! <= 127) ||
38− (parts[0] === 172 && parts[1]! >= 16 && parts[1]! <= 31) ||
39− (parts[0] === 192 && parts[1] === 168) ||
40− (parts[0] === 169 && parts[1] === 254)
41− );
42− }
107+ const parts = parseIpv4Address(normalized) ?? parseIpv4MappedIpv6Address(normalized);
108+ if (parts) return isPrivateIpv4Address(parts);
43109 const firstIpv6Token = normalized.split(":", 1)[0] ?? "";
44110 if (!normalized.includes(":") || !/^[\da-f]{1,4}$/u.test(firstIpv6Token)) return false;
45111 const firstIpv6Hextet = Number.parseInt(firstIpv6Token, 16);
@@ -49,6 +115,48 @@ const isPrivateNetworkHost = (host: string): boolean => { · 3 unmodified lines
49115 );
50116 };
51117
118+/** Whether a hostname is eligible to be disclosed to a public favicon provider. */
119+export const isPublicFaviconHost = (host: string): boolean => {
120+ // A single trailing dot is a valid absolute DNS name. Repeated trailing
121+ // dots are malformed and can conceal legacy numeric forms such as 127.1.
122+ if (host.endsWith("..")) return false;
123+ const normalized = normalizeHostname(host);
124+ if (isPrivateNetworkHost(normalized)) return false;
125+ if (
126+ [".alt", ".example", ".internal", ".invalid", ".onion", ".test"].some(
127+ (suffix) => normalized === suffix.slice(1) || normalized.endsWith(suffix),
128+ )
129+ ) {
130+ return false;
131+ }
132+ const ipv4 = parseIpv4Address(normalized) ?? parseIpv4MappedIpv6Address(normalized);
133+ if (ipv4) return !isSpecialPurposeIpv4Address(ipv4);
134+ if (!normalized.includes(":")) return true;
135+ const ipv6 = parseIpv6Address(normalized);
136+ if (!ipv6) return false;
137+ if (ipv6PrefixMatches(ipv6, [0x0064, 0xff9b, 0, 0, 0, 0, 0, 0], 96)) {
138+ const embeddedIpv4 = [ipv6[6]! >>> 8, ipv6[6]! & 0xff, ipv6[7]! >>> 8, ipv6[7]! & 0xff];
139+ return !isSpecialPurposeIpv4Address(embeddedIpv4);
140+ }
141+ const first = ipv6[0]!;
142+ if ((first & 0xe000) !== 0x2000) return false;
143+ if (ipv6PrefixMatches(ipv6, [0x2001, 0, 0, 0, 0, 0, 0, 0], 23)) {
144+ const publicProtocolAssignment =
145+ (ipv6[1] === 1 &&
146+ ipv6.slice(2, 7).every((part) => part === 0) &&
147+ [1, 2, 3].includes(ipv6[7]!)) ||
148+ ipv6PrefixMatches(ipv6, [0x2001, 3, 0, 0, 0, 0, 0, 0], 32) ||
149+ ipv6PrefixMatches(ipv6, [0x2001, 4, 0x0112, 0, 0, 0, 0, 0], 48) ||
150+ ipv6PrefixMatches(ipv6, [0x2001, 0x20, 0, 0, 0, 0, 0, 0], 28) ||
151+ ipv6PrefixMatches(ipv6, [0x2001, 0x30, 0, 0, 0, 0, 0, 0], 28);
152+ return publicProtocolAssignment;
153+ }
154+ if (ipv6PrefixMatches(ipv6, [0x2001, 0x0db8, 0, 0, 0, 0, 0, 0], 32)) return false;
155+ if (ipv6PrefixMatches(ipv6, [0x2002, 0, 0, 0, 0, 0, 0, 0], 16)) return false;
156+ if (first === 0x3fff && (ipv6[1]! & 0xf000) === 0) return false;
157+ return true;
158+};
159+
52160 const readEnvironmentUrl = (environmentId: EnvironmentId): URL => {
53161 const connection = readPreparedConnection(environmentId);
54162 if (!connection) throw new Error(`Environment ${environmentId} is not connected.`);
apps/web/src/browserFaviconLogic.test.ts+124−0
@@ -0,0 +1,124 @@
1+import { describe, expect, it } from "vite-plus/test";
2+
3+import {
4+ BROWSER_FAVICON_MAX_ENTRIES,
5+ type BrowserFaviconEntry,
6+ evictExcessFavicons,
7+ faviconKey,
8+ faviconStorageLocation,
9+ isStorableFaviconDataUrl,
10+ migratePersistedBrowserFaviconState,
11+} from "./browserFaviconLogic";
12+
13+const PNG = "data:image/png;base64,AAAA";
14+
15+function entry(capturedAt = 0): BrowserFaviconEntry {
16+ return { dataUrl: PNG, capturedAt };
17+}
18+
19+describe("browser favicon logic", () => {
20+ it("keys valid origins canonically while keeping distinct scopes separate", () => {
21+ expect(faviconKey("env:project", "http://myapp.test:3000/admin?x=1", null)).toBe(
22+ "env:project http://myapp.test:3000",
23+ );
24+ expect(faviconKey("env:project", "http://192.168.64.2:3000/", "192.168.64.2")).toBe(
25+ faviconKey("env:project", "http://localhost:3000/", "192.168.64.2"),
26+ );
27+ expect(faviconKey("env:project", "http://127.0.0.1:3000/", null)).toBe(
28+ faviconKey("env:project", "http://0.0.0.0:3000/", null),
29+ );
30+ const keys = [
31+ faviconKey("env:a", "http://localhost:3000/", null),
32+ faviconKey("env:b", "http://localhost:3000/", null),
33+ faviconKey("env:a", "http://localhost:5173/", null),
34+ faviconKey("env:a", "https://localhost:3000/", null),
35+ faviconKey("env:a", "http://192.168.1.50:3000/", "192.168.64.2"),
36+ ];
37+ expect(new Set(keys).size).toBe(keys.length);
38+ expect(faviconKey("env:a", "not a url", null)).toBeNull();
39+ expect(faviconKey("env:a", "ftp://example.com/", null)).toBeNull();
40+ expect(faviconKey("", "http://localhost/", null)).toBeNull();
41+ expect(faviconKey("env:a", "http://local:3000/", null)).not.toBe(
42+ faviconKey("env:a", "http://localhost:3000/", null),
43+ );
44+ });
45+
46+ it("retains an exact environment-host alias for offline lookup", () => {
47+ const expected = {
48+ aliases: ["192.168.64.2"],
49+ key: "env:project http://localhost:3000",
50+ };
51+ expect(
52+ faviconStorageLocation("env:project", "http://localhost:3000/app", "192.168.64.2"),
53+ ).toEqual(expected);
54+ expect(
55+ faviconStorageLocation("env:project", "http://192.168.64.2:3000/app", "192.168.64.2"),
56+ ).toEqual(expected);
57+ expect(
58+ faviconStorageLocation("env:project", "https://example.com/app", "192.168.64.2"),
59+ ).toEqual({ aliases: [], key: "env:project https://example.com:443" });
60+ expect(faviconStorageLocation("env:project", "http://localhost:3000/app", "fd00::1")).toEqual({
61+ aliases: ["fd00::1"],
62+ key: "env:project http://localhost:3000",
63+ });
64+ expect(faviconKey("env:project", "http://[2001:4860:4860::8888]/", null)).toBe(
65+ "env:project http://[2001:4860:4860::8888]:80",
66+ );
67+ });
68+
69+ it("accepts only bounded base64 PNG data", () => {
70+ expect(isStorableFaviconDataUrl(PNG)).toBe(true);
71+ expect(isStorableFaviconDataUrl("data:image/svg+xml;base64,AAAA")).toBe(false);
72+ expect(isStorableFaviconDataUrl("data:image/png;base64,")).toBe(false);
73+ expect(isStorableFaviconDataUrl("data:image/png;base64,%%%%")).toBe(false);
74+ expect(isStorableFaviconDataUrl(`data:image/png;base64,${"A".repeat(8192)}`)).toBe(false);
75+ });
76+
77+ it("evicts old entries and sanitizes hydrated state", () => {
78+ const byKey = Object.fromEntries(
79+ Array.from({ length: BROWSER_FAVICON_MAX_ENTRIES + 2 }, (_, index) => [
80+ `key-${index}`,
81+ entry(index),
82+ ]),
83+ );
84+ const result = evictExcessFavicons(byKey);
85+ expect(Object.keys(result)).toHaveLength(BROWSER_FAVICON_MAX_ENTRIES);
86+ expect(result["key-0"]).toBeUndefined();
87+ expect(result["key-1"]).toBeUndefined();
88+ expect(
89+ migratePersistedBrowserFaviconState({
90+ byKey: {
91+ "env:project http://local:3000": entry(4),
92+ "env:project http://localhost:3000": entry(5),
93+ "env:project http://localhost:3003": {
94+ ...entry(6),
95+ aliases: [
96+ "192.168.64.2",
97+ "192.168.64.2",
98+ "192.168.64.3",
99+ "192.168.64.4",
100+ "192.168.64.5",
101+ "192.168.64.6",
102+ "Not Normalized",
103+ "not a host/",
104+ "x".repeat(5_000),
105+ ],
106+ },
107+ "env:project http://localhost:3001": {
108+ dataUrl: "https://example.com/icon.png",
109+ capturedAt: 6,
110+ },
111+ "env:project http://localhost:3002": { dataUrl: PNG, capturedAt: Number.NaN },
112+ },
113+ }),
114+ ).toEqual({
115+ byKey: {
116+ "env:project http://localhost:3000": entry(5),
117+ "env:project http://localhost:3003": {
118+ ...entry(6),
119+ aliases: ["192.168.64.2", "192.168.64.3", "192.168.64.4", "192.168.64.5"],
120+ },
121+ },
122+ });
123+ });
124+});
apps/web/src/browserFaviconLogic.ts+201−0
@@ -0,0 +1,201 @@
1+import { FAVICON_CAPTURED_AT_MAX, FAVICON_DATA_URL_MAX_LENGTH } from "@t3tools/contracts";
2+
3+import { isLocalLoopbackHost, normalizeHostname } from "./browser/browserTargetResolver";
4+
5+export type BrowserFaviconEntry = {
6+ dataUrl: string;
7+ capturedAt: number;
8+ aliases?: ReadonlyArray<string>;
9+};
10+
11+export const BROWSER_FAVICON_MAX_ENTRIES = 40;
12+export const BROWSER_FAVICON_MAX_KEY_LENGTH = 4_096;
13+const BROWSER_FAVICON_MAX_FUTURE_SKEW_MS = 5 * 60 * 1_000;
14+export const BROWSER_FAVICON_MAX_ALIASES_PER_ENTRY = 4;
15+const BROWSER_FAVICON_MAX_ALIAS_LENGTH = 255;
16+
17+function formatFaviconHost(host: string): string {
18+ return host.includes(":") ? `[${host}]` : host;
19+}
20+
21+export function canCanonicalizeFaviconWithoutEnvironment(url: string): boolean {
22+ try {
23+ const parsed = new URL(url);
24+ const host = normalizeHostname(parsed.hostname);
25+ return (
26+ (parsed.protocol === "http:" || parsed.protocol === "https:") &&
27+ (isLocalLoopbackHost(host) || host === "0.0.0.0")
28+ );
29+ } catch {
30+ return false;
31+ }
32+}
33+
34+export function isValidFaviconCapturedAt(value: unknown): value is number {
35+ return (
36+ typeof value === "number" &&
37+ Number.isFinite(value) &&
38+ value >= 0 &&
39+ value <= FAVICON_CAPTURED_AT_MAX &&
40+ value <= Date.now() + BROWSER_FAVICON_MAX_FUTURE_SKEW_MS
41+ );
42+}
43+
44+function migratePersistedFaviconKey(key: string): string | null {
45+ if (key.length === 0 || key.length > BROWSER_FAVICON_MAX_KEY_LENGTH) return null;
46+ const separator = key.indexOf(" ");
47+ if (separator <= 0) return null;
48+ const scope = key.slice(0, separator);
49+ const origin = key.slice(separator + 1);
50+ try {
51+ const parsed = new URL(origin);
52+ if (
53+ (parsed.protocol !== "http:" && parsed.protocol !== "https:") ||
54+ parsed.pathname !== "/" ||
55+ parsed.search !== "" ||
56+ parsed.hash !== "" ||
57+ parsed.username !== "" ||
58+ parsed.password !== ""
59+ )
60+ return null;
61+ if (normalizeHostname(parsed.hostname) !== "local") return key;
62+ const port = parsed.port || (parsed.protocol === "https:" ? "443" : "80");
63+ return `${scope} ${parsed.protocol}//localhost:${port}`;
64+ } catch {
65+ return null;
66+ }
67+}
68+
69+function persistedFaviconAlias(value: unknown): string | null {
70+ if (typeof value !== "string" || value.length > BROWSER_FAVICON_MAX_ALIAS_LENGTH) return null;
71+ const normalized = normalizeHostname(value);
72+ if (!normalized || normalized !== value) return null;
73+ try {
74+ const parsed = new URL(`http://${formatFaviconHost(normalized)}`);
75+ return normalizeHostname(parsed.hostname) === normalized ? normalized : null;
76+ } catch {
77+ return null;
78+ }
79+}
80+
81+export function faviconKey(
82+ projectRefKey: string,
83+ url: string,
84+ environmentHostname: string | null,
85+): string | null {
86+ if (projectRefKey.length === 0) return null;
87+ try {
88+ const parsed = new URL(url);
89+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
90+ const host = normalizeHostname(parsed.hostname);
91+ const canonicalHost =
92+ isLocalLoopbackHost(host) ||
93+ host === "0.0.0.0" ||
94+ (environmentHostname !== null && host === normalizeHostname(environmentHostname))
95+ ? "localhost"
96+ : host;
97+ const port = parsed.port || (parsed.protocol === "https:" ? "443" : "80");
98+ return `${projectRefKey} ${parsed.protocol}//${formatFaviconHost(canonicalHost)}:${port}`;
99+ } catch {
100+ return null;
101+ }
102+}
103+
104+export function faviconStorageLocation(
105+ projectRefKey: string,
106+ url: string,
107+ environmentHostname: string | null,
108+): { readonly aliases: ReadonlyArray<string>; readonly key: string } | null {
109+ const canonicalKey = faviconKey(projectRefKey, url, environmentHostname);
110+ if (!canonicalKey) return null;
111+ if (environmentHostname === null) return { aliases: [], key: canonicalKey };
112+ try {
113+ const parsed = new URL(url);
114+ const host = normalizeHostname(parsed.hostname);
115+ const normalizedEnvironmentHostname = normalizeHostname(environmentHostname);
116+ if (
117+ normalizedEnvironmentHostname.length === 0 ||
118+ (!isLocalLoopbackHost(host) && host !== "0.0.0.0" && host !== normalizedEnvironmentHostname)
119+ ) {
120+ return { aliases: [], key: canonicalKey };
121+ }
122+ return {
123+ aliases:
124+ isLocalLoopbackHost(normalizedEnvironmentHostname) ||
125+ normalizedEnvironmentHostname === "0.0.0.0"
126+ ? []
127+ : [normalizedEnvironmentHostname],
128+ key: canonicalKey,
129+ };
130+ } catch {
131+ return { aliases: [], key: canonicalKey };
132+ }
133+}
134+
135+export function isStorableFaviconDataUrl(value: unknown): value is string {
136+ if (
137+ typeof value !== "string" ||
138+ !value.startsWith("data:image/png;base64,") ||
139+ value.length > FAVICON_DATA_URL_MAX_LENGTH
140+ ) {
141+ return false;
142+ }
143+ const payload = value.slice("data:image/png;base64,".length);
144+ return (
145+ payload.length > 0 &&
146+ payload.length % 4 !== 1 &&
147+ !/[^a-z0-9+/=]/i.test(payload) &&
148+ /^[a-z0-9+/]*={0,2}$/i.test(payload)
149+ );
150+}
151+
152+export function evictExcessFavicons(
153+ byKey: Record<string, BrowserFaviconEntry>,
154+): Record<string, BrowserFaviconEntry> {
155+ const keys = Object.keys(byKey);
156+ if (keys.length <= BROWSER_FAVICON_MAX_ENTRIES) return byKey;
157+ return Object.fromEntries(
158+ keys
159+ .toSorted((left, right) => (byKey[right]?.capturedAt ?? 0) - (byKey[left]?.capturedAt ?? 0))
160+ .slice(0, BROWSER_FAVICON_MAX_ENTRIES)
161+ .map((key) => [key, byKey[key] as BrowserFaviconEntry]),
162+ );
163+}
164+
165+export function migratePersistedBrowserFaviconState(persistedState: unknown): {
166+ byKey: Record<string, BrowserFaviconEntry>;
167+} {
168+ if (!persistedState || typeof persistedState !== "object") return { byKey: {} };
169+ const raw = "byKey" in persistedState ? (persistedState as { byKey?: unknown }).byKey : null;
170+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return { byKey: {} };
171+ const byKey: Record<string, BrowserFaviconEntry> = {};
172+ for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
173+ const migratedKey = migratePersistedFaviconKey(key);
174+ if (!migratedKey) continue;
175+ if (!value || typeof value !== "object") continue;
176+ const { dataUrl, capturedAt } = value as Record<string, unknown>;
177+ if (!isStorableFaviconDataUrl(dataUrl)) continue;
178+ if (!isValidFaviconCapturedAt(capturedAt)) continue;
179+ const rawAliases = (value as Record<string, unknown>).aliases;
180+ const aliases = Array.isArray(rawAliases)
181+ ? [...new Set(rawAliases.map(persistedFaviconAlias).filter((alias) => alias !== null))].slice(
182+ 0,
183+ BROWSER_FAVICON_MAX_ALIASES_PER_ENTRY,
184+ )
185+ : [];
186+ const existing = byKey[migratedKey];
187+ const newest = !existing || capturedAt > existing.capturedAt;
188+ const mergedAliases = [
189+ ...new Set([
190+ ...(newest ? aliases : (existing?.aliases ?? [])),
191+ ...(newest ? (existing?.aliases ?? []) : aliases),
192+ ]),
193+ ].slice(0, BROWSER_FAVICON_MAX_ALIASES_PER_ENTRY);
194+ byKey[migratedKey] = {
195+ dataUrl: newest ? dataUrl : existing.dataUrl,
196+ capturedAt: newest ? capturedAt : existing.capturedAt,
197+ ...(mergedAliases.length > 0 ? { aliases: mergedAliases } : {}),
198+ };
199+ }
200+ return { byKey: evictExcessFavicons(byKey) };
201+}
apps/web/src/browserFaviconStore.test.ts+316−0
@@ -0,0 +1,316 @@
1+import { scopeProjectRef } from "@t3tools/client-runtime/environment";
2+import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts";
3+import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
4+
5+vi.mock("~/state/entities", () => ({ useThreadShell: () => null }));
6+vi.mock("~/state/session", () => ({ usePreparedConnection: () => ({ _tag: "None" }) }));
7+
8+import {
9+ flushPendingFaviconsForThread,
10+ lookupFavicon,
11+ mergeBrowserFaviconState,
12+ recordFaviconForProject,
13+ recordFaviconForThread,
14+ registerFaviconProjectForThread,
15+ resetBrowserFaviconsForTests,
16+ resolveBrowserFaviconStorage,
17+ useBrowserFaviconStore,
18+} from "./browserFaviconStore";
19+import {
20+ BROWSER_FAVICON_MAX_ENTRIES,
21+ migratePersistedBrowserFaviconState,
22+} from "./browserFaviconLogic";
23+
24+const environmentId = EnvironmentId.make("env-1");
25+const projectRef = scopeProjectRef(environmentId, ProjectId.make("project-1"));
26+const threadRef = { environmentId, threadId: ThreadId.make("thread-1") };
27+const PNG = "data:image/png;base64,AAAA";
28+const favicon = (pageUrl: string, capturedAt: number, dataUrl = PNG) => ({
29+ pageUrl,
30+ capturedAt,
31+ dataUrl,
32+});
33+
34+describe("browser favicon store", () => {
35+ beforeEach(resetBrowserFaviconsForTests);
36+ afterEach(() => {
37+ vi.unstubAllGlobals();
38+ vi.restoreAllMocks();
39+ });
40+
41+ it("keeps the newest capture for an origin and permits an identical later revisit", () => {
42+ const recordFavicon = vi.spyOn(useBrowserFaviconStore.getState(), "recordFavicon");
43+ recordFaviconForProject(projectRef, favicon("http://localhost:3000/", 20), null);
44+ recordFaviconForProject(
45+ projectRef,
46+ favicon("http://localhost:3000/old", 10, "data:image/png;base64,QkJCQg=="),
47+ null,
48+ );
49+ recordFaviconForProject(projectRef, favicon("http://localhost:3000/new", 30), null);
50+ recordFaviconForProject(projectRef, favicon("http://localhost:3000/new", 30), null);
51+ expect(Object.values(useBrowserFaviconStore.getState().byKey)).toEqual([
52+ { dataUrl: PNG, capturedAt: 30 },
53+ ]);
54+ expect(recordFavicon).toHaveBeenCalledTimes(2);
55+ });
56+
57+ it("does not share localhost icons across environments or physical projects", () => {
58+ const otherEnvironment = scopeProjectRef(
59+ EnvironmentId.make("env-2"),
60+ ProjectId.make("project-1"),
61+ );
62+ const otherProject = scopeProjectRef(environmentId, ProjectId.make("project-2"));
63+ recordFaviconForProject(projectRef, favicon("http://localhost:3000/", 1), null);
64+ recordFaviconForProject(otherEnvironment, favicon("http://localhost:3000/", 2), null);
65+ recordFaviconForProject(otherProject, favicon("http://localhost:3000/", 3), null);
66+ expect(Object.keys(useBrowserFaviconStore.getState().byKey)).toEqual([
67+ "env-1:project-1 http://localhost:3000",
68+ "env-2:project-1 http://localhost:3000",
69+ "env-1:project-2 http://localhost:3000",
70+ ]);
71+ });
72+
73+ it("finds a persisted environment icon after shell hydration without a live connection", () => {
74+ recordFaviconForProject(projectRef, favicon("http://192.168.64.2:3000/app", 5), "192.168.64.2");
75+ const byKey = useBrowserFaviconStore.getState().byKey;
76+ expect(lookupFavicon(byKey, null, "http://localhost:3000/app", null)).toBeNull();
77+ expect(lookupFavicon(byKey, projectRef, "http://localhost:3000/app", null)).toBe(PNG);
78+ expect(lookupFavicon(byKey, projectRef, "http://192.168.64.2:3000/app", null)).toBe(PNG);
79+ expect(lookupFavicon(byKey, projectRef, "http://192.168.64.3:3000/app", null)).toBeNull();
80+ expect(lookupFavicon(byKey, projectRef, "https://192.168.64.2:3000/app", null)).toBeNull();
81+ expect(lookupFavicon(byKey, projectRef, "http://192.168.64.2:3001/app", null)).toBeNull();
82+ expect(
83+ lookupFavicon(
84+ byKey,
85+ scopeProjectRef(environmentId, ProjectId.make("project-2")),
86+ "http://192.168.64.2:3000/app",
87+ null,
88+ ),
89+ ).toBeNull();
90+ });
91+
92+ it("finds a persisted IPv6 environment icon without a live connection", () => {
93+ recordFaviconForProject(projectRef, favicon("http://[fd00::1]:3000/app", 5), "fd00::1");
94+ const migrated = migratePersistedBrowserFaviconState({
95+ byKey: useBrowserFaviconStore.getState().byKey,
96+ }).byKey;
97+ expect(lookupFavicon(migrated, projectRef, "http://[fd00::1]:3000/app", null)).toBe(PNG);
98+ });
99+
100+ it("keeps exact host aliases attached to the newest canonical icon", () => {
101+ const olderPng = "data:image/png;base64,QkJCQg==";
102+ const newerPng = "data:image/png;base64,Q0NDQw==";
103+ recordFaviconForProject(
104+ projectRef,
105+ favicon("http://192.168.64.2:3000/", 10, olderPng),
106+ "192.168.64.2",
107+ );
108+ recordFaviconForProject(
109+ projectRef,
110+ favicon("http://192.168.64.3:3000/", 20, newerPng),
111+ "192.168.64.3",
112+ );
113+ recordFaviconForProject(
114+ projectRef,
115+ favicon("http://192.168.64.4:3000/", 15, olderPng),
116+ "192.168.64.4",
117+ );
118+ const byKey = useBrowserFaviconStore.getState().byKey;
119+ for (const host of ["192.168.64.2", "192.168.64.3", "192.168.64.4"]) {
120+ expect(lookupFavicon(byKey, projectRef, `http://${host}:3000/`, null)).toBe(newerPng);
121+ }
122+ expect(byKey["env-1:project-1 http://localhost:3000"]?.capturedAt).toBe(20);
123+ });
124+
125+ it("evicts an icon and its exact host aliases atomically", () => {
126+ recordFaviconForProject(projectRef, favicon("http://192.168.64.2:3000/", 100), "192.168.64.2");
127+ for (let index = 1; index < BROWSER_FAVICON_MAX_ENTRIES; index += 1) {
128+ recordFaviconForProject(
129+ projectRef,
130+ favicon(`https://example-${index}.com/`, 100 + index),
131+ null,
132+ );
133+ }
134+ expect(
135+ lookupFavicon(
136+ useBrowserFaviconStore.getState().byKey,
137+ projectRef,
138+ "http://192.168.64.2:3000/",
139+ null,
140+ ),
141+ ).toBe(PNG);
142+
143+ recordFaviconForProject(projectRef, favicon("https://evicts-oldest.example/", 1_000), null);
144+ expect(
145+ lookupFavicon(
146+ useBrowserFaviconStore.getState().byKey,
147+ projectRef,
148+ "http://192.168.64.2:3000/",
149+ null,
150+ ),
151+ ).toBeNull();
152+ });
153+
154+ it("retains multiple origins until project and connection metadata hydrate", () => {
155+ expect(
156+ recordFaviconForThread(threadRef, favicon("http://localhost:3000/", 1), null, undefined),
157+ ).toBe(false);
158+ expect(
159+ recordFaviconForThread(threadRef, favicon("http://localhost:5173/", 2), null, undefined),
160+ ).toBe(false);
161+ expect(
162+ Object.keys(Object.values(useBrowserFaviconStore.getState().pendingByThreadKey)[0] ?? {}),
163+ ).toHaveLength(2);
164+
165+ expect(flushPendingFaviconsForThread(threadRef, projectRef, "192.168.64.2")).toBe(true);
166+ expect(Object.keys(useBrowserFaviconStore.getState().byKey).toSorted()).toEqual([
167+ "env-1:project-1 http://localhost:3000",
168+ "env-1:project-1 http://localhost:5173",
169+ ]);
170+ expect(useBrowserFaviconStore.getState().pendingByThreadKey).toEqual({});
171+ });
172+
173+ it("persists unambiguous loopback captures while the environment is offline", () => {
174+ expect(
175+ recordFaviconForThread(
176+ threadRef,
177+ favicon("http://localhost:3000/", 1),
178+ projectRef,
179+ undefined,
180+ ),
181+ ).toBe(true);
182+ expect(useBrowserFaviconStore.getState().byKey).toEqual({
183+ "env-1:project-1 http://localhost:3000": { dataUrl: PNG, capturedAt: 1 },
184+ });
185+
186+ recordFaviconForThread(
187+ threadRef,
188+ favicon("http://192.168.64.2:5173/", 2),
189+ projectRef,
190+ undefined,
191+ );
192+ expect(flushPendingFaviconsForThread(threadRef, projectRef, undefined)).toBe(false);
193+ expect(Object.values(useBrowserFaviconStore.getState().pendingByThreadKey)[0]).toBeDefined();
194+ });
195+
196+ it("keeps pending captures in store-owned state independent of bridge lifetime", () => {
197+ recordFaviconForThread(threadRef, favicon("http://localhost:3000/", 10), null, undefined);
198+ const pendingAfterUnmount = useBrowserFaviconStore.getState().pendingByThreadKey;
199+ useBrowserFaviconStore.setState({ pendingByThreadKey: pendingAfterUnmount });
200+ flushPendingFaviconsForThread(threadRef, projectRef, "localhost");
201+ expect(useBrowserFaviconStore.getState().byKey).toEqual({
202+ "env-1:project-1 http://localhost:3000": { dataUrl: PNG, capturedAt: 10 },
203+ });
204+ });
205+
206+ it("flushes and resolves a pending draft-thread favicon after physical project registration", () => {
207+ recordFaviconForThread(threadRef, favicon("http://localhost:8025/", 10), null, undefined);
208+ registerFaviconProjectForThread(threadRef, projectRef);
209+ const registered = useBrowserFaviconStore.getState().projectRefByThreadKey["env-1:thread-1"];
210+ expect(registered).toEqual(projectRef);
211+ expect(flushPendingFaviconsForThread(threadRef, registered!, undefined)).toBe(true);
212+ expect(
213+ lookupFavicon(
214+ useBrowserFaviconStore.getState().byKey,
215+ registered!,
216+ "http://localhost:8025/",
217+ null,
218+ ),
219+ ).toBe(PNG);
220+ });
221+
222+ it("bounds pending memory by origin and thread", () => {
223+ for (let thread = 0; thread < 22; thread += 1) {
224+ for (let port = 3000; port < 3012; port += 1) {
225+ recordFaviconForThread(
226+ { environmentId, threadId: ThreadId.make(`thread-${thread}`) },
227+ favicon(`http://localhost:${port}/`, port),
228+ null,
229+ undefined,
230+ );
231+ }
232+ }
233+ const pending = useBrowserFaviconStore.getState().pendingByThreadKey;
234+ expect(Object.keys(pending)).toHaveLength(20);
235+ expect(Object.values(pending).every((byOrigin) => Object.keys(byOrigin).length === 10)).toBe(
236+ true,
237+ );
238+ });
239+
240+ it("sanitizes hydrated state while preserving actions and transient pending data", () => {
241+ recordFaviconForThread(threadRef, favicon("http://localhost:3000/", 1), null, undefined);
242+ const current = useBrowserFaviconStore.getState();
243+ const merged = mergeBrowserFaviconState(
244+ {
245+ byKey: {
246+ "env-1:project-1 http://localhost:3000": { dataUrl: PNG, capturedAt: 2 },
247+ "env-1:project-1 http://localhost:3001": { dataUrl: "bad", capturedAt: 3 },
248+ "env-1:project-1 http://localhost:3002": { dataUrl: PNG, capturedAt: 1e308 },
249+ ["x".repeat(5_000)]: { dataUrl: PNG, capturedAt: 4 },
250+ },
251+ },
252+ current,
253+ );
254+ expect(merged.byKey).toEqual({
255+ "env-1:project-1 http://localhost:3000": { dataUrl: PNG, capturedAt: 2 },
256+ });
257+ expect(merged.pendingByThreadKey).toEqual(current.pendingByThreadKey);
258+ expect(typeof merged.recordFavicon).toBe("function");
259+ });
260+
261+ it("falls back to memory when localStorage access throws", () => {
262+ vi.stubGlobal(
263+ "window",
264+ Object.defineProperty({}, "localStorage", {
265+ get: () => {
266+ throw new Error("storage blocked");
267+ },
268+ }),
269+ );
270+ const storage = resolveBrowserFaviconStorage();
271+ storage.setItem("key", "value");
272+ expect(storage.getItem("key")).toBe("value");
273+ });
274+
275+ it("falls back to memory when localStorage operations throw", () => {
276+ vi.stubGlobal("window", {
277+ localStorage: {
278+ getItem: vi.fn(() => {
279+ throw new Error("read blocked");
280+ }),
281+ setItem: vi.fn(() => {
282+ throw new Error("quota exceeded");
283+ }),
284+ removeItem: vi.fn(() => {
285+ throw new Error("remove blocked");
286+ }),
287+ },
288+ });
289+ const storage = resolveBrowserFaviconStorage();
290+
291+ storage.setItem("key", "value");
292+ expect(storage.getItem("key")).toBe("value");
293+ storage.removeItem("key");
294+ expect(storage.getItem("key")).toBeNull();
295+ });
296+
297+ it("keeps the memory shadow authoritative after an asymmetric primary write failure", () => {
298+ vi.stubGlobal("window", {
299+ localStorage: {
300+ getItem: vi.fn(() => "stale"),
301+ setItem: vi.fn(() => {
302+ throw new Error("quota exceeded");
303+ }),
304+ removeItem: vi.fn(() => {
305+ throw new Error("remove blocked");
306+ }),
307+ },
308+ });
309+ const storage = resolveBrowserFaviconStorage();
310+
311+ storage.setItem("key", "fresh");
312+ expect(storage.getItem("key")).toBe("fresh");
313+ storage.removeItem("key");
314+ expect(storage.getItem("key")).toBeNull();
315+ });
316+});
apps/web/src/browserFaviconStore.ts+342−0
@@ -0,0 +1,342 @@
1+import {
2+ scopedProjectKey,
3+ scopedThreadKey,
4+ scopeProjectRef,
5+} from "@t3tools/client-runtime/environment";
6+import type { DesktopPreviewFavicon, ScopedProjectRef, ScopedThreadRef } from "@t3tools/contracts";
7+import * as Option from "effect/Option";
8+import { useMemo } from "react";
9+import { create } from "zustand";
10+import { createJSONStorage, persist } from "zustand/middleware";
11+
12+import { normalizeHostname } from "~/browser/browserTargetResolver";
13+import { useThreadShell } from "~/state/entities";
14+import { usePreparedConnection } from "~/state/session";
15+
16+import {
17+ BROWSER_FAVICON_MAX_ALIASES_PER_ENTRY,
18+ type BrowserFaviconEntry,
19+ canCanonicalizeFaviconWithoutEnvironment,
20+ evictExcessFavicons,
21+ faviconKey,
22+ faviconStorageLocation,
23+ isStorableFaviconDataUrl,
24+ isValidFaviconCapturedAt,
25+ migratePersistedBrowserFaviconState,
26+} from "./browserFaviconLogic";
27+import { createMemoryStorage, type StateStorage } from "./lib/storage";
28+
29+const BROWSER_FAVICON_STORAGE_KEY = "t3code:browser-favicons:v1";
30+const MAX_PENDING_ORIGINS_PER_THREAD = 10;
31+const MAX_PENDING_THREADS = 20;
32+const MAX_REGISTERED_THREADS = 100;
33+
34+type PendingFavicon = DesktopPreviewFavicon;
35+type PendingFaviconsByOrigin = Record<string, PendingFavicon>;
36+
37+export interface BrowserFaviconStoreState {
38+ byKey: Record<string, BrowserFaviconEntry>;
39+ /** Capture buffering only. */
40+ pendingByThreadKey: Record<string, PendingFaviconsByOrigin>;
41+ /** Non-persisted fallback for draft/background threads without a hydrated shell. */
42+ projectRefByThreadKey: Record<string, ScopedProjectRef>;
43+ recordFavicon: (
44+ key: string,
45+ dataUrl: string,
46+ capturedAt: number,
47+ aliases?: ReadonlyArray<string>,
48+ ) => void;
49+}
50+
51+function pendingOriginKey(pageUrl: string): string | null {
52+ return faviconKey("pending", pageUrl, null)?.slice("pending ".length) ?? null;
53+}
54+
55+function addPendingFavicon(
56+ pendingByThreadKey: Record<string, PendingFaviconsByOrigin>,
57+ threadKey: string,
58+ favicon: PendingFavicon,
59+): Record<string, PendingFaviconsByOrigin> {
60+ const originKey = pendingOriginKey(favicon.pageUrl);
61+ if (!originKey) return pendingByThreadKey;
62+ const current = pendingByThreadKey[threadKey] ?? {};
63+ const existing = current[originKey];
64+ if (existing && existing.capturedAt >= favicon.capturedAt) return pendingByThreadKey;
65+ const nextForThread = {
66+ ...current,
67+ [originKey]: favicon,
68+ };
69+ const boundedForThread = Object.fromEntries(
70+ Object.entries(nextForThread)
71+ .toSorted(([, left], [, right]) => right.capturedAt - left.capturedAt)
72+ .slice(0, MAX_PENDING_ORIGINS_PER_THREAD),
73+ );
74+
75+ const withoutThread = { ...pendingByThreadKey };
76+ delete withoutThread[threadKey];
77+ return Object.fromEntries(
78+ [...Object.entries(withoutThread), [threadKey, boundedForThread]].slice(-MAX_PENDING_THREADS),
79+ );
80+}
81+
82+export function resolveBrowserFaviconStorage(): StateStorage {
83+ const fallback = createMemoryStorage();
84+ const shadowedNames = new Set<string>();
85+ let primary: Storage;
86+ try {
87+ if (typeof window === "undefined") return fallback;
88+ primary = window.localStorage;
89+ } catch {
90+ return fallback;
91+ }
92+ return {
93+ getItem: (name) => {
94+ if (shadowedNames.has(name)) return fallback.getItem(name);
95+ try {
96+ return primary.getItem(name);
97+ } catch {
98+ return fallback.getItem(name);
99+ }
100+ },
101+ setItem: (name, value) => {
102+ fallback.setItem(name, value);
103+ try {
104+ primary.setItem(name, value);
105+ shadowedNames.delete(name);
106+ } catch {
107+ shadowedNames.add(name);
108+ }
109+ },
110+ removeItem: (name) => {
111+ fallback.removeItem(name);
112+ try {
113+ primary.removeItem(name);
114+ shadowedNames.delete(name);
115+ } catch {
116+ shadowedNames.add(name);
117+ }
118+ },
119+ };
120+}
121+
122+export const useBrowserFaviconStore = create<BrowserFaviconStoreState>()(
123+ persist(
124+ (set) => ({
125+ byKey: {},
126+ pendingByThreadKey: {},
127+ projectRefByThreadKey: {},
128+ recordFavicon: (key, dataUrl, capturedAt, aliases = []) =>
129+ set((state) => {
130+ if (!isStorableFaviconDataUrl(dataUrl)) return state;
131+ if (!isValidFaviconCapturedAt(capturedAt)) return state;
132+ const existing = state.byKey[key];
133+ const aliasCandidates =
134+ capturedAt > (existing?.capturedAt ?? -1)
135+ ? [...aliases, ...(existing?.aliases ?? [])]
136+ : [...(existing?.aliases ?? []), ...aliases];
137+ const nextAliases = [...new Set(aliasCandidates)].slice(
138+ 0,
139+ BROWSER_FAVICON_MAX_ALIASES_PER_ENTRY,
140+ );
141+ const aliasesUnchanged =
142+ nextAliases.length === (existing?.aliases?.length ?? 0) &&
143+ nextAliases.every((alias, index) => alias === existing?.aliases?.[index]);
144+ if (existing && capturedAt <= existing.capturedAt && aliasesUnchanged) return state;
145+ return {
146+ byKey: evictExcessFavicons({
147+ ...state.byKey,
148+ [key]: {
149+ dataUrl: capturedAt > (existing?.capturedAt ?? -1) ? dataUrl : existing!.dataUrl,
150+ capturedAt: Math.max(capturedAt, existing?.capturedAt ?? -1),
151+ ...(nextAliases.length > 0 ? { aliases: nextAliases } : {}),
152+ },
153+ }),
154+ };
155+ }),
156+ }),
157+ {
158+ name: BROWSER_FAVICON_STORAGE_KEY,
159+ version: 1,
160+ storage: createJSONStorage(resolveBrowserFaviconStorage),
161+ partialize: (state) => ({ byKey: state.byKey }),
162+ migrate: migratePersistedBrowserFaviconState,
163+ merge: mergeBrowserFaviconState,
164+ },
165+ ),
166+);
167+
168+export function mergeBrowserFaviconState(
169+ persistedState: unknown,
170+ currentState: BrowserFaviconStoreState,
171+): BrowserFaviconStoreState {
172+ return {
173+ ...currentState,
174+ ...migratePersistedBrowserFaviconState(persistedState),
175+ };
176+}
177+
178+export function registerFaviconProjectForThread(
179+ threadRef: ScopedThreadRef,
180+ projectRef: ScopedProjectRef,
181+): void {
182+ const threadKey = scopedThreadKey(threadRef);
183+ const state = useBrowserFaviconStore.getState();
184+ const current = state.projectRefByThreadKey[threadKey];
185+ if (
186+ current?.environmentId === projectRef.environmentId &&
187+ current.projectId === projectRef.projectId
188+ ) {
189+ return;
190+ }
191+ useBrowserFaviconStore.setState({
192+ projectRefByThreadKey: Object.fromEntries(
193+ [
194+ ...Object.entries(state.projectRefByThreadKey).filter(([key]) => key !== threadKey),
195+ [threadKey, projectRef],
196+ ].slice(-MAX_REGISTERED_THREADS),
197+ ),
198+ });
199+}
200+
201+export function useFaviconProjectRefForThread(threadRef: ScopedThreadRef): ScopedProjectRef | null {
202+ const shell = useThreadShell(threadRef);
203+ const shellProjectId = shell?.projectId ?? null;
204+ const shellProjectRef = useMemo(
205+ () => (shellProjectId ? scopeProjectRef(threadRef.environmentId, shellProjectId) : null),
206+ [shellProjectId, threadRef.environmentId],
207+ );
208+ const registered = useBrowserFaviconStore(
209+ (state) => state.projectRefByThreadKey[scopedThreadKey(threadRef)] ?? null,
210+ );
211+ return shellProjectRef ?? registered;
212+}
213+
214+export function recordFaviconForProject(
215+ projectRef: ScopedProjectRef,
216+ favicon: DesktopPreviewFavicon,
217+ environmentHostname: string | null,
218+): boolean {
219+ if (!isStorableFaviconDataUrl(favicon.dataUrl) || !isValidFaviconCapturedAt(favicon.capturedAt)) {
220+ return false;
221+ }
222+ const location = faviconStorageLocation(
223+ scopedProjectKey(projectRef),
224+ favicon.pageUrl,
225+ environmentHostname,
226+ );
227+ if (!location) return false;
228+ const state = useBrowserFaviconStore.getState();
229+ const existing = state.byKey[location.key];
230+ if (
231+ existing &&
232+ existing.capturedAt >= favicon.capturedAt &&
233+ location.aliases.every((alias) => existing.aliases?.includes(alias))
234+ )
235+ return true;
236+ state.recordFavicon(location.key, favicon.dataUrl, favicon.capturedAt, location.aliases);
237+ return true;
238+}
239+
240+export function recordFaviconForThread(
241+ threadRef: ScopedThreadRef,
242+ favicon: DesktopPreviewFavicon,
243+ projectRef: ScopedProjectRef | null,
244+ environmentHostname: string | undefined,
245+): boolean {
246+ if (
247+ !isStorableFaviconDataUrl(favicon.dataUrl) ||
248+ !isValidFaviconCapturedAt(favicon.capturedAt) ||
249+ !pendingOriginKey(favicon.pageUrl)
250+ )
251+ return false;
252+ const hostname =
253+ environmentHostname !== undefined
254+ ? environmentHostname
255+ : canCanonicalizeFaviconWithoutEnvironment(favicon.pageUrl)
256+ ? null
257+ : undefined;
258+ if (
259+ projectRef &&
260+ hostname !== undefined &&
261+ recordFaviconForProject(projectRef, favicon, hostname)
262+ ) {
263+ return true;
264+ }
265+ const threadKey = scopedThreadKey(threadRef);
266+ const state = useBrowserFaviconStore.getState();
267+ const pendingByThreadKey = addPendingFavicon(state.pendingByThreadKey, threadKey, favicon);
268+ if (pendingByThreadKey !== state.pendingByThreadKey) {
269+ useBrowserFaviconStore.setState({ pendingByThreadKey });
270+ }
271+ return false;
272+}
273+
274+export function flushPendingFaviconsForThread(
275+ threadRef: ScopedThreadRef,
276+ projectRef: ScopedProjectRef,
277+ environmentHostname: string | undefined,
278+): boolean {
279+ const threadKey = scopedThreadKey(threadRef);
280+ const pending = useBrowserFaviconStore.getState().pendingByThreadKey[threadKey];
281+ if (!pending) return true;
282+ const remaining = Object.fromEntries(
283+ Object.entries(pending).filter(([, favicon]) => {
284+ const hostname =
285+ environmentHostname !== undefined
286+ ? environmentHostname
287+ : canCanonicalizeFaviconWithoutEnvironment(favicon.pageUrl)
288+ ? null
289+ : undefined;
290+ return hostname === undefined || !recordFaviconForProject(projectRef, favicon, hostname);
291+ }),
292+ );
293+ useBrowserFaviconStore.setState((state) => {
294+ const pendingByThreadKey = { ...state.pendingByThreadKey };
295+ if (Object.keys(remaining).length === 0) delete pendingByThreadKey[threadKey];
296+ else pendingByThreadKey[threadKey] = remaining;
297+ return { pendingByThreadKey };
298+ });
299+ return Object.keys(remaining).length === 0;
300+}
301+
302+export function useFaviconForThreadUrl(threadRef: ScopedThreadRef, url: string): string | null {
303+ const projectRef = useFaviconProjectRefForThread(threadRef);
304+ const preparedConnection = usePreparedConnection(threadRef.environmentId);
305+ const environmentHostname = Option.isSome(preparedConnection)
306+ ? new URL(preparedConnection.value.httpBaseUrl).hostname
307+ : null;
308+ return useBrowserFaviconStore((state) =>
309+ lookupFavicon(state.byKey, projectRef, url, environmentHostname),
310+ );
311+}
312+
313+export function lookupFavicon(
314+ byKey: Record<string, BrowserFaviconEntry>,
315+ projectRef: ScopedProjectRef | null,
316+ url: string,
317+ environmentHostname: string | null,
318+): string | null {
319+ const key = projectRef
320+ ? faviconKey(scopedProjectKey(projectRef), url, environmentHostname)
321+ : null;
322+ if (!key) return null;
323+ const direct = byKey[key];
324+ if (direct) return direct.dataUrl;
325+ try {
326+ const requestedHost = normalizeHostname(new URL(url).hostname);
327+ const localKey = faviconKey(scopedProjectKey(projectRef!), url, requestedHost);
328+ const localEntry = localKey ? byKey[localKey] : null;
329+ return localEntry?.aliases?.includes(requestedHost) ? localEntry.dataUrl : null;
330+ } catch {
331+ return null;
332+ }
333+}
334+
335+export function resetBrowserFaviconsForTests(): void {
336+ useBrowserFaviconStore.setState({
337+ byKey: {},
338+ pendingByThreadKey: {},
339+ projectRefByThreadKey: {},
340+ });
341+ useBrowserFaviconStore.persist.clearStorage();
342+}
apps/web/src/components/ChatView.tsx+12−3
@@ -179,6 +179,7 @@ import { · 178 unmodified lines
179179 } from "~/projectScripts";
180180 import { newDraftId, newMessageId, newThreadId } from "~/lib/utils";
181181 import { useBrowserHistoryStore } from "~/browserHistoryStore";
182+import { registerFaviconProjectForThread } from "~/browserFaviconStore";
182183 import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels";
183184 import { NO_PROVIDER_MODEL_SELECTION } from "../providerInstances";
184185 import {
@@ -1704,9 +1705,11 @@ function ChatViewContent(props: ChatViewProps) { · 1519 unmodified lines
17041705 });
17051706 }, [activeThreadKey, existingOpenTerminalThreadKeys, terminalUiState.terminalOpen]);
17061707 const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null);
1707− const activeProjectRef = activeThread
1708− ? scopeProjectRef(activeThread.environmentId, activeThread.projectId)
1709− : null;
1708+ const activeProjectRef = useMemo(
1709+ () =>
1710+ activeThread ? scopeProjectRef(activeThread.environmentId, activeThread.projectId) : null,
1711+ [activeThread?.environmentId, activeThread?.projectId],
1712+ );
17101713 const activeProject = useProject(activeProjectRef);
17111714 const handleNewThreadInActiveProject = useCallback(() => {
17121715 startNewThreadForProject(activeProjectRef, handleNewThread);
@@ -1758,6 +1761,10 @@ function ChatViewContent(props: ChatViewProps) { · 45 unmodified lines
17581761 // drive the environment picker in BranchToolbar.
17591762 const allProjects = useProjects();
17601763 const primaryEnvironmentId = primaryEnvironment?.environmentId ?? null;
1764+ useEffect(() => {
1765+ if (!activeThreadRef || !activeProjectRef) return;
1766+ registerFaviconProjectForThread(activeThreadRef, activeProjectRef);
1767+ }, [activeProjectRef, activeThreadRef]);
17611768 useEffect(() => {
17621769 if (!clientSettingsHydrated || !activeThreadRef || !activeProject) return;
17631770 // Reuse the sidebar's grouping so history follows the project rows the user
@@ -6546,6 +6553,7 @@ function ChatViewContent(props: ChatViewProps) { · 4782 unmodified lines
65466553 activeSurfaceId={activeRightPanelSurface?.id ?? null}
65476554 pendingSurfaceIds={pendingFileSurfaceIds}
65486555 previewSessions={activePreviewState.sessions}
6556+ desktopByTabId={activePreviewState.desktopByTabId}
65496557 terminalLabelsById={activeTerminalLabelsById}
65506558 onActivate={activateRightPanelSurface}
65516559 onCloseSurface={closeRightPanelSurface}
@@ -6580,6 +6588,7 @@ function ChatViewContent(props: ChatViewProps) { · 28 unmodified lines
65806588 activeSurfaceId={activeRightPanelSurface?.id ?? null}
65816589 pendingSurfaceIds={pendingFileSurfaceIds}
65826590 previewSessions={activePreviewState.sessions}
6591+ desktopByTabId={activePreviewState.desktopByTabId}
65836592 terminalLabelsById={activeTerminalLabelsById}
65846593 onActivate={activateRightPanelSurface}
65856594 onCloseSurface={closeRightPanelSurface}
apps/web/src/components/RightPanelTabs.test.tsx+115−0
@@ -0,0 +1,115 @@
1+import type { DesktopPreviewFavicon, PreviewSessionSnapshot } from "@t3tools/contracts";
2+import { renderToStaticMarkup } from "react-dom/server";
3+import { describe, expect, it } from "vite-plus/test";
4+
5+import { RightPanelTabs } from "./RightPanelTabs";
6+
7+const previewSurface = {
8+ id: "browser:tab-1" as const,
9+ kind: "preview" as const,
10+ resourceId: "tab-1",
11+};
12+const secondSurface = {
13+ id: "browser:tab-2" as const,
14+ kind: "preview" as const,
15+ resourceId: "tab-2",
16+};
17+const sessions: Readonly<Record<string, PreviewSessionSnapshot>> = {
18+ "tab-1": {
19+ threadId: "thread-1",
20+ tabId: "tab-1",
21+ navStatus: { _tag: "Success", url: "http://24x.xf.local/", title: "Local site" },
22+ canGoBack: false,
23+ canGoForward: false,
24+ updatedAt: "2026-08-09T00:00:00.000Z",
25+ },
26+ "tab-2": {
27+ threadId: "thread-1",
28+ tabId: "tab-2",
29+ navStatus: { _tag: "Success", url: "http://24x.xf.local/admin", title: "Admin" },
30+ canGoBack: false,
31+ canGoForward: false,
32+ updatedAt: "2026-08-09T00:00:00.000Z",
33+ },
34+};
35+
36+const favicon = (dataUrl: string, pageUrl: string): DesktopPreviewFavicon => ({
37+ dataUrl,
38+ pageUrl,
39+ capturedAt: 1,
40+});
41+
42+function overlay(icon: DesktopPreviewFavicon | null) {
43+ return {
44+ hasWebContents: true,
45+ canGoBack: false,
46+ canGoForward: false,
47+ loading: false,
48+ zoomFactor: 1,
49+ pictureInPicture: false,
50+ colorScheme: "system" as const,
51+ controller: "none" as const,
52+ favicon: icon,
53+ };
54+}
55+
56+function renderTabs(first: DesktopPreviewFavicon | null, second?: DesktopPreviewFavicon) {
57+ return renderToStaticMarkup(
58+ <RightPanelTabs
59+ mode="inline"
60+ surfaces={second ? [previewSurface, secondSurface] : [previewSurface]}
61+ activeSurfaceId={previewSurface.id}
62+ pendingSurfaceIds={new Set()}
63+ previewSessions={sessions}
64+ desktopByTabId={{
65+ "tab-1": overlay(first),
66+ ...(second ? { "tab-2": overlay(second) } : {}),
67+ }}
68+ terminalLabelsById={new Map()}
69+ onActivate={() => undefined}
70+ onCloseSurface={() => undefined}
71+ onCloseOtherSurfaces={() => undefined}
72+ onCloseSurfacesToRight={() => undefined}
73+ onCloseAllSurfaces={() => undefined}
74+ onCopyFilePath={() => undefined}
75+ onAddBrowser={() => undefined}
76+ onAddTerminal={() => undefined}
77+ onAddPullRequest={() => undefined}
78+ onAddDiff={() => undefined}
79+ onAddFiles={() => undefined}
80+ onAddAgents={() => undefined}
81+ liveAgentCount={0}
82+ browserAvailable
83+ terminalAvailable={false}
84+ diffAvailable={false}
85+ filesAvailable={false}
86+ pullRequestAvailable={false}
87+ agentsAvailable={false}
88+ >
89+ <div>content</div>
90+ </RightPanelTabs>,
91+ );
92+}
93+
94+describe("RightPanelTabs preview favicon", () => {
95+ it("prefers a live capture and never asks Google about a private hostname", () => {
96+ const captured = renderTabs(favicon("data:image/png;base64,AAAA", "http://24x.xf.local/"));
97+ expect(captured).toContain("data:image/png;base64,AAAA");
98+ expect(captured).not.toContain("s2/favicons");
99+ expect(renderTabs(null)).not.toContain("s2/favicons");
100+ });
101+
102+ it("keeps route-specific captures isolated between live tabs on one origin", () => {
103+ const html = renderTabs(
104+ favicon("data:image/png;base64,AAAA", "http://24x.xf.local/"),
105+ favicon("data:image/png;base64,BBBB", "http://24x.xf.local/admin"),
106+ );
107+ expect(html).toContain("data:image/png;base64,AAAA");
108+ expect(html).toContain("data:image/png;base64,BBBB");
109+ });
110+
111+ it("hides a capture while the server session still describes another origin", () => {
112+ const html = renderTabs(favicon("data:image/png;base64,AAAA", "https://example.com/"));
113+ expect(html).not.toContain("data:image/png;base64,AAAA");
114+ });
115+});
apps/web/src/components/RightPanelTabs.tsx+24−12
@@ -22,6 +22,7 @@ import { · 21 unmodified lines
2222 } from "react";
2323
2424 import { isElectron } from "~/env";
25+import type { DesktopPreviewOverlay } from "~/previewStateStore";
2526 import type { RightPanelSurface } from "~/rightPanelStore";
2627 import { cn } from "~/lib/utils";
2728 import { readLocalApi } from "~/localApi";
@@ -34,6 +35,7 @@ import { useTheme } from "~/hooks/useTheme"; · 6 unmodified lines
3435 import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar";
3536
3637 import { PreviewPanelShell, type PreviewPanelMode } from "./preview/PreviewPanelShell";
38+import { FaviconImage } from "./preview/PreviewFaviconIcon";
3739 import { PierreEntryIcon } from "./chat/PierreEntryIcon";
3840
3941 interface RightPanelTabsProps {
@@ -48,6 +50,7 @@ interface RightPanelTabsProps { · 8 unmodified lines
4850 activeSurfaceId: string | null;
4951 pendingSurfaceIds: ReadonlySet<string>;
5052 previewSessions: Readonly<Record<string, PreviewSessionSnapshot>>;
53+ desktopByTabId: Readonly<Record<string, DesktopPreviewOverlay>>;
5154 terminalLabelsById: ReadonlyMap<string, string>;
5255 onActivate: (surface: RightPanelSurface) => void;
5356 onCloseSurface: (surface: RightPanelSurface) => void;
@@ -435,30 +438,35 @@ function surfaceTitle( · 381 unmodified lines
435438 }
436439 }
437440
438−function PreviewFavicon({ url }: { url: string | null }) {
439− const faviconUrl = faviconUrlForOrigin(url, 32);
440− const [failedUrl, setFailedUrl] = useState<string | null>(null);
441− if (!faviconUrl || failedUrl === faviconUrl) return <Globe2 className="size-3 shrink-0" />;
441+function PreviewFavicon({ capturedUrl, url }: { capturedUrl: string | null; url: string | null }) {
442+ const publicProviderUrl = faviconUrlForOrigin(url, 32);
442443 return (
443− <img
444− src={faviconUrl}
445− alt=""
446− aria-hidden
447− draggable={false}
448− className="size-3 shrink-0 rounded-sm"
449− onError={() => setFailedUrl(faviconUrl)}
444+ <FaviconImage
445+ sources={[capturedUrl, publicProviderUrl]}
446+ fallback={<Globe2 className="size-3 shrink-0" />}
447+ className="size-3 shrink-0 rounded-sm object-contain"
450448 />
451449 );
452450 }
453451
452+function sameOrigin(left: string, right: string): boolean {
453+ try {
454+ return new URL(left).origin === new URL(right).origin;
455+ } catch {
456+ return false;
457+ }
458+}
459+
454460 function SurfaceIcon({
455461 surface,
456462 sessions,
463+ desktopByTabId,
457464 theme,
458465 pullRequestStatuses,
459466 }: {
460467 surface: RightPanelSurface;
461468 sessions: Readonly<Record<string, PreviewSessionSnapshot>>;
469+ desktopByTabId: Readonly<Record<string, DesktopPreviewOverlay>>;
462470 theme: "light" | "dark";
463471 pullRequestStatuses: Readonly<Record<string, PullRequestTabStatus>> | undefined;
464472 }) {
@@ -466,7 +474,10 @@ function SurfaceIcon({ · 1 unmodified line
466474 case "preview": {
467475 const snapshot = surface.resourceId ? sessions[surface.resourceId] : null;
468476 const url = !snapshot || snapshot.navStatus._tag === "Idle" ? null : snapshot.navStatus.url;
469− return <PreviewFavicon url={url} />;
477+ const favicon = snapshot ? (desktopByTabId[snapshot.tabId]?.favicon ?? null) : null;
478+ const capturedUrl =
479+ favicon && url && sameOrigin(favicon.pageUrl, url) ? favicon.dataUrl : null;
480+ return <PreviewFavicon capturedUrl={capturedUrl} url={url} />;
470481 }
471482 case "diff":
472483 return <FileDiff className="size-3 shrink-0" />;
@@ -636,6 +647,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { · 163 unmodified lines
636647 <SurfaceIcon
637648 surface={surface}
638649 sessions={props.previewSessions}
650+ desktopByTabId={props.desktopByTabId}
639651 theme={resolvedTheme}
640652 pullRequestStatuses={props.pullRequestStatuses}
641653 />
apps/web/src/components/preview/PreviewEmptyState.test.tsx+6−1
@@ -1,4 +1,4 @@
1−import { EnvironmentId } from "@t3tools/contracts";
1+import { EnvironmentId, ThreadId } from "@t3tools/contracts";
22 import { renderToStaticMarkup } from "react-dom/server";
33 import { describe, expect, it, vi } from "vite-plus/test";
44
@@ -19,10 +19,14 @@ const mocks = vi.hoisted(() => ({ · 14 unmodified lines
1919 vi.mock("./useDiscoveredLocalServers", () => ({
2020 useDiscoveredLocalServers: () => mocks.servers,
2121 }));
22+vi.mock("./PreviewFaviconIcon", () => ({
23+ PreviewFaviconIcon: () => <span data-favicon-icon />,
24+}));
2225
2326 import { PreviewEmptyState } from "./PreviewEmptyState";
2427
2528 const environmentId = EnvironmentId.make("env-1");
29+const threadRef = { environmentId, threadId: ThreadId.make("thread-1") };
2630
2731 function server(port: number) {
2832 return {
@@ -41,6 +45,7 @@ function server(port: number) { · 12 unmodified lines
4145 function render(recentEntries: Array<{ url: string; lastVisitedAt: number; title?: string }>) {
4246 return renderToStaticMarkup(
4347 <PreviewEmptyState
48+ threadRef={threadRef}
4449 environmentId={environmentId}
4550 recentEntries={recentEntries}
4651 onRemoveRecent={() => undefined}
apps/web/src/components/preview/PreviewEmptyState.tsx+5−1
@@ -1,4 +1,4 @@
1−import type { EnvironmentId } from "@t3tools/contracts";
1+import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts";
22 import { Globe, History, RadioTower } from "lucide-react";
33
44 import type { BrowserHistoryEntry } from "~/browserHistoryStore";
@@ -9,6 +9,7 @@ import { PreviewRecentUrlCard } from "./PreviewRecentUrlCard"; · 4 unmodified lines
99 import { useDiscoveredLocalServers } from "./useDiscoveredLocalServers";
1010
1111 interface Props {
12+ threadRef: ScopedThreadRef;
1213 environmentId: EnvironmentId;
1314 configuredUrls?: ReadonlyArray<string> | undefined;
1415 recentlySeenUrls?: ReadonlyArray<string> | undefined;
@@ -18,6 +19,7 @@ interface Props { · 3 unmodified lines
1819 }
1920
2021 export function PreviewEmptyState({
22+ threadRef,
2123 environmentId,
2224 configuredUrls,
2325 recentlySeenUrls,
@@ -60,6 +62,7 @@ export function PreviewEmptyState({ · 36 unmodified lines
6062 {recents.map((entry) => (
6163 <PreviewRecentUrlCard
6264 key={entry.url}
65+ threadRef={threadRef}
6366 entry={entry}
6467 onOpen={() => onOpenUrl(entry.url)}
6568 onRemove={() => onRemoveRecent(entry.url)}
@@ -78,6 +81,7 @@ export function PreviewEmptyState({ · 12 unmodified lines
7881 {servers.map((server) => (
7982 <PreviewLocalServerCard
8083 key={`${server.host}:${server.port}`}
84+ threadRef={threadRef}
8185 server={server}
8286 onOpen={() => onOpenUrl(server.requestedUrl)}
8387 />
apps/web/src/components/preview/PreviewFaviconIcon.test.tsx+51−0
@@ -0,0 +1,51 @@
1+import { EnvironmentId, ThreadId } from "@t3tools/contracts";
2+import { renderToStaticMarkup } from "react-dom/server";
3+import { describe, expect, it, vi } from "vite-plus/test";
4+
5+const mocks = vi.hoisted(() => ({ favicon: null as string | null }));
6+
7+vi.mock("~/browserFaviconStore", () => ({
8+ useFaviconForThreadUrl: () => mocks.favicon,
9+}));
10+
11+import { FaviconImage, PreviewFaviconIcon, selectFaviconSource } from "./PreviewFaviconIcon";
12+
13+const threadRef = {
14+ environmentId: EnvironmentId.make("env-1"),
15+ threadId: ThreadId.make("thread-1"),
16+};
17+
18+describe("preview favicon image", () => {
19+ it("renders a captured source before later fallback sources", () => {
20+ expect(
21+ renderToStaticMarkup(
22+ <FaviconImage
23+ sources={["data:image/png;base64,AAAA", "https://public.example/icon"]}
24+ fallback={<span>fallback</span>}
25+ />,
26+ ),
27+ ).toContain('src="data:image/png;base64,AAAA"');
28+ const captured = "data:image/png;base64,AAAA";
29+ const google = "https://public.example/icon";
30+ expect(selectFaviconSource([captured, google], new Set())).toBe(captured);
31+ expect(selectFaviconSource([captured, google], new Set([captured]))).toBe(google);
32+ expect(selectFaviconSource([captured, google], new Set([captured, google]))).toBeNull();
33+ expect(selectFaviconSource(["data:image/png;base64,BBBB", google], new Set([captured]))).toBe(
34+ "data:image/png;base64,BBBB",
35+ );
36+ });
37+
38+ it("uses a stored project icon or falls back to the browser mockup", () => {
39+ mocks.favicon = null;
40+ const html = renderToStaticMarkup(
41+ <PreviewFaviconIcon threadRef={threadRef} url="http://localhost:3000/" />,
42+ );
43+ expect(html).not.toContain("<img");
44+ expect(html).toContain("rounded-[5px]");
45+ mocks.favicon = "data:image/png;base64,AAAA";
46+ const faviconHtml = renderToStaticMarkup(
47+ <PreviewFaviconIcon threadRef={threadRef} url="http://localhost:3000/" />,
48+ );
49+ expect(faviconHtml).toContain('src="data:image/png;base64,AAAA"');
50+ });
51+});
apps/web/src/components/preview/PreviewFaviconIcon.tsx+66−0
@@ -0,0 +1,66 @@
1+import type { ScopedThreadRef } from "@t3tools/contracts";
2+import { type ReactNode, useState } from "react";
3+
4+import { useFaviconForThreadUrl } from "~/browserFaviconStore";
5+import { cn } from "~/lib/utils";
6+
7+import { BrowserMockup } from "./BrowserMockup";
8+
9+export function selectFaviconSource(
10+ sources: ReadonlyArray<string>,
11+ failed: ReadonlySet<string>,
12+): string | null {
13+ return sources.find((candidate) => !failed.has(candidate)) ?? null;
14+}
15+
16+export function FaviconImage(props: {
17+ sources: ReadonlyArray<string | null | undefined>;
18+ fallback: ReactNode;
19+ className?: string | undefined;
20+}) {
21+ const sources = props.sources.filter((source): source is string => Boolean(source));
22+ return (
23+ <FaviconImageAttempt
24+ key={sources.join("\0")}
25+ sources={sources}
26+ fallback={props.fallback}
27+ className={props.className}
28+ />
29+ );
30+}
31+
32+function FaviconImageAttempt(props: {
33+ sources: ReadonlyArray<string>;
34+ fallback: ReactNode;
35+ className?: string | undefined;
36+}) {
37+ const [failed, setFailed] = useState<ReadonlySet<string>>(() => new Set());
38+ const source = selectFaviconSource(props.sources, failed);
39+ if (!source) return props.fallback;
40+ return (
41+ <img
42+ src={source}
43+ alt=""
44+ aria-hidden="true"
45+ draggable={false}
46+ className={props.className}
47+ onError={() => setFailed((current) => new Set(current).add(source))}
48+ />
49+ );
50+}
51+
52+export function PreviewFaviconIcon(props: {
53+ threadRef: ScopedThreadRef;
54+ url: string;
55+ className?: string | undefined;
56+}) {
57+ const source = useFaviconForThreadUrl(props.threadRef, props.url);
58+ const fallback = <BrowserMockup className={cn("size-7 shrink-0", props.className)} />;
59+ return (
60+ <FaviconImage
61+ sources={[source]}
62+ fallback={fallback}
63+ className={cn("size-7 shrink-0 rounded object-contain", props.className)}
64+ />
65+ );
66+}
apps/web/src/components/preview/PreviewLocalServerCard.tsx+6−3
@@ -1,12 +1,15 @@
1−import { BrowserMockup } from "./BrowserMockup";
1+import type { ScopedThreadRef } from "@t3tools/contracts";
2+
3+import { PreviewFaviconIcon } from "./PreviewFaviconIcon";
24 import type { PreviewableServer } from "./useDiscoveredLocalServers";
35
46 interface Props {
7+ threadRef: ScopedThreadRef;
58 server: PreviewableServer;
69 onOpen: () => void;
710 }
811
9−export function PreviewLocalServerCard({ server, onOpen }: Props) {
12+export function PreviewLocalServerCard({ threadRef, server, onOpen }: Props) {
1013 const subtitle = describeServer(server);
1114 return (
1215 <button
@@ -14,7 +17,7 @@ export function PreviewLocalServerCard({ server, onOpen }: Props) { · 1 unmodified line
1417 onClick={onOpen}
1518 className="group flex w-full items-center gap-3 px-3 py-3 text-left hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
1619 >
17− <BrowserMockup className="size-7 shrink-0" />
20+ <PreviewFaviconIcon threadRef={threadRef} url={server.requestedUrl} />
1821 <div className="flex min-w-0 flex-1 flex-col">
1922 <span className="truncate text-sm font-medium text-foreground">{subtitle}</span>
2023 <span className="truncate text-xs text-muted-foreground">
apps/web/src/components/preview/PreviewRecentUrlCard.tsx+5−3
@@ -1,18 +1,20 @@
1+import type { ScopedThreadRef } from "@t3tools/contracts";
12 import { X } from "lucide-react";
23
34 import { isValidHistoryTimestamp, type BrowserHistoryEntry } from "~/browserHistoryStore";
45 import { useNowMinute } from "~/hooks/useNowMinute";
56 import { formatRelativeTimeLabel } from "~/timestampFormat";
67
7−import { BrowserMockup } from "./BrowserMockup";
8+import { PreviewFaviconIcon } from "./PreviewFaviconIcon";
89
910 interface Props {
11+ threadRef: ScopedThreadRef;
1012 entry: BrowserHistoryEntry;
1113 onOpen: () => void;
1214 onRemove: () => void;
1315 }
1416
15−export function PreviewRecentUrlCard({ entry, onOpen, onRemove }: Props) {
17+export function PreviewRecentUrlCard({ threadRef, entry, onOpen, onRemove }: Props) {
1618 const parsed = new URL(entry.url);
1719 const path = parsed.pathname === "/" ? "" : parsed.pathname;
1820 const label = `${parsed.host}${path}${parsed.search}${parsed.hash}`;
@@ -27,7 +29,7 @@ export function PreviewRecentUrlCard({ entry, onOpen, onRemove }: Props) { · 8 unmodified lines
2729 onClick={onOpen}
2830 className="flex w-full items-center gap-3 px-3 py-3 pr-10 text-left hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
2931 >
30− <BrowserMockup className="size-7 shrink-0" />
32+ <PreviewFaviconIcon threadRef={threadRef} url={entry.url} />
3133 <div className="flex min-w-0 flex-1 flex-col">
3234 <span className="truncate text-sm font-medium text-foreground">
3335 {entry.title ?? label}
apps/web/src/components/preview/PreviewView.tsx+1−0
@@ -710,6 +710,7 @@ export function PreviewView({ · 709 unmodified lines
710710 ) : null}
711711 {showEmptyState ? (
712712 <PreviewEmptyState
713+ threadRef={threadRef}
713714 environmentId={threadRef.environmentId}
714715 configuredUrls={configuredUrls}
715716 recentlySeenUrls={previewState.recentlySeenUrls}
apps/web/src/components/preview/usePreviewBridge.test.ts+48−0
@@ -0,0 +1,48 @@
1+import type { DesktopPreviewTabState } from "@t3tools/contracts";
2+import { describe, expect, it } from "vite-plus/test";
3+
4+import { projectDesktopState } from "./usePreviewBridge";
5+
6+const favicon = {
7+ dataUrl: "data:image/png;base64,AAAA",
8+ pageUrl: "http://localhost:3000/app",
9+ capturedAt: 1,
10+};
11+
12+function state(navStatus: DesktopPreviewTabState["navStatus"]): DesktopPreviewTabState {
13+ return {
14+ tabId: "tab-1",
15+ webContentsId: 1,
16+ navStatus,
17+ canGoBack: false,
18+ canGoForward: false,
19+ zoomFactor: 1,
20+ pictureInPicture: false,
21+ colorScheme: "system",
22+ controller: "none",
23+ favicon,
24+ updatedAt: "2026-08-09T00:00:00.000Z",
25+ };
26+}
27+
28+describe("projectDesktopState", () => {
29+ it("shows a retained icon only while the current document has the captured origin", () => {
30+ expect(
31+ projectDesktopState(
32+ state({ kind: "Loading", url: "http://localhost:3000/reload", title: "" }),
33+ ).favicon,
34+ ).toEqual(favicon);
35+ expect(
36+ projectDesktopState(
37+ state({
38+ kind: "LoadFailed",
39+ url: "https://example.com/",
40+ title: "",
41+ code: -105,
42+ description: "failed",
43+ }),
44+ ).favicon,
45+ ).toBeNull();
46+ expect(projectDesktopState(state({ kind: "Idle" })).favicon).toBeNull();
47+ });
48+});
apps/web/src/components/preview/usePreviewBridge.ts+52−14
@@ -6,15 +6,31 @@ import type { · 5 unmodified lines
66 ScopedThreadRef,
77 ThreadId,
88 } from "@t3tools/contracts";
9−import { useEffect, useRef } from "react";
9+import { parseScopedThreadKey, scopedThreadKey } from "@t3tools/client-runtime/environment";
10+import * as Option from "effect/Option";
11+import { useEffect, useEffectEvent, useMemo, useRef } from "react";
1012
13+import {
14+ flushPendingFaviconsForThread,
15+ recordFaviconForThread,
16+ useFaviconProjectRefForThread,
17+} from "~/browserFaviconStore";
1118 import { useBrowserPointerStore } from "~/browser/browserPointerStore";
1219 import { applyPreviewDesktopState, type DesktopPreviewOverlay } from "~/previewStateStore";
1320 import { previewEnvironment } from "~/state/preview";
21+import { usePreparedConnection } from "~/state/session";
1422 import { useAtomCommand } from "~/state/use-atom-command";
1523
1624 import { previewBridge } from "./previewBridge";
1725
26+function originOf(url: string): string | null {
27+ try {
28+ return new URL(url).origin;
29+ } catch {
30+ return null;
31+ }
32+}
33+
1834 /**
1935 * Mirrors low-latency desktop state into the store and reflects navigation
2036 * events back to the server. Webview lifetime is owned by ElectronBrowserHost.
@@ -28,26 +44,36 @@ export function usePreviewBridge(input: { · 7 unmodified lines
2844 const clearBrowserPointer = useBrowserPointerStore((state) => state.clear);
2945 const reportStatus = useAtomCommand(previewEnvironment.reportStatus, "preview status report");
3046 const bridge = previewBridge;
47+ const threadKey = scopedThreadKey(threadRef);
48+ const stableThreadRef = useMemo(() => {
49+ const parsed = parseScopedThreadKey(threadKey);
50+ if (!parsed) throw new Error(`Invalid scoped thread key: ${threadKey}`);
51+ return parsed;
52+ }, [threadKey]);
53+ const projectRef = useFaviconProjectRefForThread(stableThreadRef);
54+ const preparedConnection = usePreparedConnection(stableThreadRef.environmentId);
55+ const environmentHostname = Option.isSome(preparedConnection)
56+ ? new URL(preparedConnection.value.httpBaseUrl).hostname
57+ : undefined;
3158
3259 // One bridge subscription does both jobs (mirror state + forward to
3360 // server) so the desktop bridge keeps a single listener entry per tab.
3461 const lastReportedUrl = useRef<string | null>(null);
3562 const lastReportedKind = useRef<DesktopPreviewTabState["navStatus"]["kind"] | null>(null);
3663 const lastDesktopNavStatus = useRef<DesktopPreviewTabState["navStatus"] | null>(null);
37− useEffect(() => {
38− if (!bridge || typeof window === "undefined") return;
39− lastReportedUrl.current = null;
40− lastReportedKind.current = null;
41− lastDesktopNavStatus.current = null;
42− const unsubscribe = bridge.onStateChange((changedTabId, state) => {
64+ const handleStateChange = useEffectEvent(
65+ (changedTabId: string, state: DesktopPreviewTabState): void => {
4366 if (changedTabId !== runtimeTabId) return;
4467 if (shouldClearBrowserPointer(lastDesktopNavStatus.current, state.navStatus)) {
4568 clearBrowserPointer(runtimeTabId);
4669 }
4770 lastDesktopNavStatus.current = state.navStatus;
48− applyPreviewDesktopState(threadRef, tabId, projectDesktopState(state));
71+ applyPreviewDesktopState(stableThreadRef, tabId, projectDesktopState(state));
72+ if (state.favicon) {
73+ recordFaviconForThread(stableThreadRef, state.favicon, projectRef, environmentHostname);
74+ }
4975 const reported = buildReportInput({
50− threadId: threadRef.threadId,
76+ threadId: stableThreadRef.threadId,
5177 tabId,
5278 state,
5379 lastReportedUrl: lastReportedUrl.current,
@@ -57,12 +83,22 @@ export function usePreviewBridge(input: { · 3 unmodified lines
5783 lastReportedUrl.current = reported.lastReportedUrl;
5884 lastReportedKind.current = reported.lastReportedKind;
5985 void reportStatus({
60− environmentId: threadRef.environmentId,
86+ environmentId: stableThreadRef.environmentId,
6187 input: reported.input,
6288 });
63− });
64− return unsubscribe;
65− }, [bridge, clearBrowserPointer, reportStatus, runtimeTabId, tabId, threadRef]);
89+ },
90+ );
91+ useEffect(() => {
92+ if (!bridge || typeof window === "undefined") return;
93+ lastReportedUrl.current = null;
94+ lastReportedKind.current = null;
95+ lastDesktopNavStatus.current = null;
96+ return bridge.onStateChange(handleStateChange);
97+ }, [bridge, runtimeTabId, stableThreadRef, tabId]);
98+ useEffect(() => {
99+ if (!projectRef) return;
100+ flushPendingFaviconsForThread(stableThreadRef, projectRef, environmentHostname);
101+ }, [environmentHostname, projectRef, stableThreadRef]);
66102 }
67103
68104 function shouldClearBrowserPointer(
@@ -75,7 +111,8 @@ function shouldClearBrowserPointer( · 6 unmodified lines
75111 return current.url !== previous.url;
76112 }
77113
78−function projectDesktopState(state: DesktopPreviewTabState): DesktopPreviewOverlay {
114+export function projectDesktopState(state: DesktopPreviewTabState): DesktopPreviewOverlay {
115+ const navOrigin = state.navStatus.kind === "Idle" ? null : originOf(state.navStatus.url);
79116 return {
80117 hasWebContents: state.webContentsId !== null,
81118 canGoBack: state.canGoBack,
@@ -85,6 +122,7 @@ function projectDesktopState(state: DesktopPreviewTabState): DesktopPreviewOverl · 3 unmodified lines
85122 pictureInPicture: state.pictureInPicture,
86123 colorScheme: state.colorScheme,
87124 controller: state.controller,
125+ favicon: state.favicon && originOf(state.favicon.pageUrl) === navOrigin ? state.favicon : null,
88126 };
89127 }
90128
apps/web/src/lib/favicon.test.ts+42−0
@@ -0,0 +1,42 @@
1+import { describe, expect, it } from "vite-plus/test";
2+
3+import { faviconUrlForOrigin } from "./favicon";
4+
5+describe("faviconUrlForOrigin", () => {
6+ it("never sends private origin hostnames to the public provider", () => {
7+ for (const url of [
8+ "http://localhost:3000/",
9+ "http://127.0.0.1:3000/",
10+ "http://0.0.0.0:3000/",
11+ "http://devbox:3000/",
12+ "https://24x.xf.local/",
13+ "http://printer.home.arpa/",
14+ "http://192.168.1.20:3000/",
15+ "http://[::]/",
16+ "http://[::ffff:192.168.1.20]/",
17+ "http://100.65.180.100:3000/",
18+ "https://devbox.example.ts.net/",
19+ "http://192.0.2.1/",
20+ "http://198.51.100.1/",
21+ "http://203.0.113.1/",
22+ "http://224.0.0.1/",
23+ "http://240.0.0.1/",
24+ "http://[2001:db8::1]/",
25+ "http://[ff02::1]/",
26+ "http://app.test../",
27+ "https://24x.xf.local../",
28+ "http://printer.home.arpa../",
29+ "https://devbox.example.ts.net../",
30+ "http://127.0.0.1../",
31+ "http://127.1../",
32+ "http://10.1../",
33+ "http://172.16.1../",
34+ "http://192.168.1../",
35+ ]) {
36+ expect(faviconUrlForOrigin(url)).toBeNull();
37+ }
38+ expect(faviconUrlForOrigin("https://example.com/path", 32)).toBe(
39+ "https://www.google.com/s2/favicons?domain=example.com&sz=32",
40+ );
41+ });
42+});
apps/web/src/lib/favicon.ts+3−0
@@ -1,3 +1,5 @@
1+import { isPublicFaviconHost } from "~/browser/browserTargetResolver";
2+
13 /**
24 * Favicon helpers for the preview tab strip.
35 *
@@ -13,6 +15,7 @@ export function faviconUrlForOrigin(rawUrl: string | null | undefined, size = 32 · 9 unmodified lines
1315 const url = new URL(rawUrl);
1416 if (!url.host) return null;
1517 if (url.protocol !== "http:" && url.protocol !== "https:") return null;
18+ if (!isPublicFaviconHost(url.hostname)) return null;
1619 return `${FAVICON_PROVIDER}?domain=${encodeURIComponent(url.host)}&sz=${size}`;
1720 } catch {
1821 return null;
apps/web/src/previewStateStore.test.ts+4−0
@@ -322,6 +322,7 @@ describe("previewStateStore (single-tab)", () => { · 321 unmodified lines
322322 pictureInPicture: false,
323323 colorScheme: "system",
324324 controller: "none",
325+ favicon: null,
325326 });
326327 const state = readThreadPreviewState(ref);
327328 expect(state.desktopOverlay?.canGoBack).toBe(true);
@@ -342,6 +343,7 @@ describe("previewStateStore (single-tab)", () => { · 14 unmodified lines
342343 pictureInPicture: false,
343344 colorScheme: "system",
344345 controller: "none",
346+ favicon: null,
345347 });
346348 setActivePreviewTab(ref, first.tabId);
347349
@@ -390,6 +392,7 @@ describe("previewStateStore (single-tab)", () => { · 42 unmodified lines
390392 pictureInPicture: false,
391393 colorScheme: "system",
392394 controller: "none",
395+ favicon: null,
393396 });
394397
395398 reconcilePreviewServerSessions(ref, { sessions: [active], serverEpoch, revision: 1 });
@@ -504,6 +507,7 @@ describe("previewStateStore (single-tab)", () => { · 108 unmodified lines
504507 pictureInPicture: false,
505508 colorScheme: "system",
506509 controller: "none",
510+ favicon: null,
507511 });
508512 const restarted = makeSnapshot({
509513 navStatus: { _tag: "Success", url: "https://new.example", title: "New" },
apps/web/src/previewStateStore.ts+2−0
@@ -9,6 +9,7 @@ import { useAtomValue } from "@effect/atom-react"; · 8 unmodified lines
99 import { scopedThreadKey } from "@t3tools/client-runtime/environment";
1010 import {
1111 type DesktopPreviewColorScheme,
12+ type DesktopPreviewFavicon,
1213 type PreviewEvent,
1314 type PreviewListResult,
1415 type PreviewSessionSnapshot,
@@ -28,6 +29,7 @@ export interface DesktopPreviewOverlay { · 13 unmodified lines
2829 pictureInPicture: boolean;
2930 colorScheme: DesktopPreviewColorScheme;
3031 controller: "human" | "agent" | "none";
32+ favicon: DesktopPreviewFavicon | null;
3133 }
3234
3335 export interface ThreadPreviewState {
apps/web/src/routes/_chat.pull-requests.tsx+2−0
@@ -175,6 +175,7 @@ const PULL_REQUESTS_PANEL_ENVIRONMENT_ID = "pull-requests-panel" as EnvironmentI · 174 unmodified lines
175175 /** Stable so a read that is not wanted right now does not re-key on every render. */
176176 const NO_LIST_TARGETS: ReadonlyArray<EnvironmentQueryTarget<PullRequestListInput>> = [];
177177 const EMPTY_PREVIEW_SESSIONS = {};
178+const EMPTY_PREVIEW_DESKTOP_STATE = {};
178179 const EMPTY_TERMINAL_LABELS = new Map<string, string>();
179180 const EMPTY_PENDING_SURFACES = new Set<string>();
180181
@@ -1540,6 +1541,7 @@ function PullRequestsRouteView() { · 1359 unmodified lines
15401541 activeSurfaceId={activePullRequestSurface.id}
15411542 pendingSurfaceIds={EMPTY_PENDING_SURFACES}
15421543 previewSessions={EMPTY_PREVIEW_SESSIONS}
1544+ desktopByTabId={EMPTY_PREVIEW_DESKTOP_STATE}
15431545 terminalLabelsById={EMPTY_TERMINAL_LABELS}
15441546 onActivate={(surface) => {
15451547 if (surface.kind === "pull-request") activateSurface(surface);
packages/contracts/src/ipc.ts+24−0
@@ -524,6 +524,28 @@ export type DesktopPreviewColorScheme = "system" | "light" | "dark"; · 523 unmodified lines
524524 export const DesktopPreviewColorSchemeSchema: Schema.Codec<DesktopPreviewColorScheme> =
525525 Schema.Literals(["system", "light", "dark"]);
526526
527+export const FAVICON_DATA_URL_MAX_LENGTH = 8192;
528+export const FAVICON_CAPTURED_AT_MAX = 8_640_000_000_000_000;
529+
530+export interface DesktopPreviewFavicon {
531+ dataUrl: string;
532+ pageUrl: string;
533+ capturedAt: number;
534+}
535+
536+export const DesktopPreviewFaviconSchema: Schema.Codec<DesktopPreviewFavicon> = Schema.Struct({
537+ dataUrl: Schema.String.check(
538+ Schema.isMaxLength(FAVICON_DATA_URL_MAX_LENGTH),
539+ Schema.isPattern(/^data:image\/png;base64,[a-z0-9+/]+={0,2}$/i),
540+ ),
541+ pageUrl: Schema.String.check(Schema.isMaxLength(2_048)),
542+ capturedAt: Schema.Number.check(
543+ Schema.isFinite(),
544+ Schema.isGreaterThanOrEqualTo(0),
545+ Schema.isLessThanOrEqualTo(FAVICON_CAPTURED_AT_MAX),
546+ ),
547+});
548+
527549 export interface DesktopPreviewTabState {
528550 tabId: string;
529551 webContentsId: number | null;
@@ -536,6 +558,7 @@ export interface DesktopPreviewTabState { · 6 unmodified lines
536558 pictureInPicture: boolean;
537559 colorScheme: DesktopPreviewColorScheme;
538560 controller: "human" | "agent" | "none";
561+ favicon?: DesktopPreviewFavicon;
539562 updatedAt: string;
540563 }
541564
@@ -574,6 +597,7 @@ export const DesktopPreviewTabStateSchema: Schema.Codec<DesktopPreviewTabState> · 32 unmodified lines
574597 pictureInPicture: Schema.Boolean,
575598 colorScheme: DesktopPreviewColorSchemeSchema,
576599 controller: Schema.Literals(["human", "agent", "none"]),
600+ favicon: Schema.optionalKey(DesktopPreviewFaviconSchema),
577601 updatedAt: Schema.String,
578602 });
579603