From 59d1f61b741c3df0e0ea7823ce67eee880640c2b Mon Sep 17 00:00:00 2001 From: Chris Deeming Date: Mon, 10 Aug 2026 05:18:49 +0100 Subject: [PATCH 01/10] feat(desktop): show favicons in browser history --- .../src/preview/FaviconCapture.test.ts | 520 +++++++++++++++ apps/desktop/src/preview/FaviconCapture.ts | 604 ++++++++++++++++++ apps/desktop/src/preview/Manager.test.ts | 604 ++++++++++++++++++ apps/desktop/src/preview/Manager.ts | 225 ++++++- .../src/browser/browserTargetResolver.test.ts | 112 ++++ apps/web/src/browser/browserTargetResolver.ts | 128 +++- apps/web/src/browserFaviconLogic.test.ts | 74 +++ apps/web/src/browserFaviconLogic.ts | 111 ++++ apps/web/src/browserFaviconStore.test.ts | 196 ++++++ apps/web/src/browserFaviconStore.ts | 267 ++++++++ apps/web/src/components/ChatView.tsx | 15 +- .../src/components/RightPanelTabs.test.tsx | 106 +++ apps/web/src/components/RightPanelTabs.tsx | 29 +- .../preview/PreviewEmptyState.test.tsx | 7 +- .../components/preview/PreviewEmptyState.tsx | 6 +- .../preview/PreviewFaviconIcon.test.tsx | 51 ++ .../components/preview/PreviewFaviconIcon.tsx | 66 ++ .../preview/PreviewLocalServerCard.tsx | 9 +- .../preview/PreviewRecentUrlCard.tsx | 8 +- .../src/components/preview/PreviewView.tsx | 1 + .../preview/usePreviewBridge.test.ts | 48 ++ .../components/preview/usePreviewBridge.ts | 57 +- apps/web/src/lib/favicon.test.ts | 33 + apps/web/src/lib/favicon.ts | 3 + apps/web/src/previewStateStore.test.ts | 4 + apps/web/src/previewStateStore.ts | 2 + packages/contracts/src/ipc.ts | 24 + 27 files changed, 3246 insertions(+), 64 deletions(-) create mode 100644 apps/desktop/src/preview/FaviconCapture.test.ts create mode 100644 apps/desktop/src/preview/FaviconCapture.ts create mode 100644 apps/web/src/browserFaviconLogic.test.ts create mode 100644 apps/web/src/browserFaviconLogic.ts create mode 100644 apps/web/src/browserFaviconStore.test.ts create mode 100644 apps/web/src/browserFaviconStore.ts create mode 100644 apps/web/src/components/RightPanelTabs.test.tsx create mode 100644 apps/web/src/components/preview/PreviewFaviconIcon.test.tsx create mode 100644 apps/web/src/components/preview/PreviewFaviconIcon.tsx create mode 100644 apps/web/src/components/preview/usePreviewBridge.test.ts create mode 100644 apps/web/src/lib/favicon.test.ts diff --git a/apps/desktop/src/preview/FaviconCapture.test.ts b/apps/desktop/src/preview/FaviconCapture.test.ts new file mode 100644 index 00000000000..d0fd41a7fda --- /dev/null +++ b/apps/desktop/src/preview/FaviconCapture.test.ts @@ -0,0 +1,520 @@ +import { describe, expect, it, vi } from "vite-plus/test"; + +import { + MAX_FAVICON_CANDIDATES, + MAX_FAVICON_RESPONSE_BYTES, + captureFavicon, + selectFaviconCandidates, +} from "./FaviconCapture.ts"; + +const PNG = "data:image/png;base64,cG5n"; +const SOURCE_PNG = Buffer.alloc(24); +Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(SOURCE_PNG); +SOURCE_PNG.writeUInt32BE(1, 16); +SOURCE_PNG.writeUInt32BE(1, 20); +const SOURCE_PNG_URL = `data:image/png;base64,${SOURCE_PNG.toString("base64")}`; + +function sourceGif( + width: number, + height: number, + frameWidth = width, + frameHeight = height, + additionalFrames: ReadonlyArray<{ + readonly left?: number; + readonly top?: number; + readonly width: number; + readonly height: number; + }> = [], +): Buffer { + const frames = [{ width: frameWidth, height: frameHeight }, ...additionalFrames]; + const buffer = Buffer.alloc(13 + frames.length * 12 + 1); + buffer.write("GIF89a", 0, "ascii"); + buffer.writeUInt16LE(width, 6); + buffer.writeUInt16LE(height, 8); + let offset = 13; + for (const frame of frames) { + buffer[offset] = 0x2c; + buffer.writeUInt16LE(frame.left ?? 0, offset + 1); + buffer.writeUInt16LE(frame.top ?? 0, offset + 3); + buffer.writeUInt16LE(frame.width, offset + 5); + buffer.writeUInt16LE(frame.height, offset + 7); + offset += 10; + buffer[offset] = 2; + buffer[offset + 1] = 0; + offset += 2; + } + buffer[offset] = 0x3b; + return buffer; +} + +function sourceJpeg(width: number, height: number): Buffer { + return Buffer.from([ + 0xff, + 0xd8, + 0xff, + 0xc0, + 0x00, + 0x07, + 0x08, + height >>> 8, + height & 0xff, + width >>> 8, + width & 0xff, + ]); +} + +function sourceWebp(width: number, height: number): Buffer { + const buffer = Buffer.alloc(30); + buffer.write("RIFF", 0, "ascii"); + buffer.write("WEBP", 8, "ascii"); + buffer.write("VP8X", 12, "ascii"); + buffer.writeUIntLE(width - 1, 24, 3); + buffer.writeUIntLE(height - 1, 27, 3); + return buffer; +} + +function sourceIco(embedded: Buffer): Buffer { + const buffer = Buffer.alloc(22 + embedded.byteLength); + buffer.writeUInt16LE(1, 2); + buffer.writeUInt16LE(1, 4); + buffer.writeUInt32LE(embedded.byteLength, 14); + buffer.writeUInt32LE(22, 18); + embedded.copy(buffer, 22); + return buffer; +} + +function makeUnsafePng(): Buffer { + const buffer = Buffer.from(SOURCE_PNG); + buffer.writeUInt32BE(4096, 16); + buffer.writeUInt32BE(4096, 20); + return buffer; +} + +function makeUnsafeDib(): Buffer { + const buffer = Buffer.alloc(40); + buffer.writeUInt32LE(40, 0); + buffer.writeInt32LE(4096, 4); + buffer.writeInt32LE(4096, 8); + return buffer; +} + +function makeWebContents(options?: { + readonly fetch?: (url: string, init?: RequestInit) => Promise; + readonly rasterize?: (code: string) => Promise; +}) { + const fetch = vi.fn( + options?.fetch ?? + (async () => + new Response(new Uint8Array(SOURCE_PNG), { + headers: { "content-type": "image/png" }, + })), + ); + const executeJavaScriptInIsolatedWorld = vi.fn( + async (_worldId: number, scripts: ReadonlyArray<{ readonly code: string }>) => + options?.rasterize ? options.rasterize(scripts[0]?.code ?? "") : PNG, + ); + return { + webContents: { + session: { fetch }, + executeJavaScriptInIsolatedWorld, + } as never, + executeJavaScriptInIsolatedWorld, + fetch, + }; +} + +describe("selectFaviconCandidates", () => { + it("filters and deduplicates before applying the candidate cap", () => { + const valid = Array.from( + { length: MAX_FAVICON_CANDIDATES + 2 }, + (_, index) => `https://example.com/favicon-${index}.png`, + ); + expect( + selectFaviconCandidates([ + ...Array.from({ length: 64 }, () => "javascript:alert(1)"), + valid[0]!, + valid[0]!, + ...valid.slice(1), + ]), + ).toEqual(valid.slice(0, MAX_FAVICON_CANDIDATES)); + }); + + it("bounds raw candidate scanning independently of the usable-candidate cap", () => { + const oversizedInvalid = `javascript:${"x".repeat(2_048)}`; + expect( + selectFaviconCandidates([ + ...Array.from({ length: 128 }, () => oversizedInvalid), + "https://example.com/too-late.png", + ]), + ).toEqual([]); + }); +}); + +describe("captureFavicon", () => { + it.each([ + { + label: "same-origin", + pageUrl: "https://example.com/page", + faviconUrl: "https://example.com/favicon.png", + credentials: "include", + }, + { + label: "cross-origin", + pageUrl: "https://example.com/page", + faviconUrl: "https://cdn.example.net/favicon.png", + credentials: "omit", + }, + ])("uses the explicit credential policy for $label requests", async (testCase) => { + const { webContents, fetch } = makeWebContents(); + const result = await captureFavicon({ + webContents, + pageUrl: testCase.pageUrl, + candidates: [testCase.faviconUrl], + signal: new AbortController().signal, + }); + + expect(result).toEqual({ kind: "captured", dataUrl: PNG }); + expect(fetch).toHaveBeenCalledWith( + testCase.faviconUrl, + expect.objectContaining({ credentials: testCase.credentials, redirect: "error" }), + ); + }); + + it("decodes base64 and percent-encoded inline images without fetching", async () => { + const { webContents, fetch, executeJavaScriptInIsolatedWorld } = makeWebContents(); + + for (const candidate of [ + SOURCE_PNG_URL, + "data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%2F%3E", + ]) { + expect( + await captureFavicon({ + webContents, + pageUrl: "https://example.com/page", + candidates: [candidate], + signal: new AbortController().signal, + }), + ).toEqual({ kind: "captured", dataUrl: PNG }); + } + + expect(fetch).not.toHaveBeenCalled(); + expect(executeJavaScriptInIsolatedWorld).toHaveBeenCalledTimes(2); + }); + + it("tries the next candidate after an ordinary rejection", async () => { + const { webContents, fetch } = makeWebContents({ + fetch: async (url) => + url.endsWith("first.png") + ? new Response(null, { status: 404 }) + : new Response(new Uint8Array(SOURCE_PNG), { + headers: { "content-type": "image/png" }, + }), + }); + + expect( + await captureFavicon({ + webContents, + pageUrl: "https://example.com/page", + candidates: ["https://example.com/first.png", "https://example.com/second.png"], + signal: new AbortController().signal, + }), + ).toEqual({ kind: "captured", dataUrl: PNG }); + expect(fetch).toHaveBeenCalledTimes(2); + }); + + it("cancels a rejected response body before trying the next candidate", async () => { + const cancel = vi.fn(); + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(1)); + }, + cancel, + }); + const { webContents, fetch } = makeWebContents({ + fetch: async (url) => + url.endsWith("first.png") + ? new Response(body, { status: 404 }) + : new Response(new Uint8Array(SOURCE_PNG), { + headers: { "content-type": "image/png" }, + }), + }); + + expect( + await captureFavicon({ + webContents, + pageUrl: "https://example.com/page", + candidates: ["https://example.com/first.png", "https://example.com/second.png"], + signal: new AbortController().signal, + }), + ).toEqual({ kind: "captured", dataUrl: PNG }); + expect(cancel).toHaveBeenCalledOnce(); + expect(fetch).toHaveBeenCalledTimes(2); + }); + + it("stops a pending fetch when its capture is aborted", async () => { + const controller = new AbortController(); + const { webContents } = makeWebContents({ + fetch: (_url, init) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => reject(init.signal?.reason), { + once: true, + }); + }), + }); + const capture = captureFavicon({ + webContents, + pageUrl: "https://example.com/page", + candidates: ["https://example.com/favicon.png"], + signal: controller.signal, + }); + controller.abort(); + expect(await capture).toEqual({ kind: "none" }); + }); + + it("rejects and cancels an oversized streamed response", async () => { + const cancel = vi.fn(); + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(MAX_FAVICON_RESPONSE_BYTES)); + controller.enqueue(new Uint8Array(1)); + }, + cancel, + }); + const { webContents, executeJavaScriptInIsolatedWorld } = makeWebContents({ + fetch: async () => new Response(body, { headers: { "content-type": "image/png" } }), + }); + + expect( + await captureFavicon({ + webContents, + pageUrl: "https://example.com/page", + candidates: ["https://example.com/favicon.png"], + signal: new AbortController().signal, + }), + ).toEqual({ kind: "none" }); + expect(cancel).toHaveBeenCalledOnce(); + expect(executeJavaScriptInIsolatedWorld).not.toHaveBeenCalled(); + }); + + it("retains bounded compatibility with common favicon formats", async () => { + const { webContents, executeJavaScriptInIsolatedWorld } = makeWebContents(); + for (const [mime, buffer] of [ + ["image/gif", sourceGif(32, 32)], + ["image/jpeg", sourceJpeg(32, 32)], + ["image/webp", sourceWebp(32, 32)], + ["image/x-icon", sourceIco(SOURCE_PNG)], + ] as const) { + expect( + await captureFavicon({ + webContents, + pageUrl: "https://example.com/page", + candidates: [`data:${mime};base64,${buffer.toString("base64")}`], + signal: new AbortController().signal, + }), + ).toEqual({ kind: "captured", dataUrl: PNG }); + } + expect(executeJavaScriptInIsolatedWorld).toHaveBeenCalledTimes(4); + }); + + it("rejects an unsafe PNG size before rasterization", async () => { + const buffer = makeUnsafePng(); + const { webContents, executeJavaScriptInIsolatedWorld } = makeWebContents({ + fetch: async () => + new Response(new Uint8Array(buffer), { + headers: { "content-type": "image/png" }, + }), + }); + + expect( + await captureFavicon({ + webContents, + pageUrl: "https://example.com/page", + candidates: ["https://example.com/favicon.png"], + signal: new AbortController().signal, + }), + ).toEqual({ kind: "none" }); + expect(executeJavaScriptInIsolatedWorld).not.toHaveBeenCalled(); + }); + + it.each([ + ["GIF", "image/gif", sourceGif(4096, 4096)], + ["GIF frame", "image/gif", sourceGif(1, 1, 4096, 4096)], + ["GIF later frame", "image/gif", sourceGif(1, 1, 1, 1, [{ width: 4096, height: 4096 }])], + [ + "GIF cumulative frames", + "image/gif", + sourceGif( + 64, + 64, + 64, + 64, + Array.from({ length: 256 }, () => ({ width: 64, height: 64 })), + ), + ], + ["JPEG", "image/jpeg", sourceJpeg(4096, 4096)], + ["WebP", "image/webp", sourceWebp(4096, 4096)], + ["ICO with PNG", "image/x-icon", sourceIco(makeUnsafePng())], + ["ICO with DIB", "image/x-icon", sourceIco(makeUnsafeDib())], + ["SVG", "image/svg+xml", Buffer.from('')], + [ + "SVG attribute decoy", + "image/svg+xml", + Buffer.from(``), + ], + [ + "SVG comment decoy", + "image/svg+xml", + Buffer.from(''), + ], + [ + "SVG entity dimensions", + "image/svg+xml", + Buffer.from(']>'), + ], + [ + "SVG styled dimensions", + "image/svg+xml", + Buffer.from(''), + ], + [ + "SVG namespaced style", + "image/svg+xml", + Buffer.from( + 'svg{width:4096px;height:4096px}', + ), + ], + [ + "ICO invalid payload span", + "image/x-icon", + (() => { + const buffer = Buffer.alloc(22); + buffer.writeUInt16LE(1, 2); + buffer.writeUInt16LE(1, 4); + buffer.writeUInt32LE(100, 14); + buffer.writeUInt32LE(22, 18); + return buffer; + })(), + ], + ])("rejects unsafe %s dimensions before rasterization", async (_label, mime, buffer) => { + const { webContents, executeJavaScriptInIsolatedWorld } = makeWebContents(); + const candidate = `data:${mime};base64,${buffer.toString("base64")}`; + expect( + await captureFavicon({ + webContents, + pageUrl: "https://example.com/page", + candidates: [candidate], + signal: new AbortController().signal, + }), + ).toEqual({ kind: "none" }); + expect(executeJavaScriptInIsolatedWorld).not.toHaveBeenCalled(); + }); + + it("ignores output that is not a bounded PNG data URL", async () => { + const { webContents } = makeWebContents({ + rasterize: async () => "data:image/svg+xml;base64,c3Zn", + }); + + expect( + await captureFavicon({ + webContents, + pageUrl: "https://example.com/page", + candidates: [SOURCE_PNG_URL], + signal: new AbortController().signal, + }), + ).toEqual({ kind: "none" }); + }); + + it("lets a newer attempt publish after a logical rasterization timeout", async () => { + vi.useFakeTimers(); + try { + let resolveOld!: (value: unknown) => void; + let executions = 0; + const { webContents } = makeWebContents({ + rasterize: () => { + executions += 1; + return executions === 1 + ? new Promise((resolve) => { + resolveOld = resolve; + }) + : Promise.resolve(PNG); + }, + }); + const input = { + webContents, + pageUrl: "https://example.com/page", + candidates: [SOURCE_PNG_URL], + signal: new AbortController().signal, + }; + const timedOut = captureFavicon(input); + await vi.advanceTimersByTimeAsync(1_001); + expect(await timedOut).toEqual({ kind: "timed-out" }); + + const newer = captureFavicon(input); + expect(await newer).toEqual({ kind: "captured", dataUrl: PNG }); + resolveOld(PNG); + await Promise.resolve(); + expect(executions).toBe(2); + } finally { + vi.useRealTimers(); + } + }); + + it("ends candidate fallback after a rasterization timeout", async () => { + vi.useFakeTimers(); + try { + let resolveRasterization!: (value: unknown) => void; + const { webContents, fetch, executeJavaScriptInIsolatedWorld } = makeWebContents({ + rasterize: () => + new Promise((resolve) => { + resolveRasterization = resolve; + }), + }); + const capture = captureFavicon({ + webContents, + pageUrl: "https://example.com/page", + candidates: ["https://example.com/first.png", "https://example.com/second.png"], + signal: new AbortController().signal, + }); + + await vi.advanceTimersByTimeAsync(1_001); + + expect(await capture).toEqual({ kind: "timed-out" }); + expect(fetch).toHaveBeenCalledOnce(); + expect(executeJavaScriptInIsolatedWorld).toHaveBeenCalledOnce(); + resolveRasterization(PNG); + } finally { + vi.useRealTimers(); + } + }); + + it("coalesces queued rasterizations so only the latest pending capture launches", async () => { + let resolveFirst!: (value: unknown) => void; + let executions = 0; + const { webContents } = makeWebContents({ + rasterize: () => { + executions += 1; + return executions === 1 + ? new Promise((resolve) => { + resolveFirst = resolve; + }) + : Promise.resolve(PNG); + }, + }); + const input = { + webContents, + pageUrl: "https://example.com/page", + candidates: [SOURCE_PNG_URL], + signal: new AbortController().signal, + }; + const first = captureFavicon(input); + const superseded = captureFavicon(input); + const newest = captureFavicon(input); + + expect(executions).toBe(1); + resolveFirst(PNG); + expect(await first).toEqual({ kind: "captured", dataUrl: PNG }); + expect(await superseded).toEqual({ kind: "none" }); + expect(await newest).toEqual({ kind: "captured", dataUrl: PNG }); + expect(executions).toBe(2); + }); +}); diff --git a/apps/desktop/src/preview/FaviconCapture.ts b/apps/desktop/src/preview/FaviconCapture.ts new file mode 100644 index 00000000000..e72cf66bf43 --- /dev/null +++ b/apps/desktop/src/preview/FaviconCapture.ts @@ -0,0 +1,604 @@ +import { FAVICON_DATA_URL_MAX_LENGTH } from "@t3tools/contracts"; + +export const MAX_FAVICON_RESPONSE_BYTES = 100_000; +export const MAX_FAVICON_CANDIDATES = 8; +export const MAX_FAVICON_HTTP_URL_LENGTH = 2_048; + +const MAX_FAVICON_CANDIDATE_INPUT_UNITS = 262_144; +const MIN_FAVICON_CANDIDATE_INPUT_UNITS = 256; +const MAX_FAVICON_SOURCE_PIXELS = 1_048_576; +const MAX_FAVICON_INLINE_URL_LENGTH = Math.ceil((MAX_FAVICON_RESPONSE_BYTES * 4) / 3) + 128; +const FAVICON_CAPTURE_TIMEOUT_MS = 5_000; +const FAVICON_RASTER_WORLD_ID = 1001; +const FAVICON_RASTER_TIMEOUT_MS = 1_000; +const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]); + +interface RasterizationGate { + generation: number; + launchAllowed?: Promise; +} + +const rasterizationGates = new WeakMap(); + +async function waitForRasterLaunch(previous: Promise, signal: AbortSignal): Promise { + if (signal.aborted) return; + await new Promise((resolve) => { + const finish = () => { + signal.removeEventListener("abort", finish); + resolve(); + }; + signal.addEventListener("abort", finish, { once: true }); + void previous.then(finish); + }); +} + +export type FaviconCaptureResult = + | { readonly kind: "captured"; readonly dataUrl: string } + | { readonly kind: "none" } + | { readonly kind: "timed-out" }; + +type RasterizationResult = + | { readonly kind: "completed"; readonly value: unknown } + | { readonly kind: "timed-out" }; + +export function safeHttpOrigin(url: string): string | null { + try { + const parsed = new URL(url); + return parsed.protocol === "http:" || parsed.protocol === "https:" ? parsed.origin : null; + } catch { + return null; + } +} + +export function selectFaviconCandidates(candidates: ReadonlyArray): ReadonlyArray { + const selected: string[] = []; + const seen = new Set(); + let inputUnits = 0; + for (const candidate of candidates) { + // Charge a minimum per entry so a large array of tiny malformed values is bounded too. + inputUnits += Math.max(MIN_FAVICON_CANDIDATE_INPUT_UNITS, candidate.length); + if (inputUnits > MAX_FAVICON_CANDIDATE_INPUT_UNITS) break; + if (!isSupportedFaviconUrl(candidate) || seen.has(candidate)) continue; + seen.add(candidate); + selected.push(candidate); + if (selected.length === MAX_FAVICON_CANDIDATES) break; + } + return selected; +} + +export async function captureFavicon(input: { + readonly webContents: Electron.WebContents; + readonly pageUrl: string; + readonly candidates: ReadonlyArray; + readonly signal: AbortSignal; +}): Promise { + const pageOrigin = safeHttpOrigin(input.pageUrl); + if (!pageOrigin) return { kind: "none" }; + + for (const candidate of selectFaviconCandidates(input.candidates)) { + if (input.signal.aborted) return { kind: "none" }; + const captured = await captureCandidate({ + webContents: input.webContents, + pageOrigin, + candidate, + signal: input.signal, + }); + if (captured.kind === "captured" || captured.kind === "timed-out") return captured; + } + + return { kind: "none" }; +} + +async function captureCandidate(input: { + readonly webContents: Electron.WebContents; + readonly pageOrigin: string; + readonly candidate: string; + readonly signal: AbortSignal; +}): Promise { + try { + const inline = parseInlineFavicon(input.candidate); + if (inline) { + return await normalizeFaviconBuffer( + input.webContents, + inline.mime, + inline.buffer, + input.signal, + ); + } + + const candidateOrigin = safeHttpOrigin(input.candidate); + if (!candidateOrigin) return { kind: "none" }; + const response = await input.webContents.session.fetch(input.candidate, { + credentials: candidateOrigin === input.pageOrigin ? "include" : "omit", + redirect: "error", + signal: AbortSignal.any([input.signal, AbortSignal.timeout(FAVICON_CAPTURE_TIMEOUT_MS)]), + }); + if (!response.ok) { + await response.body?.cancel(); + return { kind: "none" }; + } + const buffer = await readFaviconResponse(response); + if (!buffer || input.signal.aborted) return { kind: "none" }; + const mime = response.headers.get("content-type")?.split(";", 1)[0] ?? null; + return await normalizeFaviconBuffer(input.webContents, mime, buffer, input.signal); + } catch { + return { kind: "none" }; + } +} + +async function readFaviconResponse(response: Response): Promise { + const contentLength = Number(response.headers.get("content-length")); + if (Number.isFinite(contentLength) && contentLength > MAX_FAVICON_RESPONSE_BYTES) { + await response.body?.cancel(); + return null; + } + if (!response.body) { + const buffer = Buffer.from(await response.arrayBuffer()); + return buffer.byteLength <= MAX_FAVICON_RESPONSE_BYTES ? buffer : null; + } + + const reader = response.body.getReader(); + const chunks: Buffer[] = []; + let byteLength = 0; + try { + while (true) { + const next = await reader.read(); + if (next.done) return Buffer.concat(chunks, byteLength); + byteLength += next.value.byteLength; + if (byteLength > MAX_FAVICON_RESPONSE_BYTES) { + await reader.cancel(); + return null; + } + chunks.push(Buffer.from(next.value)); + } + } finally { + reader.releaseLock(); + } +} + +function isSupportedFaviconUrl(url: string): boolean { + if (url.length > MAX_FAVICON_INLINE_URL_LENGTH) return false; + if (/^data:/i.test(url)) return /^data:image\/[a-z0-9.+-]+(?:;[^,]*)?,/i.test(url); + try { + const protocol = new URL(url).protocol; + return ( + (protocol === "http:" || protocol === "https:") && url.length <= MAX_FAVICON_HTTP_URL_LENGTH + ); + } catch { + return false; + } +} + +function decodeInlineFaviconPayload(payload: string): Buffer | null { + const decoded = Buffer.allocUnsafe(Buffer.byteLength(payload)); + let inputOffset = 0; + let outputOffset = 0; + while (inputOffset < payload.length) { + const escapeOffset = payload.indexOf("%", inputOffset); + const literalEnd = escapeOffset === -1 ? payload.length : escapeOffset; + outputOffset += decoded.write(payload.slice(inputOffset, literalEnd), outputOffset, "utf8"); + if (escapeOffset === -1) break; + const hex = payload.slice(escapeOffset + 1, escapeOffset + 3); + if (!/^[0-9a-f]{2}$/i.test(hex)) return null; + decoded[outputOffset] = Number.parseInt(hex, 16); + outputOffset += 1; + inputOffset = escapeOffset + 3; + } + return decoded.subarray(0, outputOffset); +} + +function parseInlineFavicon( + url: string, +): { readonly buffer: Buffer; readonly mime: string } | null { + if (url.length > MAX_FAVICON_INLINE_URL_LENGTH) return null; + const match = /^data:(image\/[a-z0-9.+-]+)((?:;[^,]*)?),(.*)$/is.exec(url); + if (!match) return null; + const mime = match[1]?.toLowerCase(); + const parameters = match[2] + ?.split(";") + .filter(Boolean) + .map((parameter) => parameter.toLowerCase()); + const payload = match[3]; + if (!mime || !parameters || !payload) return null; + const base64 = parameters.at(-1) === "base64"; + if (parameters.includes("base64") && !base64) return null; + + let buffer: Buffer; + try { + if (base64) { + if (!/^[a-z0-9+/]*={0,2}$/i.test(payload) || payload.length % 4 === 1) return null; + buffer = Buffer.from(payload, "base64"); + if (buffer.toString("base64").replace(/=+$/, "") !== payload.replace(/=+$/, "")) { + return null; + } + } else { + const decoded = decodeInlineFaviconPayload(payload); + if (!decoded) return null; + buffer = decoded; + } + } catch { + return null; + } + + return buffer.byteLength > 0 && buffer.byteLength <= MAX_FAVICON_RESPONSE_BYTES + ? { buffer, mime } + : null; +} + +interface ImageDimensions { + readonly width: number; + readonly height: number; +} + +function safeDimensions(dimensions: ImageDimensions | null): boolean { + return ( + dimensions !== null && + Number.isSafeInteger(dimensions.width) && + Number.isSafeInteger(dimensions.height) && + dimensions.width > 0 && + dimensions.height > 0 && + dimensions.width * dimensions.height <= MAX_FAVICON_SOURCE_PIXELS + ); +} + +function pngDimensions(buffer: Buffer): ImageDimensions | null { + if (!buffer.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE) || buffer.byteLength < 24) { + return null; + } + return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) }; +} + +function skipGifSubBlocks(buffer: Buffer, startOffset: number): number | null { + let offset = startOffset; + while (offset < buffer.byteLength) { + const blockLength = buffer[offset]!; + offset += 1; + if (blockLength === 0) return offset; + if (offset + blockLength > buffer.byteLength) return null; + offset += blockLength; + } + return null; +} + +function gifDimensions(buffer: Buffer): ImageDimensions | null { + if (buffer.byteLength < 13 || !/^GIF8[79]a$/u.test(buffer.subarray(0, 6).toString("ascii"))) { + return null; + } + const logicalWidth = buffer.readUInt16LE(6); + const logicalHeight = buffer.readUInt16LE(8); + if (!safeDimensions({ width: logicalWidth, height: logicalHeight })) return null; + const packed = buffer[10]!; + let offset = 13 + ((packed & 0x80) === 0 ? 0 : 3 * 2 ** ((packed & 0x07) + 1)); + if (offset > buffer.byteLength) return null; + let width = logicalWidth; + let height = logicalHeight; + let frameCount = 0; + let framePixels = 0; + while (offset < buffer.byteLength) { + const marker = buffer[offset]; + if (marker === 0x3b) return frameCount > 0 ? { width, height } : null; + if (marker === 0x2c) { + if (offset + 10 > buffer.byteLength) return null; + const left = buffer.readUInt16LE(offset + 1); + const top = buffer.readUInt16LE(offset + 3); + const frameWidth = buffer.readUInt16LE(offset + 5); + const frameHeight = buffer.readUInt16LE(offset + 7); + if (frameWidth === 0 || frameHeight === 0) return null; + framePixels += frameWidth * frameHeight; + if (framePixels > MAX_FAVICON_SOURCE_PIXELS) return null; + width = Math.max(width, left + frameWidth); + height = Math.max(height, top + frameHeight); + if (!safeDimensions({ width, height })) return null; + const framePacked = buffer[offset + 9]!; + offset += 10; + if ((framePacked & 0x80) !== 0) { + offset += 3 * 2 ** ((framePacked & 0x07) + 1); + } + if (offset >= buffer.byteLength) return null; + const minimumCodeSize = buffer[offset]!; + if (minimumCodeSize < 2 || minimumCodeSize > 8) return null; + offset += 1; + const nextOffset = skipGifSubBlocks(buffer, offset); + if (nextOffset === null) return null; + offset = nextOffset; + frameCount += 1; + continue; + } + if (marker !== 0x21 || offset + 2 > buffer.byteLength) return null; + const nextOffset = skipGifSubBlocks(buffer, offset + 2); + if (nextOffset === null) return null; + offset = nextOffset; + } + return null; +} + +function jpegDimensions(buffer: Buffer): ImageDimensions | null { + if (buffer.byteLength < 4 || buffer[0] !== 0xff || buffer[1] !== 0xd8) return null; + const startOfFrameMarkers = new Set([ + 0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf, + ]); + let offset = 2; + while (offset + 3 < buffer.byteLength) { + if (buffer[offset] !== 0xff) { + offset += 1; + continue; + } + while (buffer[offset] === 0xff) offset += 1; + const marker = buffer[offset]; + offset += 1; + if (marker === undefined || marker === 0xd9 || marker === 0xda) return null; + if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd8)) continue; + if (offset + 1 >= buffer.byteLength) return null; + const length = buffer.readUInt16BE(offset); + if (length < 2 || offset + length > buffer.byteLength) return null; + if (startOfFrameMarkers.has(marker)) { + if (length < 7) return null; + return { height: buffer.readUInt16BE(offset + 3), width: buffer.readUInt16BE(offset + 5) }; + } + offset += length; + } + return null; +} + +function webpDimensions(buffer: Buffer): ImageDimensions | null { + if ( + buffer.byteLength < 30 || + buffer.subarray(0, 4).toString("ascii") !== "RIFF" || + buffer.subarray(8, 12).toString("ascii") !== "WEBP" + ) { + return null; + } + const kind = buffer.subarray(12, 16).toString("ascii"); + if (kind === "VP8X") { + return { + width: 1 + buffer.readUIntLE(24, 3), + height: 1 + buffer.readUIntLE(27, 3), + }; + } + if (kind === "VP8 " && buffer.subarray(23, 26).equals(Buffer.from([0x9d, 0x01, 0x2a]))) { + return { + width: buffer.readUInt16LE(26) & 0x3fff, + height: buffer.readUInt16LE(28) & 0x3fff, + }; + } + if (kind === "VP8L" && buffer[20] === 0x2f) { + return { + width: 1 + buffer[21]! + ((buffer[22]! & 0x3f) << 8), + height: 1 + (buffer[22]! >> 6) + (buffer[23]! << 2) + ((buffer[24]! & 0x0f) << 10), + }; + } + return null; +} + +function dibDimensions(buffer: Buffer): ImageDimensions | null { + if (buffer.byteLength < 12) return null; + const headerSize = buffer.readUInt32LE(0); + if (headerSize === 12) { + return { + width: buffer.readUInt16LE(4), + height: buffer.readUInt16LE(6), + }; + } + if (headerSize < 40 || buffer.byteLength < 12) return null; + return { + width: Math.abs(buffer.readInt32LE(4)), + height: Math.abs(buffer.readInt32LE(8)), + }; +} + +function icoDimensions(buffer: Buffer): ImageDimensions | null { + if ( + buffer.byteLength < 22 || + buffer.readUInt16LE(0) !== 0 || + (buffer.readUInt16LE(2) !== 1 && buffer.readUInt16LE(2) !== 2) + ) { + return null; + } + const count = buffer.readUInt16LE(4); + if (count === 0 || count > 256 || buffer.byteLength < 6 + count * 16) return null; + let width = 0; + let height = 0; + for (let index = 0; index < count; index += 1) { + const offset = 6 + index * 16; + width = Math.max(width, buffer[offset] === 0 ? 256 : buffer[offset]!); + height = Math.max(height, buffer[offset + 1] === 0 ? 256 : buffer[offset + 1]!); + if (!safeDimensions({ width, height })) return null; + const byteLength = buffer.readUInt32LE(offset + 8); + const imageOffset = buffer.readUInt32LE(offset + 12); + if ( + byteLength === 0 || + imageOffset < 6 + count * 16 || + imageOffset > buffer.byteLength || + byteLength > buffer.byteLength - imageOffset + ) + return null; + const embedded = buffer.subarray(imageOffset, imageOffset + byteLength); + const embeddedDimensions = pngDimensions(embedded) ?? dibDimensions(embedded); + if (!safeDimensions(embeddedDimensions)) return null; + } + return { width, height }; +} + +function svgDimensions(buffer: Buffer): ImageDimensions | null { + const source = buffer.toString("utf8").replace(/^\uFEFF/u, ""); + if (/|])/iu.test(source)) { + return null; + } + const root = /^(?:\s*<\?xml[^?]*\?>)?\s*])/iu.exec(source); + if (!root) return null; + let offset = root[0].length; + const attributes = new Map(); + while (offset < source.length) { + while (/\s/u.test(source[offset] ?? "")) offset += 1; + if (source[offset] === ">" || (source[offset] === "/" && source[offset + 1] === ">")) break; + const name = /^[a-z_:][a-z0-9_.:-]*/iu.exec(source.slice(offset))?.[0]; + if (!name) return null; + offset += name.length; + while (/\s/u.test(source[offset] ?? "")) offset += 1; + if (source[offset] !== "=") return null; + offset += 1; + while (/\s/u.test(source[offset] ?? "")) offset += 1; + const quote = source[offset]; + if (quote !== '"' && quote !== "'") return null; + const valueEnd = source.indexOf(quote, offset + 1); + if (valueEnd === -1) return null; + const value = source.slice(offset + 1, valueEnd); + const normalizedName = name.toLowerCase(); + if (normalizedName === "style" || value.includes("&") || attributes.has(normalizedName)) { + return null; + } + attributes.set(normalizedName, value); + offset = valueEnd + 1; + } + if (source[offset] !== ">" && !(source[offset] === "/" && source[offset + 1] === ">")) + return null; + const readLength = (name: string, fallback: number): number | null => { + const value = attributes.get(name); + if (value === undefined) return fallback; + const match = /^\s*([-+]?(?:\d+\.?\d*|\.\d+)(?:e[-+]?\d+)?)\s*(?:px)?\s*$/iu.exec(value); + return match?.[1] === undefined ? null : Number(match[1]); + }; + const width = readLength("width", 300); + const height = readLength("height", 150); + if (width === null || height === null) return null; + const viewBox = attributes + .get("viewbox") + ?.trim() + .split(/[\s,]+/u) + .map(Number); + if (viewBox && (viewBox.length !== 4 || viewBox.some((part) => !Number.isFinite(part)))) { + return null; + } + const viewBoxWidth = viewBox ? Math.abs(viewBox[2]!) : 0; + const viewBoxHeight = viewBox ? Math.abs(viewBox[3]!) : 0; + return { + width: Math.max(width, viewBoxWidth), + height: Math.max(height, viewBoxHeight), + }; +} + +function sourceDimensions(buffer: Buffer, mime: string | null): ImageDimensions | null { + return ( + pngDimensions(buffer) ?? + gifDimensions(buffer) ?? + jpegDimensions(buffer) ?? + webpDimensions(buffer) ?? + icoDimensions(buffer) ?? + (mime === "image/svg+xml" || buffer.subarray(0, 256).toString("utf8").includes(" { + const declaredMime = mime?.trim().toLowerCase() || null; + const normalizedMime = + declaredMime === "application/x-icon" + ? "image/x-icon" + : declaredMime === "application/octet-stream" || declaredMime === "binary/octet-stream" + ? null + : declaredMime; + if ( + (normalizedMime !== null && !/^image\/[a-z0-9.+-]+$/i.test(normalizedMime)) || + buffer.byteLength > MAX_FAVICON_RESPONSE_BYTES || + !safeDimensions(sourceDimensions(buffer, normalizedMime)) + ) { + return { kind: "none" }; + } + + const rasterized = await rasterizeFavicon(webContents, normalizedMime, buffer, signal); + if (rasterized.kind === "timed-out") return rasterized; + return typeof rasterized.value === "string" && + rasterized.value.startsWith("data:image/png;base64,") && + rasterized.value.length <= FAVICON_DATA_URL_MAX_LENGTH + ? { kind: "captured", dataUrl: rasterized.value } + : { kind: "none" }; +} + +async function rasterizeFavicon( + webContents: Electron.WebContents, + mime: string | null, + buffer: Buffer, + signal: AbortSignal, +): Promise { + const gate = rasterizationGates.get(webContents) ?? { generation: 0 }; + rasterizationGates.set(webContents, gate); + const generation = ++gate.generation; + const previousLaunchAllowed = gate.launchAllowed; + if (previousLaunchAllowed) { + await waitForRasterLaunch(previousLaunchAllowed, signal); + } + if (signal.aborted || generation !== gate.generation) { + return { kind: "completed", value: null }; + } + + const payload = buffer.toString("base64"); + const blobType = mime ?? ""; + const code = ` + (() => { + const rasterize = async () => { + try { + const source = Uint8Array.from(atob("${payload}"), (char) => char.charCodeAt(0)); + const bitmap = await createImageBitmap(new Blob([source], { type: "${blobType}" })); + try { + if (bitmap.width <= 0 || bitmap.height <= 0 || bitmap.width * bitmap.height > ${MAX_FAVICON_SOURCE_PIXELS}) { + return null; + } + const canvas = new OffscreenCanvas(32, 32); + const context = canvas.getContext("2d"); + if (!context) return null; + context.drawImage(bitmap, 0, 0, 32, 32); + const blob = await canvas.convertToBlob({ type: "image/png" }); + const output = new Uint8Array(await blob.arrayBuffer()); + let binary = ""; + for (const byte of output) binary += String.fromCharCode(byte); + return "data:image/png;base64," + btoa(binary); + } finally { + bitmap.close(); + } + } catch { + return null; + } + }; + return rasterize(); + })() + `; + + const execution = webContents.executeJavaScriptInIsolatedWorld(FAVICON_RASTER_WORLD_ID, [ + { code }, + ]); + + const result = new Promise((resolve, reject) => { + // Electron cannot cancel isolated-world execution. This timeout ends only + // the logical attempt; renderer work may finish after a newer attempt starts. + const timeout = AbortSignal.timeout(FAVICON_RASTER_TIMEOUT_MS); + const onTimeout = () => { + resolve({ kind: "timed-out" }); + }; + timeout.addEventListener("abort", onTimeout, { once: true }); + void execution.then( + (value) => { + timeout.removeEventListener("abort", onTimeout); + resolve({ kind: "completed", value }); + }, + (cause: unknown) => { + timeout.removeEventListener("abort", onTimeout); + reject(cause); + }, + ); + }); + const launchAllowed = result.then( + () => undefined, + () => undefined, + ); + gate.launchAllowed = launchAllowed; + void launchAllowed.then(() => { + if (gate.launchAllowed === launchAllowed) delete gate.launchAllowed; + }); + return await result; +} diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index a6ef30c2742..c24dca802c5 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -186,6 +186,102 @@ const makeTestPreviewWebContents = ( capturePage, }) as never; +const TEST_FAVICON = "data:image/png;base64,cG5n"; + +const makeSourcePng = (width = 1, height = 1): Buffer => { + const buffer = Buffer.alloc(24); + Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(buffer); + buffer.writeUInt32BE(width, 16); + buffer.writeUInt32BE(height, 20); + return buffer; +}; + +const makeFaviconWebContents = (options?: { + readonly fetch?: (url: string, init?: RequestInit) => Promise; + readonly id?: number; + readonly rasterize?: (code: string) => Promise; + readonly url?: string; +}) => { + const sourcePng = makeSourcePng(); + const listeners = new Map void>(); + let currentUrl = options?.url ?? "http://localhost:3200/"; + let destroyed = false; + let loading = false; + const fetch = vi.fn( + options?.fetch ?? + (async () => + new Response(new Uint8Array(sourcePng), { + headers: { "content-type": "image/png" }, + })), + ); + const executeJavaScriptInIsolatedWorld = vi.fn( + async (_worldId: number, scripts: ReadonlyArray<{ readonly code: string }>) => + options?.rasterize ? options.rasterize(scripts[0]?.code ?? "") : TEST_FAVICON, + ); + const reload = vi.fn(); + const loadURL = vi.fn(async (url: string) => { + currentUrl = url; + }); + const off = vi.fn(); + const debuggerOff = vi.fn(); + const webContents = { + id: options?.id ?? 42, + isDestroyed: () => destroyed, + getType: () => "webview", + getURL: () => currentUrl, + getTitle: () => "Preview", + isLoading: () => loading, + isDevToolsOpened: () => false, + getZoomFactor: () => 1, + setZoomFactor: vi.fn(), + reload, + reloadIgnoringCache: vi.fn(), + loadURL, + on: vi.fn((event: string, listener: (...args: never[]) => void) => { + listeners.set(event, listener); + }), + off, + ipc: { on: vi.fn(), off: vi.fn() }, + send: webviewSend, + session: { fetch }, + navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setWindowOpenHandler: vi.fn(), + executeJavaScriptInIsolatedWorld, + debugger: { + isAttached: () => false, + attach: vi.fn(), + sendCommand: vi.fn(async () => undefined), + on: vi.fn(), + off: debuggerOff, + }, + }; + return { + executeJavaScriptInIsolatedWorld, + fetch, + debuggerOff, + listeners, + loadURL, + off, + reload, + setDestroyed: (value: boolean) => { + destroyed = value; + }, + setLoading: (value: boolean) => { + loading = value; + }, + setUrl: (url: string) => { + currentUrl = url; + }, + webContents: webContents as never, + }; +}; + +const settle = function* (until: () => boolean) { + for (let attempt = 0; attempt < 30 && !until(); attempt++) { + yield* Effect.promise(() => Promise.resolve()); + } +}; + const makeTestPictureInPictureWindow = (loadURL: () => Promise = async () => undefined) => { const listeners = new Map void>(); const send = vi.fn(); @@ -257,6 +353,32 @@ describe("PreviewManager", () => { ), ); + effectIt.effect("rejects a destroyed webview during registration", () => + withManager((manager) => + Effect.gen(function* () { + const getType = vi.fn(() => "webview" as const); + fromId.mockReturnValue({ + id: 42, + isDestroyed: () => true, + getType, + } as never); + yield* manager.createTab("tab_destroyed_registration"); + + const exit = yield* Effect.exit(manager.registerWebview("tab_destroyed_registration", 42)); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Option.getOrThrow(Cause.findErrorOption(exit.cause))).toMatchObject({ + _tag: "PreviewWebContentsNotFoundError", + tabId: "tab_destroyed_registration", + webContentsId: 42, + }); + } + expect(getType).not.toHaveBeenCalled(); + }), + ), + ); + effectIt.effect("isolates failed state listeners and continues delivery", () => { const loggedErrors: Array = []; const logger = Logger.make(({ message }) => { @@ -375,6 +497,488 @@ describe("PreviewManager", () => { ), ); + effectIt.effect("detaches a destroyed webview instead of navigating it", () => + withManager((manager) => + Effect.gen(function* () { + const preview = makeFaviconWebContents(); + fromId.mockReturnValue(preview.webContents); + const states: PreviewManager.PreviewTabState[] = []; + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + states.push(state); + }), + ); + yield* manager.createTab("tab_destroyed_navigation"); + yield* manager.registerWebview("tab_destroyed_navigation", 42); + yield* manager.setColorScheme("tab_destroyed_navigation", "dark"); + preview.setDestroyed(true); + + yield* manager.navigate("tab_destroyed_navigation", "https://example.com/"); + + expect(preview.loadURL).not.toHaveBeenCalled(); + expect(preview.reload).not.toHaveBeenCalled(); + expect(preview.off).toHaveBeenCalled(); + expect(preview.debuggerOff).toHaveBeenCalled(); + expect(states.at(-1)).toMatchObject({ + webContentsId: null, + navStatus: { kind: "Loading", url: "https://example.com/" }, + }); + }), + ), + ); + + effectIt.effect("does not let destroyed-webview cleanup detach a same-id replacement", () => + withManager((manager) => + Effect.gen(function* () { + const previous = makeFaviconWebContents(); + const replacement = makeFaviconWebContents({ url: "https://example.com/" }); + let current = previous.webContents; + let startReplacementRegistration: () => void = () => void 0; + const replacementReady = new Promise((resolve) => { + startReplacementRegistration = resolve; + }); + fromId.mockImplementation(() => current); + yield* manager.createTab("tab_destroyed_replacement_race"); + yield* manager.registerWebview("tab_destroyed_replacement_race", 42); + yield* manager.setColorScheme("tab_destroyed_replacement_race", "dark"); + const replacementRegistration = yield* Effect.promise(() => replacementReady).pipe( + Effect.flatMap(() => manager.registerWebview("tab_destroyed_replacement_race", 42)), + Effect.forkChild({ startImmediately: true }), + ); + previous.setDestroyed(true); + previous.debuggerOff.mockImplementationOnce(() => { + current = replacement.webContents; + startReplacementRegistration(); + }); + const states: PreviewManager.PreviewTabState[] = []; + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + states.push(state); + }), + ); + + yield* manager.navigate("tab_destroyed_replacement_race", "https://example.com/"); + const registrationExit = yield* Fiber.await(replacementRegistration); + + expect(Exit.isSuccess(registrationExit)).toBe(true); + expect(previous.off).toHaveBeenCalled(); + expect(replacement.off).not.toHaveBeenCalled(); + expect(states.at(-1)).toMatchObject({ + webContentsId: 42, + navStatus: { kind: "Loading", url: "https://example.com/" }, + }); + }), + ), + ); + + effectIt.effect("publishes a canonical favicon origin while the page is loading", () => + withManager((manager) => + Effect.gen(function* () { + const preview = makeFaviconWebContents({ + url: `http://localhost:3200/${"x".repeat(3_000)}`, + }); + preview.setLoading(true); + fromId.mockReturnValue(preview.webContents); + const states: PreviewManager.PreviewTabState[] = []; + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + states.push(state); + }), + ); + yield* manager.createTab("tab_favicon_loading"); + yield* manager.registerWebview("tab_favicon_loading", 42); + + preview.listeners.get("page-favicon-updated")?.( + {} as never, + ["http://localhost:3200/favicon.png"] as never, + ); + yield* settle(() => states.at(-1)?.favicon !== undefined); + + expect(states.at(-1)?.favicon).toMatchObject({ + dataUrl: TEST_FAVICON, + pageUrl: "http://localhost:3200", + }); + expect(states.at(-1)?.favicon?.capturedAt).toEqual(expect.any(Number)); + }), + ), + ); + + effectIt.effect("shares an identical in-flight event and lets a changed event win", () => + withManager((manager) => + Effect.gen(function* () { + let resolveFirst!: (response: Response) => void; + const firstResponse = new Promise((resolve) => { + resolveFirst = resolve; + }); + const preview = makeFaviconWebContents({ + fetch: (url) => + url.endsWith("first.png") + ? firstResponse + : Promise.resolve( + new Response(new Uint8Array(makeSourcePng()), { + headers: { "content-type": "image/png" }, + }), + ), + }); + fromId.mockReturnValue(preview.webContents); + const states: PreviewManager.PreviewTabState[] = []; + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + states.push(state); + }), + ); + yield* manager.createTab("tab_favicon_latest"); + yield* manager.registerWebview("tab_favicon_latest", 42); + + const faviconUpdated = preview.listeners.get("page-favicon-updated")!; + faviconUpdated({} as never, ["http://localhost:3200/first.png"] as never); + faviconUpdated({} as never, ["http://localhost:3200/first.png"] as never); + yield* settle(() => preview.fetch.mock.calls.length === 1); + faviconUpdated({} as never, ["http://localhost:3200/second.png"] as never); + yield* settle(() => states.at(-1)?.favicon !== undefined); + resolveFirst( + new Response(new Uint8Array(makeSourcePng()), { + headers: { "content-type": "image/png" }, + }), + ); + yield* settle(() => false); + + expect(preview.fetch).toHaveBeenCalledTimes(2); + expect(states.filter((state) => state.favicon !== undefined)).toHaveLength(1); + }), + ), + ); + + effectIt.effect("allows an identical retry after an undecodable capture", () => + withManager((manager) => + Effect.gen(function* () { + let rasterizations = 0; + const preview = makeFaviconWebContents({ + rasterize: async () => (++rasterizations === 1 ? null : TEST_FAVICON), + }); + fromId.mockReturnValue(preview.webContents); + const states: PreviewManager.PreviewTabState[] = []; + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + states.push(state); + }), + ); + yield* manager.createTab("tab_favicon_retry"); + yield* manager.registerWebview("tab_favicon_retry", 42); + const faviconUpdated = preview.listeners.get("page-favicon-updated")!; + + faviconUpdated({} as never, ["http://localhost:3200/favicon.png"] as never); + yield* settle(() => rasterizations === 1); + yield* settle(() => false); + faviconUpdated({} as never, ["http://localhost:3200/favicon.png"] as never); + yield* settle(() => states.at(-1)?.favicon !== undefined); + + expect(rasterizations).toBe(2); + expect(states.at(-1)?.favicon?.dataUrl).toBe(TEST_FAVICON); + }), + ), + ); + + effectIt.effect("does not publish a capture invalidated by navigation", () => + withManager((manager) => + Effect.gen(function* () { + let resolveFetch!: (response: Response) => void; + const preview = makeFaviconWebContents({ + fetch: () => + new Promise((resolve) => { + resolveFetch = resolve; + }), + }); + fromId.mockReturnValue(preview.webContents); + const states: PreviewManager.PreviewTabState[] = []; + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + states.push(state); + }), + ); + yield* manager.createTab("tab_favicon_navigation"); + yield* manager.registerWebview("tab_favicon_navigation", 42); + preview.listeners.get("page-favicon-updated")?.( + {} as never, + ["http://localhost:3200/favicon.png"] as never, + ); + yield* settle(() => preview.fetch.mock.calls.length === 1); + preview.listeners.get("did-start-navigation")?.({ + isMainFrame: true, + isSameDocument: false, + } as never); + preview.setUrl("https://example.com/"); + resolveFetch( + new Response(new Uint8Array(makeSourcePng()), { + headers: { "content-type": "image/png" }, + }), + ); + yield* settle(() => false); + + expect(states.some((state) => state.favicon !== undefined)).toBe(false); + }), + ), + ); + + effectIt.effect("retains a favicon when reloading the current URL without a new event", () => + withManager((manager) => + Effect.gen(function* () { + const preview = makeFaviconWebContents(); + fromId.mockReturnValue(preview.webContents); + const states: PreviewManager.PreviewTabState[] = []; + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + states.push(state); + }), + ); + yield* manager.createTab("tab_favicon_reload"); + yield* manager.registerWebview("tab_favicon_reload", 42); + preview.listeners.get("page-favicon-updated")?.( + {} as never, + ["http://localhost:3200/favicon.png"] as never, + ); + yield* settle(() => states.at(-1)?.favicon !== undefined); + + yield* manager.navigate("tab_favicon_reload", "http://localhost:3200/"); + + expect(preview.reload).toHaveBeenCalledOnce(); + expect(states.at(-1)?.favicon?.dataUrl).toBe(TEST_FAVICON); + }), + ), + ); + + effectIt.effect("clears a published favicon after a confirmed cross-origin navigation", () => + withManager((manager) => + Effect.gen(function* () { + const preview = makeFaviconWebContents(); + fromId.mockReturnValue(preview.webContents); + const states: PreviewManager.PreviewTabState[] = []; + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + states.push(state); + }), + ); + yield* manager.createTab("tab_favicon_origin"); + yield* manager.registerWebview("tab_favicon_origin", 42); + preview.listeners.get("page-favicon-updated")?.( + {} as never, + ["http://localhost:3200/favicon.png"] as never, + ); + yield* settle(() => states.at(-1)?.favicon !== undefined); + + preview.setUrl("https://example.com/"); + preview.listeners.get("did-navigate")?.({} as never); + yield* settle(() => states.at(-1)?.navStatus.kind === "Success"); + + expect(states.at(-1)?.favicon).toBeUndefined(); + }), + ), + ); + + effectIt.effect( + "retains the previous document icon across a failed cross-origin navigation", + () => + withManager((manager) => + Effect.gen(function* () { + const preview = makeFaviconWebContents(); + fromId.mockReturnValue(preview.webContents); + const states: PreviewManager.PreviewTabState[] = []; + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + states.push(state); + }), + ); + yield* manager.createTab("tab_favicon_failed_origin"); + yield* manager.registerWebview("tab_favicon_failed_origin", 42); + preview.listeners.get("page-favicon-updated")?.( + {} as never, + ["http://localhost:3200/favicon.png"] as never, + ); + yield* settle(() => states.at(-1)?.favicon !== undefined); + + preview.listeners.get("did-fail-load")?.( + {} as never, + -105 as never, + "Name not resolved" as never, + "https://unreachable.example/" as never, + true as never, + ); + yield* settle(() => states.at(-1)?.navStatus.kind === "LoadFailed"); + expect(states.at(-1)?.favicon?.dataUrl).toBe(TEST_FAVICON); + + preview.listeners.get("did-navigate")?.({} as never); + yield* settle(() => states.at(-1)?.navStatus.kind === "Success"); + expect(states.at(-1)?.favicon?.dataUrl).toBe(TEST_FAVICON); + }), + ), + ); + + effectIt.effect("does not resurrect an icon after a confirmed about:blank document", () => + withManager((manager) => + Effect.gen(function* () { + const preview = makeFaviconWebContents(); + fromId.mockReturnValue(preview.webContents); + const states: PreviewManager.PreviewTabState[] = []; + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + states.push(state); + }), + ); + yield* manager.createTab("tab_favicon_blank"); + yield* manager.registerWebview("tab_favicon_blank", 42); + preview.listeners.get("page-favicon-updated")?.( + {} as never, + ["http://localhost:3200/favicon.png"] as never, + ); + yield* settle(() => states.at(-1)?.favicon !== undefined); + + preview.setUrl("about:blank"); + preview.listeners.get("did-navigate")?.({} as never); + yield* settle(() => states.at(-1)?.navStatus.kind === "Idle"); + expect(states.at(-1)?.favicon).toBeUndefined(); + + preview.setUrl("http://localhost:3200/"); + preview.listeners.get("did-navigate")?.({} as never); + yield* settle(() => states.at(-1)?.navStatus.kind === "Success"); + expect(states.at(-1)?.favicon).toBeUndefined(); + }), + ), + ); + + effectIt.effect("clears a published favicon when a replacement webview attaches", () => + withManager((manager) => + Effect.gen(function* () { + const initial = makeFaviconWebContents({ id: 42 }); + const replacement = makeFaviconWebContents({ id: 43 }); + fromId.mockImplementation((id?: number) => { + if (id === 42) return initial.webContents; + if (id === 43) return replacement.webContents; + return null; + }); + const states: PreviewManager.PreviewTabState[] = []; + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + states.push(state); + }), + ); + yield* manager.createTab("tab_favicon_replace"); + yield* manager.registerWebview("tab_favicon_replace", 42); + initial.listeners.get("page-favicon-updated")?.( + {} as never, + ["http://localhost:3200/favicon.png"] as never, + ); + yield* settle(() => states.at(-1)?.favicon !== undefined); + + yield* manager.registerWebview("tab_favicon_replace", 43); + + expect(states.at(-1)?.webContentsId).toBe(43); + expect(states.at(-1)?.favicon).toBeUndefined(); + }), + ), + ); + + effectIt.effect("ignores an old capture that completes after webview replacement", () => + withManager((manager) => + Effect.gen(function* () { + let resolveFetch!: (response: Response) => void; + const initial = makeFaviconWebContents({ + id: 42, + fetch: () => + new Promise((resolve) => { + resolveFetch = resolve; + }), + }); + const replacement = makeFaviconWebContents({ id: 43 }); + fromId.mockImplementation((id?: number) => + id === 42 ? initial.webContents : id === 43 ? replacement.webContents : null, + ); + const states: PreviewManager.PreviewTabState[] = []; + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + states.push(state); + }), + ); + yield* manager.createTab("tab_favicon_late_replace"); + yield* manager.registerWebview("tab_favicon_late_replace", 42); + initial.listeners.get("page-favicon-updated")?.( + {} as never, + ["http://localhost:3200/favicon.png"] as never, + ); + yield* settle(() => initial.fetch.mock.calls.length === 1); + + yield* manager.registerWebview("tab_favicon_late_replace", 43); + resolveFetch( + new Response(new Uint8Array(makeSourcePng()), { + headers: { "content-type": "image/png" }, + }), + ); + yield* settle(() => false); + + expect(states.at(-1)?.webContentsId).toBe(43); + expect( + states.some((state) => state.webContentsId === 43 && state.favicon !== undefined), + ).toBe(false); + }), + ), + ); + + effectIt.effect("treats a reused WebContents id as a new attachment", () => + withManager((manager) => + Effect.gen(function* () { + const initial = makeFaviconWebContents({ id: 42 }); + const replacement = makeFaviconWebContents({ id: 42 }); + let active = initial.webContents; + fromId.mockImplementation(() => active); + const states: PreviewManager.PreviewTabState[] = []; + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + states.push(state); + }), + ); + yield* manager.createTab("tab_favicon_reused_id"); + yield* manager.registerWebview("tab_favicon_reused_id", 42); + initial.listeners.get("page-favicon-updated")?.( + {} as never, + ["http://localhost:3200/favicon.png"] as never, + ); + yield* settle(() => states.at(-1)?.favicon !== undefined); + + active = replacement.webContents; + yield* manager.registerWebview("tab_favicon_reused_id", 42); + + expect(states.at(-1)?.favicon).toBeUndefined(); + expect(initial.off).toHaveBeenCalled(); + expect(replacement.listeners.has("page-favicon-updated")).toBe(true); + }), + ), + ); + + effectIt.effect("preserves a favicon when the active attachment registers again", () => + withManager((manager) => + Effect.gen(function* () { + const preview = makeFaviconWebContents(); + fromId.mockReturnValue(preview.webContents); + const states: PreviewManager.PreviewTabState[] = []; + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + states.push(state); + }), + ); + yield* manager.createTab("tab_favicon_reregister"); + yield* manager.registerWebview("tab_favicon_reregister", 42); + preview.listeners.get("page-favicon-updated")?.( + {} as never, + ["http://localhost:3200/favicon.png"] as never, + ); + yield* settle(() => states.at(-1)?.favicon !== undefined); + + yield* manager.registerWebview("tab_favicon_reregister", 42); + + expect(states.at(-1)?.favicon?.dataUrl).toBe(TEST_FAVICON); + }), + ), + ); + effectIt.effect("mirrors Electron's effective zoom across registration and navigation", () => withManager((manager) => Effect.gen(function* () { diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 169fe2992dc..4799a7dfac2 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -8,6 +8,7 @@ import type { DesktopPreviewAnnotationTheme, DesktopPreviewColorScheme, + DesktopPreviewFavicon, DesktopPreviewPointerEvent, PreviewAnnotationPayload, PreviewAnnotationRect, @@ -62,6 +63,7 @@ import { import { isPreviewAnnotationPayload } from "./PickedElementPayload.ts"; import { playwrightInjectedRuntimeInstallExpression } from "./PlaywrightInjectedRuntime.ts"; import { makePreviewAutomationKeySequence } from "./PreviewKeyboard.ts"; +import { captureFavicon, safeHttpOrigin, selectFaviconCandidates } from "./FaviconCapture.ts"; export type PreviewNavStatus = | { kind: "Idle" } @@ -85,6 +87,7 @@ export interface PreviewTabState { pictureInPicture: boolean; colorScheme: DesktopPreviewColorScheme; controller: "human" | "agent" | "none"; + favicon?: DesktopPreviewFavicon; updatedAt: string; } @@ -346,7 +349,10 @@ type PreviewInputSignal = | { readonly kind: "key"; readonly key: string; readonly code: string }; interface ManagedListeners { + readonly attachmentId: symbol; + readonly cancelFaviconCapture: () => void; readonly scope: Scope.Closeable; + readonly webContents: Electron.WebContents; } type FrameCaptureConsumer = "picture-in-picture" | "recording"; @@ -613,6 +619,15 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); }); + const emitIfCurrent = Effect.fn("PreviewManager.emitIfCurrent")(function* ( + tabId: string, + state: PreviewTabState, + ) { + if ((yield* SynchronizedRef.get(tabsRef)).get(tabId) === state) { + yield* emit(tabId, state); + } + }); + const update = Effect.fn("PreviewManager.update")(function* ( tabId: string, patch: Partial, @@ -1204,7 +1219,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function copy.delete(webContentsId); }), ]); - if (managed) yield* Scope.close(managed.scope, Exit.void).pipe(Effect.ignore); + if (managed) { + managed.cancelFaviconCapture(); + yield* Scope.close(managed.scope, Exit.void).pipe(Effect.ignore); + } }); const isAppShortcut = (input: Electron.Input): boolean => @@ -1268,8 +1286,23 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function wc: Electron.WebContents, ) { const scope = yield* Scope.fork(parentScope, "sequential"); + const attachmentId = Symbol(); + let documentId = 0; + let nextRequestId = 0; + let activeCapture: { + readonly controller: AbortController; + readonly documentId: number; + readonly eventKey: string; + readonly requestId: number; + } | null = null; + const cancelFaviconCapture = () => { + documentId += 1; + activeCapture?.controller.abort(); + activeCapture = null; + }; const syncState = Effect.fn("PreviewManager.syncWebContentsState")(function* ( preserveLoadFailure: boolean, + confirmedNavigation = false, ) { if (wc.isDestroyed()) return; const zoomFactor = yield* attempt( @@ -1282,7 +1315,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const updatedAt = yield* currentIso; const next = yield* SynchronizedRef.modify(tabsRef, (tabs) => { const current = tabs.get(tabId); - if (!current) return [Option.none(), tabs] as const; + if (!current || current.webContentsId !== wc.id || webContents.fromId(wc.id) !== wc) { + return [Option.none(), tabs] as const; + } // Electron emits did-stop-loading after did-fail-load. At that point the // failed guest is no longer "loading", but it has not successfully // navigated anywhere. Keep the failure until a new load actually starts. @@ -1292,8 +1327,14 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function computedNavStatus.kind === "Success" ? current.navStatus : computedNavStatus; + const clearFavicon = + confirmedNavigation && + current.favicon !== undefined && + safeHttpOrigin(current.favicon.pageUrl) !== + safeHttpOrigin(navStatus.kind === "Idle" ? wc.getURL() : navStatus.url); + const { favicon: _favicon, ...currentWithoutFavicon } = current; const state: PreviewTabState = { - ...current, + ...(clearFavicon ? currentWithoutFavicon : current), navStatus, canGoBack, canGoForward, @@ -1307,10 +1348,109 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }), ] as const; }); - if (Option.isSome(next)) yield* emit(tabId, next.value); + if (Option.isSome(next)) yield* emitIfCurrent(tabId, next.value); }); const sync = () => runFork(syncState(true)); - const syncNavigation = () => runFork(syncState(false)); + const syncNavigation = () => runFork(syncState(false, true)); + const syncInPageNavigation = () => runFork(syncState(false)); + const navigationStarted = ( + event: Electron.Event, + ) => { + if (event.isMainFrame && !event.isSameDocument) cancelFaviconCapture(); + }; + const publishFavicon = Effect.fn("PreviewManager.publishFavicon")(function* (input: { + readonly captureDocumentId: number; + readonly dataUrl: string; + readonly pageUrl: string; + readonly requestId: number; + }) { + const pageOrigin = safeHttpOrigin(input.pageUrl); + const managed = (yield* Ref.get(attachedRef)).get(wc.id); + if ( + !pageOrigin || + wc.isDestroyed() || + webContents.fromId(wc.id) !== wc || + managed?.attachmentId !== attachmentId || + activeCapture?.documentId !== input.captureDocumentId || + activeCapture.requestId !== input.requestId || + safeHttpOrigin(wc.getURL()) !== pageOrigin + ) { + return; + } + const capturedAt = yield* currentMillis; + const updatedAt = yield* currentIso; + const next = yield* SynchronizedRef.modify(tabsRef, (tabs) => { + const current = tabs.get(tabId); + if ( + !current || + current.webContentsId !== wc.id || + webContents.fromId(wc.id) !== wc || + activeCapture?.documentId !== input.captureDocumentId || + activeCapture.requestId !== input.requestId + ) { + return [Option.none(), tabs] as const; + } + const state: PreviewTabState = { + ...current, + favicon: { dataUrl: input.dataUrl, pageUrl: pageOrigin, capturedAt }, + updatedAt, + }; + return [ + Option.some(state), + replaceMap(tabs, (copy) => { + copy.set(tabId, state); + }), + ] as const; + }); + if (Option.isSome(next)) yield* emitIfCurrent(tabId, next.value); + }); + const faviconUpdated = (_event: Event, rawCandidates: ReadonlyArray): void => { + const pageUrl = wc.getURL(); + if (!safeHttpOrigin(pageUrl)) return; + const candidates = selectFaviconCandidates(rawCandidates); + if (candidates.length === 0) return; + const eventKey = JSON.stringify([pageUrl, ...candidates]); + if (activeCapture?.eventKey === eventKey) return; + activeCapture?.controller.abort(); + const captureDocumentId = documentId; + const requestId = ++nextRequestId; + const controller = new AbortController(); + activeCapture = { controller, documentId: captureDocumentId, eventKey, requestId }; + runFork( + Effect.tryPromise({ + try: () => + captureFavicon({ webContents: wc, pageUrl, candidates, signal: controller.signal }), + catch: (cause) => + new PreviewOperationError({ + operation: "captureFavicon", + tabId, + webContentsId: wc.id, + cause, + }), + }).pipe( + Effect.flatMap((result) => + result.kind === "captured" + ? publishFavicon({ + captureDocumentId, + dataUrl: result.dataUrl, + pageUrl, + requestId, + }) + : Effect.void, + ), + Effect.catch((error) => + controller.signal.aborted + ? Effect.void + : Effect.logDebug("Favicon capture failed.", { error, tabId, webContentsId: wc.id }), + ), + Effect.ensuring( + Effect.sync(() => { + if (activeCapture?.requestId === requestId) activeCapture = null; + }), + ), + ), + ); + }; const failed = ( _event: Event, code: number, @@ -1387,9 +1527,12 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function yield* Scope.addFinalizer( scope, attempt({ operation: "detachListeners", tabId, webContentsId: wc.id }, () => { + cancelFaviconCapture(); + wc.off("did-start-navigation", navigationStarted); wc.off("did-navigate", syncNavigation); - wc.off("did-navigate-in-page", syncNavigation); + wc.off("did-navigate-in-page", syncInPageNavigation); wc.off("page-title-updated", sync); + wc.off("page-favicon-updated", faviconUpdated as never); wc.off("did-start-loading", sync); wc.off("did-stop-loading", sync); wc.off("did-fail-load", failed as never); @@ -1399,9 +1542,11 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); const install = Effect.fn("PreviewManager.installWebContentsListeners")(function* () { yield* attempt({ operation: "attachListeners", tabId, webContentsId: wc.id }, () => { + wc.on("did-start-navigation", navigationStarted); wc.on("did-navigate", syncNavigation); - wc.on("did-navigate-in-page", syncNavigation); + wc.on("did-navigate-in-page", syncInPageNavigation); wc.on("page-title-updated", sync); + wc.on("page-favicon-updated", faviconUpdated as never); wc.on("did-start-loading", sync); wc.on("did-stop-loading", sync); wc.on("did-fail-load", failed as never); @@ -1418,7 +1563,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }); yield* Ref.update(attachedRef, (attached) => replaceMap(attached, (copy) => { - copy.set(wc.id, { scope }); + copy.set(wc.id, { attachmentId, cancelFaviconCapture, scope, webContents: wc }); }), ); }); @@ -1561,6 +1706,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const mainWindow = yield* Ref.get(mainWindowRef); if ( !wc || + wc.isDestroyed() || wc.getType() !== "webview" || (Option.isSome(mainWindow) && wc.hostWebContents !== mainWindow.value.webContents) ) { @@ -1568,7 +1714,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function } const attached = yield* Ref.get(attachedRef); const annotationTheme = yield* Ref.get(annotationThemeRef); - if (tab.webContentsId === webContentsId && attached.has(webContentsId)) { + const currentAttachment = attached.get(webContentsId); + if (tab.webContentsId === webContentsId && currentAttachment?.webContents === wc) { const zoomFactor = yield* attempt( { operation: "registerWebview.getZoomFactor", tabId, webContentsId }, () => wc.getZoomFactor(), @@ -1580,7 +1727,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function return; } const replacedWebContentsId = - tab.webContentsId != null && tab.webContentsId !== webContentsId ? tab.webContentsId : null; + tab.webContentsId != null && + (tab.webContentsId !== webContentsId || currentAttachment?.webContents !== wc) + ? tab.webContentsId + : null; if (replacedWebContentsId !== null) { yield* Effect.all( [ @@ -1627,8 +1777,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ] as const; } const pendingUrl = current.navStatus.kind === "Loading" ? current.navStatus.url : null; + const { favicon: _favicon, ...currentWithoutFavicon } = current; const next: PreviewTabState = { - ...current, + ...currentWithoutFavicon, webContentsId, navStatus: pendingUrl === null ? computeNavStatus(wc) : current.navStatus, canGoBack: wc.navigationHistory.canGoBack(), @@ -1707,6 +1858,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function pictureInPicture: current?.pictureInPicture ?? false, colorScheme: current?.colorScheme ?? "system", controller: current?.controller ?? "none", + ...(current?.favicon ? { favicon: current.favicon } : {}), updatedAt, }; return [ @@ -1718,17 +1870,48 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }); yield* emit(tabId, pending); if (pending.webContentsId == null) return; - const wc = webContents.fromId(pending.webContentsId); - if (!wc) { - const detached = { ...pending, webContentsId: null }; - yield* SynchronizedRef.update(tabsRef, (tabs) => - tabs.get(tabId)?.webContentsId !== pending.webContentsId - ? tabs - : replaceMap(tabs, (copy) => { - copy.set(tabId, detached); - }), + const webContentsId = pending.webContentsId; + const wc = webContents.fromId(webContentsId); + if (!wc || wc.isDestroyed()) { + const expectedAttachment = (yield* Ref.get(attachedRef)).get(webContentsId); + yield* withTabLifecycleLock( + tabId, + Effect.gen(function* () { + const currentTab = (yield* SynchronizedRef.get(tabsRef)).get(tabId); + const currentAttachment = (yield* Ref.get(attachedRef)).get(webContentsId); + const currentWebContents = webContents.fromId(webContentsId); + if ( + currentTab?.webContentsId !== webContentsId || + currentAttachment !== expectedAttachment || + (currentWebContents && !currentWebContents.isDestroyed()) + ) { + return; + } + yield* Effect.all( + [ + detachControlSession(webContentsId), + detachListeners(webContentsId), + cancelPickElement(tabId), + ], + { concurrency: 3, discard: true }, + ); + const detached = yield* SynchronizedRef.modify(tabsRef, (tabs) => { + const current = tabs.get(tabId); + if (current?.webContentsId !== webContentsId) { + return [Option.none(), tabs] as const; + } + const { favicon: _favicon, ...currentWithoutFavicon } = current; + const next: PreviewTabState = { ...currentWithoutFavicon, webContentsId: null }; + return [ + Option.some(next), + replaceMap(tabs, (copy) => { + copy.set(tabId, next); + }), + ] as const; + }); + if (Option.isSome(detached)) yield* emitIfCurrent(tabId, detached.value); + }), ); - yield* emit(tabId, detached); return; } if (wc.getURL() === url) { diff --git a/apps/web/src/browser/browserTargetResolver.test.ts b/apps/web/src/browser/browserTargetResolver.test.ts index 6f89d86df88..d549a7a5855 100644 --- a/apps/web/src/browser/browserTargetResolver.test.ts +++ b/apps/web/src/browser/browserTargetResolver.test.ts @@ -180,4 +180,116 @@ describe("browser target resolver", () => { const { resolveDiscoveredServerUrl } = await import("./browserTargetResolver"); expect(resolveDiscoveredServerUrl(EnvironmentId.make("environment-1"), " ")).toBe(" "); }); + + it("classifies exact private IPv4 and IPv6 boundaries", async () => { + const { isPrivateNetworkHost } = await import("./browserTargetResolver"); + const privateHosts = [ + "0.0.0.0", + "10.0.0.0", + "10.255.255.255", + "100.64.0.0", + "100.127.255.255", + "127.0.0.0", + "127.255.255.255", + "169.254.0.0", + "169.254.255.255", + "172.16.0.0", + "172.31.255.255", + "192.168.0.0", + "192.168.255.255", + "198.18.0.0", + "198.19.255.255", + "fc00::", + "fdff:ffff:ffff:ffff:ffff:ffff:ffff:ffff", + "fe80::", + "febf:ffff:ffff:ffff:ffff:ffff:ffff:ffff", + "::ffff:192.168.1.1", + "localhost.", + "devbox.", + "printer.local.", + "printer.home.arpa.", + "devbox.example.ts.net.", + ]; + const publicHosts = [ + "1.0.0.0", + "100.63.255.255", + "100.128.0.0", + "169.253.255.255", + "169.255.0.0", + "172.15.255.255", + "172.32.0.0", + "192.167.255.255", + "192.169.0.0", + "198.17.255.255", + "198.20.0.0", + "fbff:ffff::", + "fec0::", + "2001:4860:4860::8888", + "::ffff:8.8.8.8", + "example.com.", + ]; + expect(privateHosts.filter((host) => !isPrivateNetworkHost(host))).toEqual([]); + expect(publicHosts.filter(isPrivateNetworkHost)).toEqual([]); + }); + + it("allows only globally routable hosts to reach a public favicon provider", async () => { + const { isPublicFaviconHost } = await import("./browserTargetResolver"); + const nonPublic = [ + "192.0.0.0", + "192.0.0.255", + "192.0.2.0", + "192.0.2.255", + "192.88.99.0", + "192.88.99.255", + "198.51.100.0", + "198.51.100.255", + "203.0.113.0", + "203.0.113.255", + "224.0.0.0", + "255.255.255.255", + "::2", + "100::", + "100::ffff:ffff:ffff:ffff", + "100:0:0:1::", + "100:0:0:1:ffff:ffff:ffff:ffff", + "64:ff9b:1::1", + "2001:5::1", + "2001:2::", + "2001:2:0:ffff:ffff:ffff:ffff:ffff", + "2001:db8::", + "2001:db8:ffff:ffff:ffff:ffff:ffff:ffff", + "3fff::", + "3fff:fff:ffff:ffff:ffff:ffff:ffff:ffff", + "5f00::1", + "fec0::", + "ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff", + "::ffff:192.0.2.1", + "app.test", + "service.internal", + "hidden.onion", + ]; + const publicHosts = [ + "191.255.255.255", + "192.0.1.255", + "192.0.3.0", + "198.51.99.255", + "198.51.101.0", + "203.0.112.255", + "203.0.114.0", + "223.255.255.255", + "1.1.1.1", + "2001:4860:4860::8888", + "2606:4700:4700::1111", + "64:ff9b::808:808", + "2001:1::1", + "2001:3::1", + "2001:4:112::1", + "2001:20::1", + "2001:30::1", + "::ffff:8.8.8.8", + "example.com", + ]; + expect(nonPublic.filter(isPublicFaviconHost)).toEqual([]); + expect(publicHosts.filter((host) => !isPublicFaviconHost(host))).toEqual([]); + }); }); diff --git a/apps/web/src/browser/browserTargetResolver.ts b/apps/web/src/browser/browserTargetResolver.ts index 3c3be59b457..99158353f3d 100644 --- a/apps/web/src/browser/browserTargetResolver.ts +++ b/apps/web/src/browser/browserTargetResolver.ts @@ -8,7 +8,10 @@ import { isLoopbackHost, normalizePreviewUrl } from "@t3tools/shared/preview"; import { readPreparedConnection } from "~/state/session"; export const normalizeHostname = (host: string): string => - host.toLowerCase().replace(/^\[|\]$/g, ""); + host + .toLowerCase() + .replace(/^\[|\]$/g, "") + .replace(/\.$/u, ""); const parseIpv4Address = (host: string): readonly number[] | null => { const parts = normalizeHostname(host).split(".").map(Number); @@ -18,28 +21,91 @@ const parseIpv4Address = (host: string): readonly number[] | null => { : null; }; +const parseIpv4MappedIpv6Address = (host: string): readonly number[] | null => { + const normalized = normalizeHostname(host); + if (!normalized.startsWith("::ffff:")) return null; + const suffix = normalized.slice("::ffff:".length); + const dotted = parseIpv4Address(suffix); + if (dotted) return dotted; + const hextets = suffix.split(":"); + if (hextets.length !== 2 || hextets.some((part) => !/^[\da-f]{1,4}$/u.test(part))) return null; + const high = Number.parseInt(hextets[0]!, 16); + const low = Number.parseInt(hextets[1]!, 16); + return [high >>> 8, high & 0xff, low >>> 8, low & 0xff]; +}; + +const parseIpv6Address = (host: string): readonly number[] | null => { + const normalized = normalizeHostname(host); + if (!normalized.includes(":")) return null; + const halves = normalized.split("::"); + if (halves.length > 2) return null; + const head = halves[0] ? halves[0].split(":") : []; + const tail = halves[1] ? halves[1].split(":") : []; + if ([...head, ...tail].some((part) => !/^[\da-f]{1,4}$/u.test(part))) return null; + const missing = 8 - head.length - tail.length; + if ((halves.length === 1 && missing !== 0) || (halves.length === 2 && missing < 1)) return null; + return [...head, ...Array.from({ length: missing }, () => "0"), ...tail].map((part) => + Number.parseInt(part, 16), + ); +}; + +const ipv6PrefixMatches = ( + address: readonly number[], + prefix: readonly number[], + prefixLength: number, +): boolean => { + const fullHextets = Math.floor(prefixLength / 16); + if (address.slice(0, fullHextets).some((part, index) => part !== prefix[index])) return false; + const remainingBits = prefixLength % 16; + if (remainingBits === 0) return true; + const mask = (0xffff << (16 - remainingBits)) & 0xffff; + return (address[fullHextets]! & mask) === (prefix[fullHextets]! & mask); +}; + +const isPrivateIpv4Address = (parts: readonly number[]): boolean => + parts[0] === 0 || + parts[0] === 10 || + parts[0] === 127 || + (parts[0] === 100 && parts[1]! >= 64 && parts[1]! <= 127) || + (parts[0] === 172 && parts[1]! >= 16 && parts[1]! <= 31) || + (parts[0] === 192 && parts[1] === 168) || + (parts[0] === 169 && parts[1] === 254) || + (parts[0] === 198 && parts[1]! >= 18 && parts[1]! <= 19); + +const isSpecialPurposeIpv4Address = (parts: readonly number[]): boolean => + isPrivateIpv4Address(parts) || + parts[0]! >= 224 || + // Deliberately suppress the whole protocol-assignment block. IANA marks + // .9 and .10 globally reachable, but privacy-safe false negatives are + // preferable to disclosing another special-purpose address by mistake. + (parts[0] === 192 && parts[1] === 0 && parts[2] === 0) || + (parts[0] === 192 && parts[1] === 0 && parts[2] === 2) || + (parts[0] === 192 && parts[1] === 88 && parts[2] === 99) || + (parts[0] === 198 && parts[1] === 51 && parts[2] === 100) || + (parts[0] === 203 && parts[1] === 0 && parts[2] === 113); + export const isLocalLoopbackHost = (host: string): boolean => { const normalized = normalizeHostname(host); if (normalized === "localhost" || normalized === "::1") return true; return parseIpv4Address(normalized)?.[0] === 127; }; -const isPrivateNetworkHost = (host: string): boolean => { +export const isPrivateNetworkHost = (host: string): boolean => { const normalized = normalizeHostname(host); - if (isLocalLoopbackHost(normalized) || normalized.endsWith(".local")) { + if ( + normalized === "::" || + isLocalLoopbackHost(normalized) || + normalized.endsWith(".localhost") || + normalized.endsWith(".local") || + normalized === "home.arpa" || + normalized.endsWith(".home.arpa") || + (!normalized.includes(".") && !normalized.includes(":")) + ) { return true; } if (normalized.endsWith(".ts.net")) return true; - const parts = parseIpv4Address(normalized); - if (parts) { - return ( - parts[0] === 10 || - (parts[0] === 100 && parts[1]! >= 64 && parts[1]! <= 127) || - (parts[0] === 172 && parts[1]! >= 16 && parts[1]! <= 31) || - (parts[0] === 192 && parts[1] === 168) || - (parts[0] === 169 && parts[1] === 254) - ); - } + const parts = parseIpv4Address(normalized) ?? parseIpv4MappedIpv6Address(normalized); + if (parts) return isPrivateIpv4Address(parts); const firstIpv6Token = normalized.split(":", 1)[0] ?? ""; if (!normalized.includes(":") || !/^[\da-f]{1,4}$/u.test(firstIpv6Token)) return false; const firstIpv6Hextet = Number.parseInt(firstIpv6Token, 16); @@ -49,6 +115,42 @@ const isPrivateNetworkHost = (host: string): boolean => { ); }; +/** Whether a hostname is eligible to be disclosed to a public favicon provider. */ +export const isPublicFaviconHost = (host: string): boolean => { + const normalized = normalizeHostname(host); + if (isPrivateNetworkHost(normalized)) return false; + if ( + [".alt", ".example", ".internal", ".invalid", ".onion", ".test"].some( + (suffix) => normalized === suffix.slice(1) || normalized.endsWith(suffix), + ) + ) { + return false; + } + const ipv4 = parseIpv4Address(normalized) ?? parseIpv4MappedIpv6Address(normalized); + if (ipv4) return !isSpecialPurposeIpv4Address(ipv4); + if (!normalized.includes(":")) return true; + const ipv6 = parseIpv6Address(normalized); + if (!ipv6) return false; + if (ipv6PrefixMatches(ipv6, [0x0064, 0xff9b, 0, 0, 0, 0, 0, 0], 96)) return true; + const first = ipv6[0]!; + if ((first & 0xe000) !== 0x2000) return false; + if (ipv6PrefixMatches(ipv6, [0x2001, 0, 0, 0, 0, 0, 0, 0], 23)) { + const publicProtocolAssignment = + (ipv6[1] === 1 && + ipv6.slice(2, 7).every((part) => part === 0) && + [1, 2, 3].includes(ipv6[7]!)) || + ipv6PrefixMatches(ipv6, [0x2001, 3, 0, 0, 0, 0, 0, 0], 32) || + ipv6PrefixMatches(ipv6, [0x2001, 4, 0x0112, 0, 0, 0, 0, 0], 48) || + ipv6PrefixMatches(ipv6, [0x2001, 0x20, 0, 0, 0, 0, 0, 0], 28) || + ipv6PrefixMatches(ipv6, [0x2001, 0x30, 0, 0, 0, 0, 0, 0], 28); + return publicProtocolAssignment; + } + if (ipv6PrefixMatches(ipv6, [0x2001, 0x0db8, 0, 0, 0, 0, 0, 0], 32)) return false; + if (ipv6PrefixMatches(ipv6, [0x2002, 0, 0, 0, 0, 0, 0, 0], 16)) return false; + if (first === 0x3fff && (ipv6[1]! & 0xf000) === 0) return false; + return true; +}; + const readEnvironmentUrl = (environmentId: EnvironmentId): URL => { const connection = readPreparedConnection(environmentId); if (!connection) throw new Error(`Environment ${environmentId} is not connected.`); diff --git a/apps/web/src/browserFaviconLogic.test.ts b/apps/web/src/browserFaviconLogic.test.ts new file mode 100644 index 00000000000..177d056f554 --- /dev/null +++ b/apps/web/src/browserFaviconLogic.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + BROWSER_FAVICON_MAX_ENTRIES, + type BrowserFaviconEntry, + evictExcessFavicons, + faviconKey, + isStorableFaviconDataUrl, + migratePersistedBrowserFaviconState, +} from "./browserFaviconLogic"; + +const PNG = "data:image/png;base64,AAAA"; + +function entry(capturedAt = 0): BrowserFaviconEntry { + return { dataUrl: PNG, capturedAt }; +} + +describe("browser favicon logic", () => { + it("keys valid origins canonically while keeping distinct scopes separate", () => { + expect(faviconKey("env:project", "http://myapp.test:3000/admin?x=1", null)).toBe( + "env:project http://myapp.test:3000", + ); + expect(faviconKey("env:project", "http://192.168.64.2:3000/", "192.168.64.2")).toBe( + faviconKey("env:project", "http://localhost:3000/", "192.168.64.2"), + ); + expect(faviconKey("env:project", "http://127.0.0.1:3000/", null)).toBe( + faviconKey("env:project", "http://0.0.0.0:3000/", null), + ); + const keys = [ + faviconKey("env:a", "http://localhost:3000/", null), + faviconKey("env:b", "http://localhost:3000/", null), + faviconKey("env:a", "http://localhost:5173/", null), + faviconKey("env:a", "https://localhost:3000/", null), + faviconKey("env:a", "http://192.168.1.50:3000/", "192.168.64.2"), + ]; + expect(new Set(keys).size).toBe(keys.length); + expect(faviconKey("env:a", "not a url", null)).toBeNull(); + expect(faviconKey("env:a", "ftp://example.com/", null)).toBeNull(); + expect(faviconKey("", "http://localhost/", null)).toBeNull(); + }); + + it("accepts only bounded base64 PNG data", () => { + expect(isStorableFaviconDataUrl(PNG)).toBe(true); + expect(isStorableFaviconDataUrl("data:image/svg+xml;base64,AAAA")).toBe(false); + expect(isStorableFaviconDataUrl("data:image/png;base64,")).toBe(false); + expect(isStorableFaviconDataUrl("data:image/png;base64,%%%%")).toBe(false); + expect(isStorableFaviconDataUrl(`data:image/png;base64,${"A".repeat(8192)}`)).toBe(false); + }); + + it("evicts old entries and sanitizes hydrated state", () => { + const byKey = Object.fromEntries( + Array.from({ length: BROWSER_FAVICON_MAX_ENTRIES + 2 }, (_, index) => [ + `key-${index}`, + entry(index), + ]), + ); + const result = evictExcessFavicons(byKey); + expect(Object.keys(result)).toHaveLength(BROWSER_FAVICON_MAX_ENTRIES); + expect(result["key-0"]).toBeUndefined(); + expect(result["key-1"]).toBeUndefined(); + expect( + migratePersistedBrowserFaviconState({ + byKey: { + "env:project http://local:3000": entry(5), + "env:project http://local:3001": { + dataUrl: "https://example.com/icon.png", + capturedAt: 6, + }, + "env:project http://local:3002": { dataUrl: PNG, capturedAt: Number.NaN }, + }, + }), + ).toEqual({ byKey: { "env:project http://local:3000": entry(5) } }); + }); +}); diff --git a/apps/web/src/browserFaviconLogic.ts b/apps/web/src/browserFaviconLogic.ts new file mode 100644 index 00000000000..7759a7a7feb --- /dev/null +++ b/apps/web/src/browserFaviconLogic.ts @@ -0,0 +1,111 @@ +import { FAVICON_CAPTURED_AT_MAX, FAVICON_DATA_URL_MAX_LENGTH } from "@t3tools/contracts"; + +import { isLocalLoopbackHost, normalizeHostname } from "./browser/browserTargetResolver"; + +export type BrowserFaviconEntry = { dataUrl: string; capturedAt: number }; + +export const BROWSER_FAVICON_MAX_ENTRIES = 40; +export const BROWSER_FAVICON_MAX_KEY_LENGTH = 4_096; +const BROWSER_FAVICON_MAX_FUTURE_SKEW_MS = 5 * 60 * 1_000; + +export function canCanonicalizeFaviconWithoutEnvironment(url: string): boolean { + try { + const parsed = new URL(url); + const host = normalizeHostname(parsed.hostname); + return ( + (parsed.protocol === "http:" || parsed.protocol === "https:") && + (isLocalLoopbackHost(host) || host === "0.0.0.0") + ); + } catch { + return false; + } +} + +export function isValidFaviconCapturedAt(value: unknown): value is number { + return ( + typeof value === "number" && + Number.isFinite(value) && + value >= 0 && + value <= FAVICON_CAPTURED_AT_MAX && + value <= Date.now() + BROWSER_FAVICON_MAX_FUTURE_SKEW_MS + ); +} + +function isValidPersistedFaviconKey(key: string): boolean { + if (key.length === 0 || key.length > BROWSER_FAVICON_MAX_KEY_LENGTH) return false; + const separator = key.indexOf(" "); + if (separator <= 0) return false; + const origin = key.slice(separator + 1); + return origin.startsWith("http://") || origin.startsWith("https://"); +} + +export function faviconKey( + projectRefKey: string, + url: string, + environmentHostname: string | null, +): string | null { + if (projectRefKey.length === 0) return null; + try { + const parsed = new URL(url); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null; + const host = normalizeHostname(parsed.hostname); + const canonicalHost = + isLocalLoopbackHost(host) || + host === "0.0.0.0" || + (environmentHostname !== null && host === normalizeHostname(environmentHostname)) + ? "local" + : host; + const port = parsed.port || (parsed.protocol === "https:" ? "443" : "80"); + return `${projectRefKey} ${parsed.protocol}//${canonicalHost}:${port}`; + } catch { + return null; + } +} + +export function isStorableFaviconDataUrl(value: unknown): value is string { + if ( + typeof value !== "string" || + !value.startsWith("data:image/png;base64,") || + value.length > FAVICON_DATA_URL_MAX_LENGTH + ) { + return false; + } + const payload = value.slice("data:image/png;base64,".length); + return ( + payload.length > 0 && + payload.length % 4 !== 1 && + !/[^a-z0-9+/=]/i.test(payload) && + /^[a-z0-9+/]*={0,2}$/i.test(payload) + ); +} + +export function evictExcessFavicons( + byKey: Record, +): Record { + const keys = Object.keys(byKey); + if (keys.length <= BROWSER_FAVICON_MAX_ENTRIES) return byKey; + return Object.fromEntries( + keys + .toSorted((left, right) => (byKey[right]?.capturedAt ?? 0) - (byKey[left]?.capturedAt ?? 0)) + .slice(0, BROWSER_FAVICON_MAX_ENTRIES) + .map((key) => [key, byKey[key] as BrowserFaviconEntry]), + ); +} + +export function migratePersistedBrowserFaviconState(persistedState: unknown): { + byKey: Record; +} { + if (!persistedState || typeof persistedState !== "object") return { byKey: {} }; + const raw = "byKey" in persistedState ? (persistedState as { byKey?: unknown }).byKey : null; + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return { byKey: {} }; + const byKey: Record = {}; + for (const [key, value] of Object.entries(raw as Record)) { + if (!isValidPersistedFaviconKey(key)) continue; + if (!value || typeof value !== "object") continue; + const { dataUrl, capturedAt } = value as Record; + if (!isStorableFaviconDataUrl(dataUrl)) continue; + if (!isValidFaviconCapturedAt(capturedAt)) continue; + byKey[key] = { dataUrl, capturedAt }; + } + return { byKey: evictExcessFavicons(byKey) }; +} diff --git a/apps/web/src/browserFaviconStore.test.ts b/apps/web/src/browserFaviconStore.test.ts new file mode 100644 index 00000000000..75f09d99779 --- /dev/null +++ b/apps/web/src/browserFaviconStore.test.ts @@ -0,0 +1,196 @@ +import { scopeProjectRef } from "@t3tools/client-runtime/environment"; +import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +vi.mock("~/state/entities", () => ({ useThreadShell: () => null })); +vi.mock("~/state/session", () => ({ usePreparedConnection: () => ({ _tag: "None" }) })); + +import { + flushPendingFaviconsForThread, + lookupFavicon, + mergeBrowserFaviconState, + recordFaviconForProject, + recordFaviconForThread, + registerFaviconProjectForThread, + resetBrowserFaviconsForTests, + resolveBrowserFaviconStorage, + useBrowserFaviconStore, +} from "./browserFaviconStore"; + +const environmentId = EnvironmentId.make("env-1"); +const projectRef = scopeProjectRef(environmentId, ProjectId.make("project-1")); +const threadRef = { environmentId, threadId: ThreadId.make("thread-1") }; +const PNG = "data:image/png;base64,AAAA"; +const favicon = (pageUrl: string, capturedAt: number, dataUrl = PNG) => ({ + pageUrl, + capturedAt, + dataUrl, +}); + +describe("browser favicon store", () => { + beforeEach(resetBrowserFaviconsForTests); + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("keeps the newest capture for an origin and permits an identical later revisit", () => { + const recordFavicon = vi.spyOn(useBrowserFaviconStore.getState(), "recordFavicon"); + recordFaviconForProject(projectRef, favicon("http://localhost:3000/", 20), null); + recordFaviconForProject( + projectRef, + favicon("http://localhost:3000/old", 10, "data:image/png;base64,QkJCQg=="), + null, + ); + recordFaviconForProject(projectRef, favicon("http://localhost:3000/new", 30), null); + recordFaviconForProject(projectRef, favicon("http://localhost:3000/new", 30), null); + expect(Object.values(useBrowserFaviconStore.getState().byKey)).toEqual([ + { dataUrl: PNG, capturedAt: 30 }, + ]); + expect(recordFavicon).toHaveBeenCalledTimes(2); + }); + + it("does not share localhost icons across environments or physical projects", () => { + const otherEnvironment = scopeProjectRef( + EnvironmentId.make("env-2"), + ProjectId.make("project-1"), + ); + const otherProject = scopeProjectRef(environmentId, ProjectId.make("project-2")); + recordFaviconForProject(projectRef, favicon("http://localhost:3000/", 1), null); + recordFaviconForProject(otherEnvironment, favicon("http://localhost:3000/", 2), null); + recordFaviconForProject(otherProject, favicon("http://localhost:3000/", 3), null); + expect(Object.keys(useBrowserFaviconStore.getState().byKey)).toEqual([ + "env-1:project-1 http://local:3000", + "env-2:project-1 http://local:3000", + "env-1:project-2 http://local:3000", + ]); + }); + + it("finds a persisted localhost icon after shell hydration without a live connection host", () => { + recordFaviconForProject(projectRef, favicon("http://192.168.64.2:3000/app", 5), "192.168.64.2"); + const byKey = useBrowserFaviconStore.getState().byKey; + expect(lookupFavicon(byKey, null, "http://localhost:3000/app", null)).toBeNull(); + expect(lookupFavicon(byKey, projectRef, "http://localhost:3000/app", null)).toBe(PNG); + }); + + it("retains multiple origins until project and connection metadata hydrate", () => { + expect( + recordFaviconForThread(threadRef, favicon("http://localhost:3000/", 1), null, undefined), + ).toBe(false); + expect( + recordFaviconForThread(threadRef, favicon("http://localhost:5173/", 2), null, undefined), + ).toBe(false); + expect( + Object.keys(Object.values(useBrowserFaviconStore.getState().pendingByThreadKey)[0] ?? {}), + ).toHaveLength(2); + + expect(flushPendingFaviconsForThread(threadRef, projectRef, "192.168.64.2")).toBe(true); + expect(Object.keys(useBrowserFaviconStore.getState().byKey).toSorted()).toEqual([ + "env-1:project-1 http://local:3000", + "env-1:project-1 http://local:5173", + ]); + expect(useBrowserFaviconStore.getState().pendingByThreadKey).toEqual({}); + }); + + it("persists unambiguous loopback captures while the environment is offline", () => { + expect( + recordFaviconForThread( + threadRef, + favicon("http://localhost:3000/", 1), + projectRef, + undefined, + ), + ).toBe(true); + expect(useBrowserFaviconStore.getState().byKey).toEqual({ + "env-1:project-1 http://local:3000": { dataUrl: PNG, capturedAt: 1 }, + }); + + recordFaviconForThread( + threadRef, + favicon("http://192.168.64.2:5173/", 2), + projectRef, + undefined, + ); + expect(flushPendingFaviconsForThread(threadRef, projectRef, undefined)).toBe(false); + expect(Object.values(useBrowserFaviconStore.getState().pendingByThreadKey)[0]).toBeDefined(); + }); + + it("keeps pending captures in store-owned state independent of bridge lifetime", () => { + recordFaviconForThread(threadRef, favicon("http://localhost:3000/", 10), null, undefined); + const pendingAfterUnmount = useBrowserFaviconStore.getState().pendingByThreadKey; + useBrowserFaviconStore.setState({ pendingByThreadKey: pendingAfterUnmount }); + flushPendingFaviconsForThread(threadRef, projectRef, "localhost"); + expect(useBrowserFaviconStore.getState().byKey).toEqual({ + "env-1:project-1 http://local:3000": { dataUrl: PNG, capturedAt: 10 }, + }); + }); + + it("flushes and resolves a pending draft-thread favicon after physical project registration", () => { + recordFaviconForThread(threadRef, favicon("http://localhost:8025/", 10), null, undefined); + registerFaviconProjectForThread(threadRef, projectRef); + const registered = useBrowserFaviconStore.getState().projectRefByThreadKey["env-1:thread-1"]; + expect(registered).toEqual(projectRef); + expect(flushPendingFaviconsForThread(threadRef, registered!, undefined)).toBe(true); + expect( + lookupFavicon( + useBrowserFaviconStore.getState().byKey, + registered!, + "http://localhost:8025/", + null, + ), + ).toBe(PNG); + }); + + it("bounds pending memory by origin and thread", () => { + for (let thread = 0; thread < 22; thread += 1) { + for (let port = 3000; port < 3012; port += 1) { + recordFaviconForThread( + { environmentId, threadId: ThreadId.make(`thread-${thread}`) }, + favicon(`http://localhost:${port}/`, port), + null, + undefined, + ); + } + } + const pending = useBrowserFaviconStore.getState().pendingByThreadKey; + expect(Object.keys(pending)).toHaveLength(20); + expect(Object.values(pending).every((byOrigin) => Object.keys(byOrigin).length === 10)).toBe( + true, + ); + }); + + it("sanitizes hydrated state while preserving actions and transient pending data", () => { + recordFaviconForThread(threadRef, favicon("http://localhost:3000/", 1), null, undefined); + const current = useBrowserFaviconStore.getState(); + const merged = mergeBrowserFaviconState( + { + byKey: { + "env-1:project-1 http://local:3000": { dataUrl: PNG, capturedAt: 2 }, + "env-1:project-1 http://local:3001": { dataUrl: "bad", capturedAt: 3 }, + "env-1:project-1 http://local:3002": { dataUrl: PNG, capturedAt: 1e308 }, + ["x".repeat(5_000)]: { dataUrl: PNG, capturedAt: 4 }, + }, + }, + current, + ); + expect(merged.byKey).toEqual({ + "env-1:project-1 http://local:3000": { dataUrl: PNG, capturedAt: 2 }, + }); + expect(merged.pendingByThreadKey).toEqual(current.pendingByThreadKey); + expect(typeof merged.recordFavicon).toBe("function"); + }); + + it("falls back to memory when localStorage access throws", () => { + vi.stubGlobal( + "window", + Object.defineProperty({}, "localStorage", { + get: () => { + throw new Error("storage blocked"); + }, + }), + ); + const storage = resolveBrowserFaviconStorage(); + storage.setItem("key", "value"); + expect(storage.getItem("key")).toBe("value"); + }); +}); diff --git a/apps/web/src/browserFaviconStore.ts b/apps/web/src/browserFaviconStore.ts new file mode 100644 index 00000000000..5e883292c61 --- /dev/null +++ b/apps/web/src/browserFaviconStore.ts @@ -0,0 +1,267 @@ +import { + scopedProjectKey, + scopedThreadKey, + scopeProjectRef, +} from "@t3tools/client-runtime/environment"; +import type { DesktopPreviewFavicon, ScopedProjectRef, ScopedThreadRef } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { useMemo } from "react"; +import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; + +import { useThreadShell } from "~/state/entities"; +import { usePreparedConnection } from "~/state/session"; + +import { + type BrowserFaviconEntry, + canCanonicalizeFaviconWithoutEnvironment, + evictExcessFavicons, + faviconKey, + isStorableFaviconDataUrl, + isValidFaviconCapturedAt, + migratePersistedBrowserFaviconState, +} from "./browserFaviconLogic"; +import { resolveStorage } from "./lib/storage"; + +const BROWSER_FAVICON_STORAGE_KEY = "t3code:browser-favicons:v1"; +const MAX_PENDING_ORIGINS_PER_THREAD = 10; +const MAX_PENDING_THREADS = 20; +const MAX_REGISTERED_THREADS = 100; + +type PendingFavicon = DesktopPreviewFavicon; +type PendingFaviconsByOrigin = Record; + +export interface BrowserFaviconStoreState { + byKey: Record; + /** Capture buffering only. */ + pendingByThreadKey: Record; + /** Non-persisted fallback for draft/background threads without a hydrated shell. */ + projectRefByThreadKey: Record; + recordFavicon: (key: string, dataUrl: string, capturedAt: number) => void; +} + +function pendingOriginKey(pageUrl: string): string | null { + return faviconKey("pending", pageUrl, null)?.slice("pending ".length) ?? null; +} + +function addPendingFavicon( + pendingByThreadKey: Record, + threadKey: string, + favicon: PendingFavicon, +): Record { + const originKey = pendingOriginKey(favicon.pageUrl); + if (!originKey) return pendingByThreadKey; + const current = pendingByThreadKey[threadKey] ?? {}; + const existing = current[originKey]; + if (existing && existing.capturedAt >= favicon.capturedAt) return pendingByThreadKey; + const nextForThread = { + ...current, + [originKey]: favicon, + }; + const boundedForThread = Object.fromEntries( + Object.entries(nextForThread) + .toSorted(([, left], [, right]) => right.capturedAt - left.capturedAt) + .slice(0, MAX_PENDING_ORIGINS_PER_THREAD), + ); + + const withoutThread = { ...pendingByThreadKey }; + delete withoutThread[threadKey]; + return Object.fromEntries( + [...Object.entries(withoutThread), [threadKey, boundedForThread]].slice(-MAX_PENDING_THREADS), + ); +} + +export function resolveBrowserFaviconStorage() { + try { + return resolveStorage(typeof window !== "undefined" ? window.localStorage : undefined); + } catch { + return resolveStorage(undefined); + } +} + +export const useBrowserFaviconStore = create()( + persist( + (set) => ({ + byKey: {}, + pendingByThreadKey: {}, + projectRefByThreadKey: {}, + recordFavicon: (key, dataUrl, capturedAt) => + set((state) => { + if (!isStorableFaviconDataUrl(dataUrl)) return state; + if (!isValidFaviconCapturedAt(capturedAt)) return state; + const existing = state.byKey[key]; + if (existing && capturedAt <= existing.capturedAt) return state; + return { + byKey: evictExcessFavicons({ + ...state.byKey, + [key]: { dataUrl, capturedAt }, + }), + }; + }), + }), + { + name: BROWSER_FAVICON_STORAGE_KEY, + version: 1, + storage: createJSONStorage(resolveBrowserFaviconStorage), + partialize: (state) => ({ byKey: state.byKey }), + migrate: migratePersistedBrowserFaviconState, + merge: mergeBrowserFaviconState, + }, + ), +); + +export function mergeBrowserFaviconState( + persistedState: unknown, + currentState: BrowserFaviconStoreState, +): BrowserFaviconStoreState { + return { + ...currentState, + ...migratePersistedBrowserFaviconState(persistedState), + }; +} + +export function registerFaviconProjectForThread( + threadRef: ScopedThreadRef, + projectRef: ScopedProjectRef, +): void { + const threadKey = scopedThreadKey(threadRef); + const state = useBrowserFaviconStore.getState(); + const current = state.projectRefByThreadKey[threadKey]; + if ( + current?.environmentId === projectRef.environmentId && + current.projectId === projectRef.projectId + ) { + return; + } + useBrowserFaviconStore.setState({ + projectRefByThreadKey: Object.fromEntries( + [ + ...Object.entries(state.projectRefByThreadKey).filter(([key]) => key !== threadKey), + [threadKey, projectRef], + ].slice(-MAX_REGISTERED_THREADS), + ), + }); +} + +export function useFaviconProjectRefForThread(threadRef: ScopedThreadRef): ScopedProjectRef | null { + const shell = useThreadShell(threadRef); + const shellProjectId = shell?.projectId ?? null; + const shellProjectRef = useMemo( + () => (shellProjectId ? scopeProjectRef(threadRef.environmentId, shellProjectId) : null), + [shellProjectId, threadRef.environmentId], + ); + const registered = useBrowserFaviconStore( + (state) => state.projectRefByThreadKey[scopedThreadKey(threadRef)] ?? null, + ); + return shellProjectRef ?? registered; +} + +export function recordFaviconForProject( + projectRef: ScopedProjectRef, + favicon: DesktopPreviewFavicon, + environmentHostname: string | null, +): boolean { + if (!isStorableFaviconDataUrl(favicon.dataUrl) || !isValidFaviconCapturedAt(favicon.capturedAt)) { + return false; + } + const key = faviconKey(scopedProjectKey(projectRef), favicon.pageUrl, environmentHostname); + if (!key) return false; + const state = useBrowserFaviconStore.getState(); + if (state.byKey[key] && state.byKey[key]!.capturedAt >= favicon.capturedAt) return true; + state.recordFavicon(key, favicon.dataUrl, favicon.capturedAt); + return true; +} + +export function recordFaviconForThread( + threadRef: ScopedThreadRef, + favicon: DesktopPreviewFavicon, + projectRef: ScopedProjectRef | null, + environmentHostname: string | undefined, +): boolean { + if ( + !isStorableFaviconDataUrl(favicon.dataUrl) || + !isValidFaviconCapturedAt(favicon.capturedAt) || + !pendingOriginKey(favicon.pageUrl) + ) + return false; + const hostname = + environmentHostname !== undefined + ? environmentHostname + : canCanonicalizeFaviconWithoutEnvironment(favicon.pageUrl) + ? null + : undefined; + if ( + projectRef && + hostname !== undefined && + recordFaviconForProject(projectRef, favicon, hostname) + ) { + return true; + } + const threadKey = scopedThreadKey(threadRef); + const state = useBrowserFaviconStore.getState(); + const pendingByThreadKey = addPendingFavicon(state.pendingByThreadKey, threadKey, favicon); + if (pendingByThreadKey !== state.pendingByThreadKey) { + useBrowserFaviconStore.setState({ pendingByThreadKey }); + } + return false; +} + +export function flushPendingFaviconsForThread( + threadRef: ScopedThreadRef, + projectRef: ScopedProjectRef, + environmentHostname: string | undefined, +): boolean { + const threadKey = scopedThreadKey(threadRef); + const pending = useBrowserFaviconStore.getState().pendingByThreadKey[threadKey]; + if (!pending) return true; + const remaining = Object.fromEntries( + Object.entries(pending).filter(([, favicon]) => { + const hostname = + environmentHostname !== undefined + ? environmentHostname + : canCanonicalizeFaviconWithoutEnvironment(favicon.pageUrl) + ? null + : undefined; + return hostname === undefined || !recordFaviconForProject(projectRef, favicon, hostname); + }), + ); + useBrowserFaviconStore.setState((state) => { + const pendingByThreadKey = { ...state.pendingByThreadKey }; + if (Object.keys(remaining).length === 0) delete pendingByThreadKey[threadKey]; + else pendingByThreadKey[threadKey] = remaining; + return { pendingByThreadKey }; + }); + return Object.keys(remaining).length === 0; +} + +export function useFaviconForThreadUrl(threadRef: ScopedThreadRef, url: string): string | null { + const projectRef = useFaviconProjectRefForThread(threadRef); + const preparedConnection = usePreparedConnection(threadRef.environmentId); + const environmentHostname = Option.isSome(preparedConnection) + ? new URL(preparedConnection.value.httpBaseUrl).hostname + : null; + return useBrowserFaviconStore((state) => + lookupFavicon(state.byKey, projectRef, url, environmentHostname), + ); +} + +export function lookupFavicon( + byKey: Record, + projectRef: ScopedProjectRef | null, + url: string, + environmentHostname: string | null, +): string | null { + const key = projectRef + ? faviconKey(scopedProjectKey(projectRef), url, environmentHostname) + : null; + return key ? (byKey[key]?.dataUrl ?? null) : null; +} + +export function resetBrowserFaviconsForTests(): void { + useBrowserFaviconStore.setState({ + byKey: {}, + pendingByThreadKey: {}, + projectRefByThreadKey: {}, + }); + useBrowserFaviconStore.persist.clearStorage(); +} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 1f00c177c30..c9264943b21 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -178,6 +178,7 @@ import { } from "~/projectScripts"; import { newDraftId, newMessageId, newThreadId } from "~/lib/utils"; import { useBrowserHistoryStore } from "~/browserHistoryStore"; +import { registerFaviconProjectForThread } from "~/browserFaviconStore"; import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels"; import { NO_PROVIDER_MODEL_SELECTION } from "../providerInstances"; import { @@ -1703,9 +1704,11 @@ function ChatViewContent(props: ChatViewProps) { }); }, [activeThreadKey, existingOpenTerminalThreadKeys, terminalUiState.terminalOpen]); const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null); - const activeProjectRef = activeThread - ? scopeProjectRef(activeThread.environmentId, activeThread.projectId) - : null; + const activeProjectRef = useMemo( + () => + activeThread ? scopeProjectRef(activeThread.environmentId, activeThread.projectId) : null, + [activeThread?.environmentId, activeThread?.projectId], + ); const activeProject = useProject(activeProjectRef); const handleNewThreadInActiveProject = useCallback(() => { startNewThreadForProject(activeProjectRef, handleNewThread); @@ -1757,6 +1760,10 @@ function ChatViewContent(props: ChatViewProps) { // drive the environment picker in BranchToolbar. const allProjects = useProjects(); const primaryEnvironmentId = primaryEnvironment?.environmentId ?? null; + useEffect(() => { + if (!activeThreadRef || !activeProjectRef) return; + registerFaviconProjectForThread(activeThreadRef, activeProjectRef); + }, [activeProjectRef, activeThreadRef]); useEffect(() => { if (!clientSettingsHydrated || !activeThreadRef || !activeProject) return; // Reuse the sidebar's grouping so history follows the project rows the user @@ -6542,6 +6549,7 @@ function ChatViewContent(props: ChatViewProps) { activeSurfaceId={activeRightPanelSurface?.id ?? null} pendingSurfaceIds={pendingFileSurfaceIds} previewSessions={activePreviewState.sessions} + desktopByTabId={activePreviewState.desktopByTabId} terminalLabelsById={activeTerminalLabelsById} onActivate={activateRightPanelSurface} onCloseSurface={closeRightPanelSurface} @@ -6576,6 +6584,7 @@ function ChatViewContent(props: ChatViewProps) { activeSurfaceId={activeRightPanelSurface?.id ?? null} pendingSurfaceIds={pendingFileSurfaceIds} previewSessions={activePreviewState.sessions} + desktopByTabId={activePreviewState.desktopByTabId} terminalLabelsById={activeTerminalLabelsById} onActivate={activateRightPanelSurface} onCloseSurface={closeRightPanelSurface} diff --git a/apps/web/src/components/RightPanelTabs.test.tsx b/apps/web/src/components/RightPanelTabs.test.tsx new file mode 100644 index 00000000000..27a200027f5 --- /dev/null +++ b/apps/web/src/components/RightPanelTabs.test.tsx @@ -0,0 +1,106 @@ +import type { DesktopPreviewFavicon, PreviewSessionSnapshot } from "@t3tools/contracts"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vite-plus/test"; + +import { RightPanelTabs } from "./RightPanelTabs"; + +const previewSurface = { + id: "browser:tab-1" as const, + kind: "preview" as const, + resourceId: "tab-1", +}; +const secondSurface = { + id: "browser:tab-2" as const, + kind: "preview" as const, + resourceId: "tab-2", +}; +const sessions: Readonly> = { + "tab-1": { + threadId: "thread-1", + tabId: "tab-1", + navStatus: { _tag: "Success", url: "http://24x.xf.local/", title: "Local site" }, + canGoBack: false, + canGoForward: false, + updatedAt: "2026-08-09T00:00:00.000Z", + }, + "tab-2": { + threadId: "thread-1", + tabId: "tab-2", + navStatus: { _tag: "Success", url: "http://24x.xf.local/admin", title: "Admin" }, + canGoBack: false, + canGoForward: false, + updatedAt: "2026-08-09T00:00:00.000Z", + }, +}; + +const favicon = (dataUrl: string, pageUrl: string): DesktopPreviewFavicon => ({ + dataUrl, + pageUrl, + capturedAt: 1, +}); + +function overlay(icon: DesktopPreviewFavicon | null) { + return { + hasWebContents: true, + canGoBack: false, + canGoForward: false, + loading: false, + zoomFactor: 1, + pictureInPicture: false, + colorScheme: "system" as const, + controller: "none" as const, + favicon: icon, + }; +} + +function renderTabs(first: DesktopPreviewFavicon | null, second?: DesktopPreviewFavicon) { + return renderToStaticMarkup( + undefined} + onCloseSurface={() => undefined} + onCloseOtherSurfaces={() => undefined} + onCloseSurfacesToRight={() => undefined} + onCloseAllSurfaces={() => undefined} + onCopyFilePath={() => undefined} + onAddBrowser={() => undefined} + onAddTerminal={() => undefined} + onAddDiff={() => undefined} + onAddFiles={() => undefined} + onAddAgents={() => undefined} + liveAgentCount={0} + browserAvailable + diffAvailable={false} + filesAvailable={false} + > +
content
+
, + ); +} + +describe("RightPanelTabs preview favicon", () => { + it("prefers a live capture and never asks Google about a private hostname", () => { + const captured = renderTabs(favicon("data:image/png;base64,AAAA", "http://24x.xf.local/")); + expect(captured).toContain("data:image/png;base64,AAAA"); + expect(captured).not.toContain("s2/favicons"); + expect(renderTabs(null)).not.toContain("s2/favicons"); + }); + + it("keeps route-specific captures isolated between live tabs on one origin", () => { + const html = renderTabs( + favicon("data:image/png;base64,AAAA", "http://24x.xf.local/"), + favicon("data:image/png;base64,BBBB", "http://24x.xf.local/admin"), + ); + expect(html).toContain("data:image/png;base64,AAAA"); + expect(html).toContain("data:image/png;base64,BBBB"); + }); +}); diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index 32c66dca6ae..3a04ec59faf 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -18,10 +18,10 @@ import { useCallback, useEffect, useRef, - useState, } from "react"; import { isElectron } from "~/env"; +import type { DesktopPreviewOverlay } from "~/previewStateStore"; import type { RightPanelSurface } from "~/rightPanelStore"; import { cn } from "~/lib/utils"; import { readLocalApi } from "~/localApi"; @@ -34,6 +34,7 @@ import { useTheme } from "~/hooks/useTheme"; import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; import { PreviewPanelShell, type PreviewPanelMode } from "./preview/PreviewPanelShell"; +import { FaviconImage } from "./preview/PreviewFaviconIcon"; import { PierreEntryIcon } from "./chat/PierreEntryIcon"; interface RightPanelTabsProps { @@ -48,6 +49,7 @@ interface RightPanelTabsProps { activeSurfaceId: string | null; pendingSurfaceIds: ReadonlySet; previewSessions: Readonly>; + desktopByTabId: Readonly>; terminalLabelsById: ReadonlyMap; onActivate: (surface: RightPanelSurface) => void; onCloseSurface: (surface: RightPanelSurface) => void; @@ -435,18 +437,13 @@ function surfaceTitle( } } -function PreviewFavicon({ url }: { url: string | null }) { - const faviconUrl = faviconUrlForOrigin(url, 32); - const [failedUrl, setFailedUrl] = useState(null); - if (!faviconUrl || failedUrl === faviconUrl) return ; +function PreviewFavicon({ capturedUrl, url }: { capturedUrl: string | null; url: string | null }) { + const publicProviderUrl = faviconUrlForOrigin(url, 32); return ( - setFailedUrl(faviconUrl)} + } + className="size-3 shrink-0 rounded-sm object-contain" /> ); } @@ -454,11 +451,13 @@ function PreviewFavicon({ url }: { url: string | null }) { function SurfaceIcon({ surface, sessions, + desktopByTabId, theme, pullRequestStatuses, }: { surface: RightPanelSurface; sessions: Readonly>; + desktopByTabId: Readonly>; theme: "light" | "dark"; pullRequestStatuses: Readonly> | undefined; }) { @@ -466,7 +465,10 @@ function SurfaceIcon({ case "preview": { const snapshot = surface.resourceId ? sessions[surface.resourceId] : null; const url = !snapshot || snapshot.navStatus._tag === "Idle" ? null : snapshot.navStatus.url; - return ; + const capturedUrl = snapshot + ? (desktopByTabId[snapshot.tabId]?.favicon?.dataUrl ?? null) + : null; + return ; } case "diff": return ; @@ -636,6 +638,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { diff --git a/apps/web/src/components/preview/PreviewEmptyState.test.tsx b/apps/web/src/components/preview/PreviewEmptyState.test.tsx index 86cab6dbe2b..95e21c0266a 100644 --- a/apps/web/src/components/preview/PreviewEmptyState.test.tsx +++ b/apps/web/src/components/preview/PreviewEmptyState.test.tsx @@ -1,4 +1,4 @@ -import { EnvironmentId } from "@t3tools/contracts"; +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it, vi } from "vite-plus/test"; @@ -19,10 +19,14 @@ const mocks = vi.hoisted(() => ({ vi.mock("./useDiscoveredLocalServers", () => ({ useDiscoveredLocalServers: () => mocks.servers, })); +vi.mock("./PreviewFaviconIcon", () => ({ + PreviewFaviconIcon: () => , +})); import { PreviewEmptyState } from "./PreviewEmptyState"; const environmentId = EnvironmentId.make("env-1"); +const threadRef = { environmentId, threadId: ThreadId.make("thread-1") }; function server(port: number) { return { @@ -41,6 +45,7 @@ function server(port: number) { function render(recentEntries: Array<{ url: string; lastVisitedAt: number; title?: string }>) { return renderToStaticMarkup( undefined} diff --git a/apps/web/src/components/preview/PreviewEmptyState.tsx b/apps/web/src/components/preview/PreviewEmptyState.tsx index 3b9aacf4dfd..4e74f44cb2a 100644 --- a/apps/web/src/components/preview/PreviewEmptyState.tsx +++ b/apps/web/src/components/preview/PreviewEmptyState.tsx @@ -1,4 +1,4 @@ -import type { EnvironmentId } from "@t3tools/contracts"; +import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts"; import { Globe, History, RadioTower } from "lucide-react"; import type { BrowserHistoryEntry } from "~/browserHistoryStore"; @@ -9,6 +9,7 @@ import { PreviewRecentUrlCard } from "./PreviewRecentUrlCard"; import { useDiscoveredLocalServers } from "./useDiscoveredLocalServers"; interface Props { + threadRef: ScopedThreadRef; environmentId: EnvironmentId; configuredUrls?: ReadonlyArray | undefined; recentlySeenUrls?: ReadonlyArray | undefined; @@ -18,6 +19,7 @@ interface Props { } export function PreviewEmptyState({ + threadRef, environmentId, configuredUrls, recentlySeenUrls, @@ -60,6 +62,7 @@ export function PreviewEmptyState({ {recents.map((entry) => ( onOpenUrl(entry.url)} onRemove={() => onRemoveRecent(entry.url)} @@ -78,6 +81,7 @@ export function PreviewEmptyState({ {servers.map((server) => ( onOpenUrl(server.requestedUrl)} /> diff --git a/apps/web/src/components/preview/PreviewFaviconIcon.test.tsx b/apps/web/src/components/preview/PreviewFaviconIcon.test.tsx new file mode 100644 index 00000000000..d950a99b59f --- /dev/null +++ b/apps/web/src/components/preview/PreviewFaviconIcon.test.tsx @@ -0,0 +1,51 @@ +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vite-plus/test"; + +const mocks = vi.hoisted(() => ({ favicon: null as string | null })); + +vi.mock("~/browserFaviconStore", () => ({ + useFaviconForThreadUrl: () => mocks.favicon, +})); + +import { FaviconImage, PreviewFaviconIcon, selectFaviconSource } from "./PreviewFaviconIcon"; + +const threadRef = { + environmentId: EnvironmentId.make("env-1"), + threadId: ThreadId.make("thread-1"), +}; + +describe("preview favicon image", () => { + it("renders a captured source before later fallback sources", () => { + expect( + renderToStaticMarkup( + fallback} + />, + ), + ).toContain('src="data:image/png;base64,AAAA"'); + const captured = "data:image/png;base64,AAAA"; + const google = "https://public.example/icon"; + expect(selectFaviconSource([captured, google], new Set())).toBe(captured); + expect(selectFaviconSource([captured, google], new Set([captured]))).toBe(google); + expect(selectFaviconSource([captured, google], new Set([captured, google]))).toBeNull(); + expect(selectFaviconSource(["data:image/png;base64,BBBB", google], new Set([captured]))).toBe( + "data:image/png;base64,BBBB", + ); + }); + + it("uses a stored project icon or falls back to the browser mockup", () => { + mocks.favicon = null; + const html = renderToStaticMarkup( + , + ); + expect(html).not.toContain(", + ); + expect(faviconHtml).toContain('src="data:image/png;base64,AAAA"'); + }); +}); diff --git a/apps/web/src/components/preview/PreviewFaviconIcon.tsx b/apps/web/src/components/preview/PreviewFaviconIcon.tsx new file mode 100644 index 00000000000..111facfd82d --- /dev/null +++ b/apps/web/src/components/preview/PreviewFaviconIcon.tsx @@ -0,0 +1,66 @@ +import type { ScopedThreadRef } from "@t3tools/contracts"; +import { type ReactNode, useState } from "react"; + +import { useFaviconForThreadUrl } from "~/browserFaviconStore"; +import { cn } from "~/lib/utils"; + +import { BrowserMockup } from "./BrowserMockup"; + +export function selectFaviconSource( + sources: ReadonlyArray, + failed: ReadonlySet, +): string | null { + return sources.find((candidate) => !failed.has(candidate)) ?? null; +} + +export function FaviconImage(props: { + sources: ReadonlyArray; + fallback: ReactNode; + className?: string | undefined; +}) { + const sources = props.sources.filter((source): source is string => Boolean(source)); + return ( + + ); +} + +function FaviconImageAttempt(props: { + sources: ReadonlyArray; + fallback: ReactNode; + className?: string | undefined; +}) { + const [failed, setFailed] = useState>(() => new Set()); + const source = selectFaviconSource(props.sources, failed); + if (!source) return props.fallback; + return ( + setFailed((current) => new Set(current).add(source))} + /> + ); +} + +export function PreviewFaviconIcon(props: { + threadRef: ScopedThreadRef; + url: string; + className?: string | undefined; +}) { + const source = useFaviconForThreadUrl(props.threadRef, props.url); + const fallback = ; + return ( + + ); +} diff --git a/apps/web/src/components/preview/PreviewLocalServerCard.tsx b/apps/web/src/components/preview/PreviewLocalServerCard.tsx index c7b08ad2893..1e0f0132442 100644 --- a/apps/web/src/components/preview/PreviewLocalServerCard.tsx +++ b/apps/web/src/components/preview/PreviewLocalServerCard.tsx @@ -1,12 +1,15 @@ -import { BrowserMockup } from "./BrowserMockup"; +import type { ScopedThreadRef } from "@t3tools/contracts"; + +import { PreviewFaviconIcon } from "./PreviewFaviconIcon"; import type { PreviewableServer } from "./useDiscoveredLocalServers"; interface Props { + threadRef: ScopedThreadRef; server: PreviewableServer; onOpen: () => void; } -export function PreviewLocalServerCard({ server, onOpen }: Props) { +export function PreviewLocalServerCard({ threadRef, server, onOpen }: Props) { const subtitle = describeServer(server); return (