From 29f27baa756d119abfe678ccd6ac5bcf8185e435 Mon Sep 17 00:00:00 2001 From: Chris Deeming Date: Mon, 3 Aug 2026 15:29:44 +0100 Subject: [PATCH 1/2] feat(desktop): capture and store site favicons The guest page's favicon is only observable in the main process, so the desktop side captures it from page-favicon-updated, fetches it through the preview session and publishes it on the tab state. The renderer persists it keyed by project and origin. Icons are stored as data urls so they still render when the dev server is stopped, which is the common case on the splash. Bytes are stored verbatim where they fit, since ICO and SVG cannot be decoded by nativeImage. Empty and oversized payloads are rejected, and the captured origin travels with the icon so a sticky field cannot attribute one site's favicon to another. --- apps/desktop/src/preview/Manager.test.ts | 2103 +++++++++++++++-- apps/desktop/src/preview/Manager.ts | 773 +++++- apps/web/src/browserFaviconLogic.test.ts | 125 + apps/web/src/browserFaviconLogic.ts | 77 + apps/web/src/browserFaviconStore.test.ts | 219 ++ apps/web/src/browserFaviconStore.ts | 232 ++ apps/web/src/components/ChatView.tsx | 9 + .../components/preview/PreviewView.test.tsx | 1 + .../preview/usePreviewBridge.test.ts | 116 + .../components/preview/usePreviewBridge.ts | 85 +- apps/web/src/previewStateStore.test.ts | 4 + apps/web/src/previewStateStore.ts | 1 + packages/contracts/src/ipc.ts | 7 + 13 files changed, 3496 insertions(+), 256 deletions(-) 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/preview/usePreviewBridge.test.ts diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index a6ef30c2742..b1f725fa312 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -60,6 +60,7 @@ describe("isPreviewRefreshShortcut", () => { const { browserWindowConstructor, + createFromBuffer, createFromPath, fromId, getFocusedWebContents, @@ -70,6 +71,25 @@ const { writeImage, } = vi.hoisted(() => ({ browserWindowConstructor: vi.fn(), + createFromBuffer: vi.fn( + ( + buffer: Buffer, + ): { + readonly getSize: () => { readonly width: number; readonly height: number }; + readonly isEmpty: () => boolean; + readonly toDataURL: () => string; + readonly resize: (size: { width?: number; height?: number }) => { + readonly toDataURL: () => string; + }; + } => ({ + getSize: () => ({ width: 16, height: 16 }), + isEmpty: () => false, + toDataURL: () => `data:image/png;base64,${buffer.toString("base64")}`, + resize: () => ({ + toDataURL: () => `data:image/png;base64,${buffer.toString("base64")}`, + }), + }), + ), createFromPath: vi.fn((): { readonly isEmpty: () => boolean } => ({ isEmpty: () => false })), fromId: vi.fn((_id?: number) => null), getFocusedWebContents: vi.fn(() => null), @@ -86,6 +106,7 @@ vi.mock("electron", () => ({ writeImage, }, nativeImage: { + createFromBuffer, createFromPath, }, shell: { @@ -186,6 +207,96 @@ const makeTestPreviewWebContents = ( capturePage, }) as never; +const makeFaviconWebContents = (options: { + readonly id?: number; + readonly url: string; + readonly title: string; + readonly fetch: (url: string, init?: { readonly signal?: AbortSignal }) => Promise; + readonly loading?: boolean; + readonly loadURL?: (url: string) => Promise; + readonly rasterizedFavicon?: string | null | ((code: string) => string | null); +}) => { + const { id = 42, url, title, fetch } = options; + let currentUrl = url; + let loading = options.loading ?? false; + const listeners = new Map void>(); + const reload = vi.fn(); + const reloadIgnoringCache = vi.fn(); + const loadURL = vi.fn(async (nextUrl: string) => { + currentUrl = nextUrl; + await options.loadURL?.(nextUrl); + }); + const stop = vi.fn(() => { + loading = false; + }); + const executeJavaScriptInIsolatedWorld = vi.fn( + async (_worldId: number, scripts: ReadonlyArray<{ code: string }>) => { + const result = options.rasterizedFavicon; + return typeof result === "function" ? result(scripts[0]?.code ?? "") : (result ?? null); + }, + ); + const webContents = { + id, + isDestroyed: () => false, + getType: () => "webview", + getURL: () => currentUrl, + getTitle: () => title, + isLoading: () => loading, + getZoomFactor: () => 1, + setZoomFactor: vi.fn(), + reload, + reloadIgnoringCache, + stop, + loadURL, + on: vi.fn((event: string, listener: (...args: unknown[]) => void) => { + listeners.set(event, listener); + }), + off: vi.fn(), + ipc: { on: vi.fn(), off: vi.fn() }, + send: webviewSend, + navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setWindowOpenHandler: vi.fn(), + executeJavaScriptInIsolatedWorld, + debugger: { + isAttached: () => false, + attach: vi.fn(), + sendCommand: vi.fn(async () => undefined), + on: vi.fn(), + off: vi.fn(), + }, + session: { fetch }, + } as never; + return { + webContents, + listeners, + reload, + reloadIgnoringCache, + loadURL, + stop, + executeJavaScriptInIsolatedWorld, + setUrl: (nextUrl: string) => { + currentUrl = nextUrl; + }, + setLoading: (nextLoading: boolean) => { + loading = nextLoading; + }, + }; +}; + +// Lets pending microtasks (fetch resolution, favicon publication) drain +// before an assertion runs. `extra` gives incorrect-publication paths a few +// more ticks to surface before we assert on their absence. +const settle = function* (until: () => boolean, extra = 5) { + for (let i = 0; i < 20 && !until(); i++) { + yield* Effect.promise(() => Promise.resolve()); + yield* Effect.yieldNow; + } + for (let i = 0; i < extra; i++) { + yield* Effect.promise(() => Promise.resolve()); + yield* Effect.yieldNow; + } +}; + const makeTestPictureInPictureWindow = (loadURL: () => Promise = async () => undefined) => { const listeners = new Map void>(); const send = vi.fn(); @@ -375,6 +486,123 @@ describe("PreviewManager", () => { ), ); + effectIt.effect("does not hold the tab lifecycle lock while a page load is pending", () => + withManager((manager) => + Effect.gen(function* () { + let resolveFirstLoad!: () => void; + const firstLoad = new Promise((resolve) => { + resolveFirstLoad = resolve; + }); + let loadCount = 0; + const { webContents, loadURL } = makeFaviconWebContents({ + url: "http://localhost:3200/", + title: "Pending navigation", + fetch: vi.fn(), + loadURL: () => (++loadCount === 1 ? firstLoad : Promise.resolve()), + }); + fromId.mockReturnValue(webContents); + yield* manager.createTab("tab_pending_lifecycle"); + yield* manager.registerWebview("tab_pending_lifecycle", 42); + + const firstFiber = yield* manager + .navigate("tab_pending_lifecycle", "http://localhost:3201/") + .pipe(Effect.forkChild({ startImmediately: true })); + yield* settle(() => loadURL.mock.calls.length === 1, 0); + + let secondFinished = false; + const secondFiber = yield* manager + .navigate("tab_pending_lifecycle", "http://localhost:3202/") + .pipe( + Effect.ensuring(Effect.sync(() => (secondFinished = true))), + Effect.forkChild({ startImmediately: true }), + ); + yield* settle(() => secondFinished, 0); + const secondFinishedBeforeFirstLoad = secondFinished; + + let closeFinished = false; + const closeFiber = yield* manager + .closeTab("tab_pending_lifecycle") + .pipe( + Effect.ensuring(Effect.sync(() => (closeFinished = true))), + Effect.forkChild({ startImmediately: true }), + ); + yield* settle(() => closeFinished, 0); + const closeFinishedBeforeFirstLoad = closeFinished; + + resolveFirstLoad(); + yield* Fiber.join(firstFiber); + yield* Fiber.join(secondFiber); + yield* Fiber.join(closeFiber); + + expect(loadURL).toHaveBeenCalledTimes(2); + expect(secondFinishedBeforeFirstLoad).toBe(true); + expect(closeFinishedBeforeFirstLoad).toBe(true); + }), + ), + ); + + effectIt.effect("stops a pending load without replacing the current favicon", () => + withManager((manager) => + Effect.gen(function* () { + const origin = "http://localhost:3203"; + const currentUrl = `${origin}/current`; + const nextUrl = `${origin}/next`; + const currentFaviconUrl = `${origin}/current.png`; + const nextFaviconUrl = `${origin}/next.png`; + const currentBytes = Buffer.from("current-page-favicon"); + const nextBytes = Buffer.from("favicon-from-aborted-load"); + const fetch = vi.fn(async (url: string) => { + const bytes = url === currentFaviconUrl ? currentBytes : nextBytes; + return { + ok: true, + arrayBuffer: async () => + bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength), + headers: { get: (name: string) => (name === "content-type" ? "image/png" : null) }, + }; + }); + const { webContents, listeners, reload, setLoading, setUrl, stop } = makeFaviconWebContents( + { + url: currentUrl, + title: "Pending load", + fetch, + }, + ); + fromId.mockReturnValue(webContents); + const states: PreviewManager.PreviewTabState[] = []; + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + states.push(state); + }), + ); + yield* manager.createTab("tab_stop_loading"); + yield* manager.registerWebview("tab_stop_loading", 42); + listeners.get("page-favicon-updated")?.({}, [currentFaviconUrl]); + const currentFavicon = `data:image/png;base64,${currentBytes.toString("base64")}`; + yield* settle(() => states.at(-1)?.favicon === currentFavicon); + + yield* manager.navigate("tab_stop_loading", nextUrl); + setLoading(true); + listeners.get("page-favicon-updated")?.({}, [nextFaviconUrl]); + yield* settle(() => false); + expect(fetch).toHaveBeenCalledTimes(2); + expect(states.at(-1)?.navStatus).toMatchObject({ kind: "Loading", url: nextUrl }); + expect(states.at(-1)?.favicon).toBe(currentFavicon); + + yield* manager.refresh("tab_stop_loading"); + setUrl(currentUrl); + listeners.get("did-fail-load")?.({}, -3, "ERR_ABORTED", nextUrl, true); + listeners.get("did-stop-loading")?.(); + yield* settle(() => false); + + expect(stop).toHaveBeenCalledOnce(); + expect(reload).not.toHaveBeenCalled(); + expect(states.at(-1)?.navStatus).toMatchObject({ kind: "Success", url: currentUrl }); + expect(states.at(-1)?.favicon).toBe(currentFavicon); + expect(states.at(-1)?.faviconOrigin).toBe(origin); + }), + ), + ); + effectIt.effect("mirrors Electron's effective zoom across registration and navigation", () => withManager((manager) => Effect.gen(function* () { @@ -466,249 +694,1732 @@ describe("PreviewManager", () => { on: vi.fn(), off: vi.fn(), }, - } as never); - - yield* manager.registerWebview("tab_zoom", 43); - - expect(replacementSetZoomFactor).toHaveBeenCalledWith(1.25); - expect(states.at(-1)?.zoomFactor).toBe(1.25); + } as never); + + yield* manager.registerWebview("tab_zoom", 43); + + expect(replacementSetZoomFactor).toHaveBeenCalledWith(1.25); + expect(states.at(-1)?.zoomFactor).toBe(1.25); + }), + ), + ); + + effectIt.effect("emulates prefers-color-scheme and re-applies it across webview swaps", () => + withManager((manager) => + Effect.gen(function* () { + const makeWebContents = (id: number) => { + const sendCommand = vi.fn(async () => undefined); + return { + sendCommand, + wc: { + id, + isDestroyed: () => false, + isDevToolsOpened: () => false, + getType: () => "webview", + getURL: () => "https://example.com", + getTitle: () => "Example", + isLoading: () => false, + getZoomFactor: () => 1, + setZoomFactor: vi.fn(), + on: vi.fn(), + off: vi.fn(), + ipc: { on: vi.fn(), off: vi.fn() }, + send: webviewSend, + navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setWindowOpenHandler: vi.fn(), + debugger: { + isAttached: () => false, + attach: vi.fn(), + sendCommand, + on: vi.fn(), + off: vi.fn(), + }, + } as never, + }; + }; + const first = makeWebContents(42); + fromId.mockReturnValue(first.wc); + const states: PreviewManager.PreviewTabState[] = []; + + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + states.push(state); + }), + ); + yield* manager.createTab("tab_scheme"); + yield* manager.registerWebview("tab_scheme", 42); + yield* Effect.yieldNow; + + yield* manager.setColorScheme("tab_scheme", "dark"); + + expect(first.sendCommand).toHaveBeenCalledWith("Emulation.setEmulatedMedia", { + features: [{ name: "prefers-color-scheme", value: "dark" }], + }); + expect(states.at(-1)?.colorScheme).toBe("dark"); + + const replacement = makeWebContents(43); + fromId.mockReturnValue(replacement.wc); + yield* manager.registerWebview("tab_scheme", 43); + yield* Effect.yieldNow; + + expect(replacement.sendCommand).toHaveBeenCalledWith("Emulation.setEmulatedMedia", { + features: [{ name: "prefers-color-scheme", value: "dark" }], + }); + expect(states.at(-1)?.colorScheme).toBe("dark"); + + yield* manager.setColorScheme("tab_scheme", "system"); + + expect(replacement.sendCommand).toHaveBeenCalledWith("Emulation.setEmulatedMedia", { + features: [{ name: "prefers-color-scheme", value: "" }], + }); + expect(states.at(-1)?.colorScheme).toBe("system"); + }), + ), + ); + + effectIt.effect("blocks late webview and capture starts during tab close", () => + withManager((manager) => + Effect.gen(function* () { + const capturePage = vi.fn(async () => ({ + toJPEG: () => Buffer.from("close-race-frame"), + getSize: () => ({ width: 1280, height: 720 }), + })); + const firstWebContents = makeTestPreviewWebContents(capturePage, 42); + const replacementWebContents = makeTestPreviewWebContents(capturePage, 43); + const replacementListenerSpies = replacementWebContents as unknown as { + readonly on: ReturnType; + readonly off: ReturnType; + readonly ipc: { readonly off: ReturnType }; + }; + fromId.mockImplementation((id) => { + if (id === 42) return firstWebContents; + if (id === 43) return replacementWebContents; + return null; + }); + const { pictureInPictureWindow } = makeTestPictureInPictureWindow(); + browserWindowConstructor.mockImplementation(function () { + return pictureInPictureWindow; + }); + + yield* manager.createTab("tab_close_register_race"); + yield* manager.registerWebview("tab_close_register_race", 42); + yield* manager.openPictureInPicture("tab_close_register_race"); + + const closeCleanupPaused = yield* Deferred.make(); + const continueCloseCleanup = yield* Deferred.make(); + yield* manager.subscribeStateChanges((_tabId, state) => + !state.pictureInPicture && state.webContentsId === 42 + ? Deferred.succeed(closeCleanupPaused, undefined).pipe( + Effect.andThen(Deferred.await(continueCloseCleanup)), + ) + : Effect.void, + ); + + const closeFiber = yield* manager + .closeTab("tab_close_register_race") + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(closeCleanupPaused); + const recreateFiber = yield* manager + .createTab("tab_close_register_race") + .pipe(Effect.forkChild({ startImmediately: true })); + const registrationFiber = yield* manager + .registerWebview("tab_close_register_race", 43) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.yieldNow; + expect(replacementListenerSpies.on).not.toHaveBeenCalled(); + yield* manager.closeTab("tab_close_register_race"); + const recordingExit = yield* Effect.exit(manager.startRecording("tab_close_register_race")); + yield* Deferred.succeed(continueCloseCleanup, undefined); + yield* Fiber.join(closeFiber); + const recreated = yield* Fiber.join(recreateFiber); + const registrationExit = yield* Fiber.await(registrationFiber); + + for (const exit of [registrationExit, recordingExit]) { + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isSuccess(exit)) continue; + expect(Option.getOrThrow(Cause.findErrorOption(exit.cause))).toMatchObject({ + _tag: "PreviewTabNotFoundError", + tabId: "tab_close_register_race", + }); + } + expect(replacementListenerSpies.on).not.toHaveBeenCalled(); + expect(replacementListenerSpies.off).not.toHaveBeenCalled(); + expect(replacementListenerSpies.ipc.off).not.toHaveBeenCalled(); + expect(capturePage).toHaveBeenCalledOnce(); + expect(recreated.webContentsId).toBeNull(); + }), + ), + ); + + effectIt.effect("keeps a main-frame load failure visible until a retry starts", () => + withManager((manager) => + Effect.gen(function* () { + const url = "http://localhost:5733/"; + let loading = false; + const listeners = new Map void>(); + fromId.mockReturnValue({ + id: 42, + isDestroyed: () => false, + getType: () => "webview", + getURL: () => url, + getTitle: () => "localhost:5733", + isLoading: () => loading, + getZoomFactor: () => 1, + setZoomFactor: vi.fn(), + on: vi.fn((event: string, listener: (...args: unknown[]) => void) => { + listeners.set(event, listener); + }), + off: vi.fn(), + ipc: { on: vi.fn(), off: vi.fn() }, + send: webviewSend, + navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setWindowOpenHandler: vi.fn(), + debugger: { + isAttached: () => false, + attach: vi.fn(), + sendCommand: vi.fn(async () => undefined), + on: vi.fn(), + off: vi.fn(), + }, + } as never); + const statuses: PreviewManager.PreviewNavStatus[] = []; + + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + statuses.push(state.navStatus); + }), + ); + yield* manager.createTab("tab_failed"); + yield* manager.registerWebview("tab_failed", 42); + + listeners.get("did-fail-load")?.( + {}, + -105, + "ERR_NAME_NOT_RESOLVED", + "https://missing-frame.example/", + false, + ); + yield* Effect.yieldNow; + expect(statuses.at(-1)?.kind).toBe("Success"); + + loading = true; + listeners.get("did-start-loading")?.(); + yield* Effect.yieldNow; + expect(statuses.at(-1)?.kind).toBe("Loading"); + + loading = false; + listeners.get("did-fail-load")?.({}, -102, "ERR_CONNECTION_REFUSED", url, true); + listeners.get("did-stop-loading")?.(); + listeners.get("page-title-updated")?.(); + yield* Effect.yieldNow; + expect(statuses.at(-1)).toEqual({ + kind: "LoadFailed", + url, + title: "localhost:5733", + code: -102, + description: "ERR_CONNECTION_REFUSED", + }); + + loading = true; + listeners.get("did-start-loading")?.(); + yield* Effect.yieldNow; + expect(statuses.at(-1)?.kind).toBe("Loading"); + + loading = false; + listeners.get("did-stop-loading")?.(); + yield* Effect.yieldNow; + expect(statuses.at(-1)?.kind).toBe("Success"); + + listeners.get("did-fail-load")?.({}, -102, "ERR_CONNECTION_REFUSED", url, true); + yield* Effect.yieldNow; + expect(statuses.at(-1)?.kind).toBe("LoadFailed"); + + listeners.get("did-navigate")?.(); + yield* Effect.yieldNow; + expect(statuses.at(-1)?.kind).toBe("Success"); + }), + ), + ); + + effectIt.effect("clears a stale favicon when a main-frame navigation fails", () => + withManager((manager) => + Effect.gen(function* () { + const origin = "http://localhost:5737"; + const bytes = Buffer.from("published-before-navigation-failure"); + const fetch = vi.fn(async () => ({ + ok: true, + arrayBuffer: async () => + bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength), + headers: { get: (name: string) => (name === "content-type" ? "image/png" : null) }, + })); + const { webContents, listeners } = makeFaviconWebContents({ + url: `${origin}/current`, + title: "Current page", + fetch, + }); + fromId.mockReturnValue(webContents); + const states: PreviewManager.PreviewTabState[] = []; + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + states.push(state); + }), + ); + yield* manager.createTab("tab_favicon_failed_navigation"); + yield* manager.registerWebview("tab_favicon_failed_navigation", 42); + + listeners.get("page-favicon-updated")?.({}, [`${origin}/favicon.png`]); + yield* settle(() => states.at(-1)?.favicon !== undefined); + + listeners.get("did-fail-load")?.( + {}, + -105, + "ERR_NAME_NOT_RESOLVED", + `${origin}/subframe`, + false, + ); + yield* Effect.yieldNow; + expect(states.at(-1)?.favicon).toBeDefined(); + + listeners.get("did-start-navigation")?.({ isMainFrame: true, isSameDocument: false }); + listeners.get("did-fail-load")?.({}, -3, "ERR_ABORTED", `${origin}/aborted`, true); + yield* Effect.yieldNow; + expect(states.at(-1)?.navStatus.kind).toBe("Success"); + expect(states.at(-1)?.favicon).toBeDefined(); + + const failedUrl = `${origin}/failed`; + listeners.get("did-start-navigation")?.({ isMainFrame: true, isSameDocument: false }); + listeners.get("did-fail-load")?.({}, -102, "ERR_CONNECTION_REFUSED", failedUrl, true); + yield* settle(() => states.at(-1)?.navStatus.kind === "LoadFailed"); + + expect(states.at(-1)?.navStatus).toMatchObject({ + kind: "LoadFailed", + url: failedUrl, + }); + expect(states.at(-1)?.favicon).toBeUndefined(); + expect(states.at(-1)?.faviconOrigin).toBeUndefined(); + }), + ), + ); + + effectIt.effect("captures a favicon onto the tab state", () => + withManager((manager) => + Effect.gen(function* () { + const url = "http://localhost:5733/"; + const png = Buffer.from("tiny-png-bytes-but-above-min"); + const brokenFavicon = "http://localhost:5733/broken.ico"; + const fetch = vi.fn(async (faviconUrl: string) => + faviconUrl === brokenFavicon + ? { ok: false } + : { + ok: true, + arrayBuffer: async () => + png.buffer.slice(png.byteOffset, png.byteOffset + png.byteLength), + headers: { + get: (name: string) => (name === "content-type" ? "Image/X-Icon" : null), + }, + }, + ); + const { webContents, listeners } = makeFaviconWebContents({ + url, + title: "localhost:5733", + fetch, + }); + fromId.mockReturnValue(webContents); + const states: PreviewManager.PreviewTabState[] = []; + + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + states.push(state); + }), + ); + yield* manager.createTab("tab_favicon"); + yield* manager.registerWebview("tab_favicon", 42); + + expect(listeners.has("page-favicon-updated")).toBe(true); + const inlineFavicon = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="; + listeners.get("page-favicon-updated")?.({}, [ + "file:///tmp/favicon.png", + brokenFavicon, + inlineFavicon, + ]); + + yield* settle(() => states.at(-1)?.favicon !== undefined); + + expect(fetch).toHaveBeenNthCalledWith(1, brokenFavicon, { + credentials: "include", + redirect: "error", + signal: expect.any(AbortSignal), + }); + expect(fetch).toHaveBeenCalledOnce(); + expect(states.at(-1)?.favicon).toBe(inlineFavicon); + expect(states.at(-1)?.faviconOrigin).toBe("http://localhost:5733"); + }), + ), + ); + + effectIt.effect("decodes headerless and generic binary favicon responses", () => + withManager((manager) => + Effect.gen(function* () { + const origin = "http://localhost:5744"; + const headerlessUrl = `${origin}/headerless.ico`; + const genericUrl = `${origin}/generic.ico`; + const unsupportedUrl = `${origin}/not-an-image`; + const fetch = vi.fn(async (url: string) => { + const bytes = Buffer.from(url); + const contentType = + url === headerlessUrl + ? null + : url === genericUrl + ? "application/octet-stream" + : "text/html"; + return { + ok: true, + arrayBuffer: async () => + bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength), + headers: { get: (name: string) => (name === "content-type" ? contentType : null) }, + }; + }); + const { webContents, listeners } = makeFaviconWebContents({ + url: `${origin}/`, + title: "Generic favicon responses", + fetch, + }); + fromId.mockReturnValue(webContents); + const states: PreviewManager.PreviewTabState[] = []; + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + states.push(state); + }), + ); + yield* manager.createTab("tab_favicon_generic_mime"); + yield* manager.registerWebview("tab_favicon_generic_mime", 42); + + const faviconUpdated = listeners.get("page-favicon-updated"); + faviconUpdated?.({}, [headerlessUrl]); + const headerlessFavicon = `data:image/png;base64,${Buffer.from(headerlessUrl).toString("base64")}`; + yield* settle(() => states.at(-1)?.favicon === headerlessFavicon); + + faviconUpdated?.({}, [genericUrl]); + const genericFavicon = `data:image/png;base64,${Buffer.from(genericUrl).toString("base64")}`; + yield* settle(() => states.at(-1)?.favicon === genericFavicon); + + const decodeCount = createFromBuffer.mock.calls.length; + faviconUpdated?.({}, [unsupportedUrl]); + yield* settle(() => false); + + expect(createFromBuffer).toHaveBeenCalledTimes(decodeCount); + expect(states.at(-1)?.favicon).toBe(genericFavicon); + }), + ), + ); + + effectIt.effect("publishes a loading-time favicon after a later candidate fails", () => + withManager((manager) => + Effect.gen(function* () { + const origin = "http://localhost:5738"; + const bytes = Buffer.from("favicon-during-load"); + const failedUrl = `${origin}/missing.png`; + const fetch = vi.fn(async (url: string) => + url === failedUrl + ? { ok: false } + : { + ok: true, + arrayBuffer: async () => + bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength), + headers: { + get: (name: string) => (name === "content-type" ? "image/png" : null), + }, + }, + ); + const { webContents, listeners, setLoading } = makeFaviconWebContents({ + url: `${origin}/app`, + title: "Loading favicon", + fetch, + loading: true, + }); + fromId.mockReturnValue(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); + + const faviconUrl = `${origin}/favicon.png`; + listeners.get("page-favicon-updated")?.({}, [faviconUrl, `${origin}/fallback.png`]); + yield* settle(() => fetch.mock.calls.length === 1); + expect(states.at(-1)?.navStatus.kind).toBe("Loading"); + expect(states.at(-1)?.favicon).toBeUndefined(); + listeners.get("page-favicon-updated")?.({}, [faviconUrl]); + yield* settle(() => false); + expect(fetch).toHaveBeenCalledOnce(); + listeners.get("page-favicon-updated")?.({}, [failedUrl]); + yield* settle(() => fetch.mock.calls.length === 2); + + setLoading(false); + listeners.get("did-stop-loading")?.(); + yield* settle(() => states.at(-1)?.favicon !== undefined); + + expect(states.at(-1)?.navStatus.kind).toBe("Success"); + expect(states.at(-1)?.favicon).toBe(`data:image/png;base64,${bytes.toString("base64")}`); + }), + ), + ); + + effectIt.effect("decodes percent-encoded inline favicons without fetching", () => + withManager((manager) => + Effect.gen(function* () { + const fetch = vi.fn(async () => ({ ok: false })); + const { webContents, listeners } = makeFaviconWebContents({ + url: "http://localhost:5741/", + title: "Inline SVG", + fetch, + rasterizedFavicon: "data:image/png;base64,RASTERIZED", + }); + fromId.mockReturnValue(webContents); + const states: PreviewManager.PreviewTabState[] = []; + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + states.push(state); + }), + ); + yield* manager.createTab("tab_favicon_inline_svg"); + yield* manager.registerWebview("tab_favicon_inline_svg", 42); + + const binary = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + listeners.get("page-favicon-updated")?.({}, ["data:image/png,%89PNG%0D%0A%1A%0A"]); + yield* settle( + () => states.at(-1)?.favicon === `data:image/png;base64,${binary.toString("base64")}`, + ); + + listeners.get("page-favicon-updated")?.({}, [ + "data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3C%2Fsvg%3E", + ]); + yield* settle(() => states.at(-1)?.favicon === "data:image/png;base64,RASTERIZED"); + + expect(fetch).not.toHaveBeenCalled(); + expect(states.at(-1)?.favicon).toBe("data:image/png;base64,RASTERIZED"); + }), + ), + ); + + effectIt.effect("omits credentials for cross-origin favicon requests", () => + withManager((manager) => + Effect.gen(function* () { + const bytes = Buffer.from("cross-origin-favicon"); + const fetch = vi.fn(async () => ({ + ok: true, + arrayBuffer: async () => + bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength), + headers: { get: (name: string) => (name === "content-type" ? "image/png" : null) }, + })); + const { webContents, listeners } = makeFaviconWebContents({ + url: "http://localhost:5739/", + title: "Cross-origin favicon", + fetch, + }); + fromId.mockReturnValue(webContents); + yield* manager.createTab("tab_favicon_cross_origin"); + yield* manager.registerWebview("tab_favicon_cross_origin", 42); + + const faviconUrl = "https://static.example.test/favicon.png"; + listeners.get("page-favicon-updated")?.({}, [faviconUrl]); + yield* settle(() => fetch.mock.calls.length === 1); + + expect(fetch).toHaveBeenCalledWith(faviconUrl, { + credentials: "omit", + redirect: "error", + signal: expect.any(AbortSignal), + }); + }), + ), + ); + + effectIt.effect("normalizes animated favicon formats to a static PNG", () => + withManager((manager) => + Effect.gen(function* () { + const bytes = Buffer.from("animated-favicon"); + const fetch = vi.fn(async () => ({ + ok: true, + arrayBuffer: async () => + bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength), + headers: { get: (name: string) => (name === "content-type" ? "image/gif" : null) }, + })); + const { webContents, listeners } = makeFaviconWebContents({ + url: "http://localhost:5742/", + title: "Animated favicon", + fetch, + }); + fromId.mockReturnValue(webContents); + const states: PreviewManager.PreviewTabState[] = []; + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + states.push(state); + }), + ); + yield* manager.createTab("tab_favicon_animated"); + yield* manager.registerWebview("tab_favicon_animated", 42); + + listeners.get("page-favicon-updated")?.({}, ["http://localhost:5742/favicon.gif"]); + yield* settle(() => states.at(-1)?.favicon !== undefined); + + expect(states.at(-1)?.favicon).toBe(`data:image/png;base64,${bytes.toString("base64")}`); + }), + ), + ); + + effectIt.effect("preserves aspect ratio while bounding bitmap favicons", () => + withManager((manager) => + Effect.gen(function* () { + const landscapeResize = vi.fn(() => ({ + toDataURL: () => "data:image/png;base64,LANDSCAPE", + })); + const portraitResize = vi.fn(() => ({ + toDataURL: () => "data:image/png;base64,PORTRAIT", + })); + const smallResize = vi.fn(() => ({ + toDataURL: () => "data:image/png;base64,RESIZED_SMALL", + })); + createFromBuffer + .mockReturnValueOnce({ + getSize: () => ({ width: 64, height: 16 }), + isEmpty: () => false, + toDataURL: () => "data:image/png;base64,UNRESIZED_LANDSCAPE", + resize: landscapeResize, + }) + .mockReturnValueOnce({ + getSize: () => ({ width: 16, height: 64 }), + isEmpty: () => false, + toDataURL: () => "data:image/png;base64,UNRESIZED_PORTRAIT", + resize: portraitResize, + }) + .mockReturnValueOnce({ + getSize: () => ({ width: 24, height: 12 }), + isEmpty: () => false, + toDataURL: () => "data:image/png;base64,SMALL", + resize: smallResize, + }); + const origin = "http://localhost:5743"; + const fetch = vi.fn(async (url: string) => { + const bytes = Buffer.from(url); + return { + ok: true, + arrayBuffer: async () => + bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength), + headers: { get: (name: string) => (name === "content-type" ? "image/png" : null) }, + }; + }); + const { webContents, listeners } = makeFaviconWebContents({ + url: `${origin}/`, + title: "Non-square favicon", + fetch, + }); + fromId.mockReturnValue(webContents); + const states: PreviewManager.PreviewTabState[] = []; + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + states.push(state); + }), + ); + yield* manager.createTab("tab_favicon_aspect_ratio"); + yield* manager.registerWebview("tab_favicon_aspect_ratio", 42); + + const faviconUpdated = listeners.get("page-favicon-updated"); + faviconUpdated?.({}, [`${origin}/landscape.png`]); + yield* settle(() => states.at(-1)?.favicon === "data:image/png;base64,LANDSCAPE"); + faviconUpdated?.({}, [`${origin}/portrait.png`]); + yield* settle(() => states.at(-1)?.favicon === "data:image/png;base64,PORTRAIT"); + faviconUpdated?.({}, [`${origin}/small.png`]); + yield* settle(() => states.at(-1)?.favicon === "data:image/png;base64,SMALL"); + + expect(landscapeResize).toHaveBeenCalledWith({ width: 32 }); + expect(portraitResize).toHaveBeenCalledWith({ height: 32 }); + expect(smallResize).not.toHaveBeenCalled(); + }), + ), + ); + + effectIt.effect("bounds favicon candidates and URL length before fetching", () => + withManager((manager) => + Effect.gen(function* () { + const fetch = vi.fn(async () => ({ ok: false })); + const { webContents, listeners } = makeFaviconWebContents({ + url: "http://localhost:5740/", + title: "Bounded favicons", + fetch, + }); + fromId.mockReturnValue(webContents); + yield* manager.createTab("tab_favicon_bounds"); + yield* manager.registerWebview("tab_favicon_bounds", 42); + + const candidates = Array.from( + { length: 10 }, + (_, index) => `http://localhost:5740/favicon-${index}.png`, + ); + listeners.get("page-favicon-updated")?.({}, candidates); + yield* settle(() => fetch.mock.calls.length === 8); + listeners.get("page-favicon-updated")?.({}, [ + `http://localhost:5740/${"x".repeat(2_100)}.png`, + ]); + const decodesBeforeOversizedInline = createFromBuffer.mock.calls.length; + listeners.get("page-favicon-updated")?.({}, [ + `data:image/png;base64,${"A".repeat(140_000)}`, + ]); + yield* settle(() => false); + + expect(fetch).toHaveBeenCalledTimes(8); + expect(fetch).not.toHaveBeenCalledWith(candidates[8], expect.anything()); + expect(createFromBuffer).toHaveBeenCalledTimes(decodesBeforeOversizedInline); + }), + ), + ); + + effectIt.effect("keeps a favicon within its origin and clears it on an origin change", () => + withManager((manager) => + Effect.gen(function* () { + const origin = "http://localhost:5734"; + const bytes = Buffer.from("navigation-favicon-bytes"); + const fetch = vi.fn(async () => ({ + ok: true, + arrayBuffer: async () => + bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength), + headers: { + get: (name: string) => (name === "content-type" ? "image/png" : null), + }, + })); + const { webContents, listeners, setLoading, setUrl } = makeFaviconWebContents({ + url: `${origin}/first`, + title: "Navigation favicon", + fetch, + }); + fromId.mockReturnValue(webContents); + const states: PreviewManager.PreviewTabState[] = []; + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + states.push(state); + }), + ); + yield* manager.createTab("tab_favicon_document_navigation"); + yield* manager.registerWebview("tab_favicon_document_navigation", 42); + + listeners.get("page-favicon-updated")?.({}, [`${origin}/favicon.png`]); + yield* settle(() => states.at(-1)?.favicon !== undefined); + + setUrl(`${origin}/first#section`); + listeners.get("did-navigate-in-page")?.(); + yield* Effect.yieldNow; + expect(states.at(-1)?.favicon).toBeDefined(); + + setUrl(`${origin}/second`); + setLoading(true); + listeners.get("did-start-navigation")?.({ isMainFrame: true, isSameDocument: false }); + listeners.get("did-navigate")?.(); + yield* Effect.yieldNow; + expect(states.at(-1)?.navStatus.kind).toBe("Loading"); + expect(states.at(-1)?.favicon).toBeDefined(); + expect(states.at(-1)?.faviconOrigin).toBe(origin); + setLoading(false); + listeners.get("did-stop-loading")?.(); + + setUrl("https://example.com/"); + setLoading(true); + listeners.get("did-start-navigation")?.({ isMainFrame: true, isSameDocument: false }); + listeners.get("did-navigate")?.(); + yield* settle(() => states.at(-1)?.favicon === undefined); + expect(states.at(-1)?.faviconOrigin).toBeUndefined(); + setLoading(false); + listeners.get("did-stop-loading")?.(); + expect(fetch).toHaveBeenCalledOnce(); + }), + ), + ); + + effectIt.effect("does not let a stale favicon capture overwrite a newer one", () => + withManager((manager) => + Effect.gen(function* () { + const origin = "http://localhost:5749"; + const pending = new Map void>(); + const fetch = vi.fn( + (url: string, init?: { signal?: AbortSignal }) => + new Promise((resolve, reject) => { + pending.set(url, resolve); + init?.signal?.addEventListener( + "abort", + () => reject(new DOMException("Aborted", "AbortError")), + { once: true }, + ); + }), + ); + const { webContents, listeners } = makeFaviconWebContents({ + url: `${origin}/`, + title: "localhost:5749", + fetch, + }); + fromId.mockReturnValue(webContents); + const states: PreviewManager.PreviewTabState[] = []; + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + states.push(state); + }), + ); + yield* manager.createTab("tab_favicon_race"); + yield* manager.registerWebview("tab_favicon_race", 42); + + const firstUrl = `${origin}/first.png`; + const firstFallbackUrl = `${origin}/first-fallback.png`; + const secondUrl = `${origin}/second.png`; + const faviconUpdated = listeners.get("page-favicon-updated"); + faviconUpdated?.({}, [firstUrl, firstFallbackUrl]); + yield* settle(() => fetch.mock.calls.length === 1, 0); + const firstSignal = fetch.mock.calls[0]?.[1]?.signal; + faviconUpdated?.({}, [secondUrl]); + yield* settle(() => fetch.mock.calls.length === 2, 0); + expect(firstSignal?.aborted).toBe(true); + + const response = (bytes: Buffer) => ({ + ok: true, + arrayBuffer: async () => + bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength), + headers: { get: (name: string) => (name === "content-type" ? "image/png" : null) }, + }); + const newest = Buffer.from("newest-favicon-bytes"); + pending.get(secondUrl)?.(response(newest)); + yield* settle(() => states.at(-1)?.favicon !== undefined); + + expect(fetch).toHaveBeenCalledTimes(2); + expect(fetch).not.toHaveBeenCalledWith(firstFallbackUrl, expect.anything()); + expect(states.at(-1)?.favicon).toBe(`data:image/png;base64,${newest.toString("base64")}`); + }), + ), + ); + + effectIt.effect("does not try stale favicon fallbacks after a newer capture starts", () => + withManager((manager) => + Effect.gen(function* () { + const origin = "http://localhost:5760"; + const pending = new Map void>(); + const fetch = vi.fn( + (url: string, init?: { signal?: AbortSignal }) => + new Promise((resolve, reject) => { + pending.set(url, resolve); + init?.signal?.addEventListener( + "abort", + () => reject(new DOMException("Aborted", "AbortError")), + { once: true }, + ); + }), + ); + const { webContents, listeners } = makeFaviconWebContents({ + url: `${origin}/`, + title: "localhost:5760", + fetch, + }); + fromId.mockReturnValue(webContents); + const states: PreviewManager.PreviewTabState[] = []; + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + states.push(state); + }), + ); + yield* manager.createTab("tab_favicon_failed_race"); + yield* manager.registerWebview("tab_favicon_failed_race", 42); + + const staleUrl = `${origin}/stale.png`; + const staleFallbackUrl = `${origin}/stale-fallback.png`; + const newestUrl = `${origin}/newest.png`; + const faviconUpdated = listeners.get("page-favicon-updated"); + faviconUpdated?.({}, [staleUrl, staleFallbackUrl]); + yield* settle(() => fetch.mock.calls.length === 1, 0); + faviconUpdated?.({}, [newestUrl]); + yield* settle(() => fetch.mock.calls.length === 2, 0); + + expect(fetch).toHaveBeenCalledTimes(2); + expect(fetch).not.toHaveBeenCalledWith(staleFallbackUrl, expect.anything()); + + const newest = Buffer.from("newest-favicon-after-stale-failure"); + pending.get(newestUrl)?.({ + ok: true, + arrayBuffer: async () => + newest.buffer.slice(newest.byteOffset, newest.byteOffset + newest.byteLength), + headers: { get: (name: string) => (name === "content-type" ? "image/png" : null) }, + }); + yield* settle(() => states.at(-1)?.favicon !== undefined); + + expect(states.at(-1)?.favicon).toBe(`data:image/png;base64,${newest.toString("base64")}`); + }), + ), + ); + + effectIt.effect("shares an identical favicon event while its capture is in flight", () => + withManager((manager) => + Effect.gen(function* () { + const origin = "http://localhost:5762"; + const bytes = Buffer.from("deduplicated-favicon"); + let resolveFetch!: (response: unknown) => void; + const fetch = vi.fn( + () => + new Promise((resolve) => { + resolveFetch = resolve; + }), + ); + const { webContents, listeners } = makeFaviconWebContents({ + url: `${origin}/`, + title: "Duplicate favicon event", + fetch, + }); + fromId.mockReturnValue(webContents); + const states: PreviewManager.PreviewTabState[] = []; + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + states.push(state); + }), + ); + yield* manager.createTab("tab_favicon_duplicate"); + yield* manager.registerWebview("tab_favicon_duplicate", 42); + + const faviconUrl = `${origin}/favicon.png`; + const faviconUpdated = listeners.get("page-favicon-updated"); + faviconUpdated?.({}, [faviconUrl]); + faviconUpdated?.({}, [faviconUrl]); + yield* settle(() => fetch.mock.calls.length > 0, 0); + + expect(fetch).toHaveBeenCalledOnce(); + + resolveFetch({ + ok: true, + arrayBuffer: async () => + bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength), + headers: { get: (name: string) => (name === "content-type" ? "image/png" : null) }, + }); + yield* settle(() => states.at(-1)?.favicon !== undefined); + + expect(states.at(-1)?.favicon).toBe(`data:image/png;base64,${bytes.toString("base64")}`); + }), + ), + ); + + effectIt.effect("captures a changed favicon URL on the same origin", () => + withManager((manager) => + Effect.gen(function* () { + const origin = "http://localhost:5755"; + const fetch = vi.fn(async (faviconUrl: string) => { + const bytes = Buffer.from(faviconUrl); + return { + ok: true, + arrayBuffer: async () => + bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength), + headers: { get: (name: string) => (name === "content-type" ? "image/png" : null) }, + }; + }); + const { webContents, listeners } = makeFaviconWebContents({ + url: `${origin}/`, + title: "Dynamic favicon", + fetch, + }); + fromId.mockReturnValue(webContents); + const states: PreviewManager.PreviewTabState[] = []; + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + states.push(state); + }), + ); + yield* manager.createTab("tab_favicon_dynamic"); + yield* manager.registerWebview("tab_favicon_dynamic", 42); + + const faviconUpdated = listeners.get("page-favicon-updated"); + faviconUpdated?.({}, [`${origin}/first.png`]); + yield* settle(() => fetch.mock.calls.length === 1 && states.at(-1)?.favicon !== undefined); + faviconUpdated?.({}, [`${origin}/second.png`]); + yield* settle(() => fetch.mock.calls.length === 2); + + expect(fetch).toHaveBeenCalledTimes(2); + expect(states.at(-1)?.favicon).toBe( + `data:image/png;base64,${Buffer.from(`${origin}/second.png`).toString("base64")}`, + ); + }), + ), + ); + + effectIt.effect("keeps the page origin from the favicon event", () => + withManager((manager) => + Effect.gen(function* () { + const siteA = "http://localhost:5756"; + const siteB = "http://localhost:5757"; + const bytes = Buffer.from("site-a-favicon"); + const fetch = vi.fn(async () => ({ + ok: true, + arrayBuffer: async () => + bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength), + headers: { get: (name: string) => (name === "content-type" ? "image/png" : null) }, + })); + const { webContents, listeners, setUrl } = makeFaviconWebContents({ + url: `${siteA}/`, + title: "Site A", + fetch, + }); + fromId.mockReturnValue(webContents); + const states: PreviewManager.PreviewTabState[] = []; + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + states.push(state); + }), + ); + yield* manager.createTab("tab_favicon_event_origin"); + yield* manager.registerWebview("tab_favicon_event_origin", 42); + + listeners.get("page-favicon-updated")?.({}, [`${siteA}/favicon.png`]); + setUrl(`${siteB}/`); + yield* settle(() => fetch.mock.calls.length === 1); + + expect(states.at(-1)?.favicon).toBeUndefined(); + expect(states.at(-1)?.faviconOrigin).toBeUndefined(); + }), + ), + ); + + effectIt.effect("cancels a favicon response when its stream exceeds the byte limit", () => + withManager((manager) => + Effect.gen(function* () { + let cancelled = false; + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(60_000)); + controller.enqueue(new Uint8Array(60_000)); + }, + cancel() { + cancelled = true; + }, + }); + const fetch = vi.fn(async () => ({ + ok: true, + body, + headers: { get: (name: string) => (name === "content-type" ? "image/png" : null) }, + })); + const { webContents, listeners } = makeFaviconWebContents({ + url: "http://localhost:5758/", + title: "Oversized favicon", + fetch, + }); + fromId.mockReturnValue(webContents); + const states: PreviewManager.PreviewTabState[] = []; + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + states.push(state); + }), + ); + yield* manager.createTab("tab_favicon_oversized"); + yield* manager.registerWebview("tab_favicon_oversized", 42); + + listeners.get("page-favicon-updated")?.({}, ["http://localhost:5758/oversized.png"]); + yield* settle(() => cancelled); + + expect(cancelled).toBe(true); + expect(states.at(-1)?.favicon).toBeUndefined(); + }), + ), + ); + + effectIt.effect("recaptures a favicon after navigating to the current URL", () => + withManager((manager) => + Effect.gen(function* () { + const url = "http://localhost:5754/"; + const fetch = vi.fn(async () => { + const bytes = Buffer.from("same-origin-favicon-bytes"); + return { + ok: true, + arrayBuffer: async () => + bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength), + headers: { get: (name: string) => (name === "content-type" ? "image/png" : null) }, + }; + }); + const { webContents, listeners, reload } = makeFaviconWebContents({ + url, + title: "Same origin", + fetch, + }); + fromId.mockReturnValue(webContents); + const states: PreviewManager.PreviewTabState[] = []; + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + states.push(state); + }), + ); + yield* manager.createTab("tab_favicon_same_origin"); + yield* manager.registerWebview("tab_favicon_same_origin", 42); + + const faviconUpdated = listeners.get("page-favicon-updated"); + faviconUpdated?.({}, [`${url}favicon.png`]); + yield* settle(() => states.at(-1)?.faviconOrigin === new URL(url).origin); + + yield* manager.navigate("tab_favicon_same_origin", url); + listeners.get("did-navigate")?.(); + yield* settle(() => states.at(-1)?.navStatus.kind === "Success", 0); + faviconUpdated?.({}, [`${url}favicon.png`]); + yield* settle(() => fetch.mock.calls.length === 2 && states.at(-1)?.favicon !== undefined); + + expect(reload).toHaveBeenCalledOnce(); + expect(fetch).toHaveBeenCalledTimes(2); + expect(states.at(-1)?.faviconOrigin).toBe(new URL(url).origin); + }), + ), + ); + + effectIt.effect("publishes a favicon received before did-navigate completes", () => + withManager((manager) => + Effect.gen(function* () { + const origin = "http://localhost:5764"; + const faviconUrl = `${origin}/favicon.png`; + const fetch = vi.fn(async () => { + const bytes = Buffer.from("reloaded-favicon"); + return { + ok: true, + arrayBuffer: async () => + bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength), + headers: { get: (name: string) => (name === "content-type" ? "image/png" : null) }, + }; + }); + const { webContents, listeners, setLoading } = makeFaviconWebContents({ + url: `${origin}/`, + title: "Reload ordering", + fetch, + }); + fromId.mockReturnValue(webContents); + const states: PreviewManager.PreviewTabState[] = []; + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + states.push(state); + }), + ); + yield* manager.createTab("tab_favicon_reload_order"); + yield* manager.registerWebview("tab_favicon_reload_order", 42); + + setLoading(true); + listeners.get("did-start-navigation")?.({ isMainFrame: true, isSameDocument: false }); + listeners.get("did-start-loading")?.(); + listeners.get("page-favicon-updated")?.({}, [faviconUrl]); + yield* settle(() => fetch.mock.calls.length === 1); + + listeners.get("did-navigate")?.(); + yield* settle(() => states.at(-1)?.navStatus.kind === "Loading", 0); + expect(states.at(-1)?.favicon).toBeUndefined(); + + setLoading(false); + listeners.get("did-stop-loading")?.(); + yield* settle(() => states.at(-1)?.favicon !== undefined); + + expect(states.at(-1)?.favicon).toBe( + `data:image/png;base64,${Buffer.from("reloaded-favicon").toString("base64")}`, + ); + expect(states.at(-1)?.faviconOrigin).toBe(origin); + }), + ), + ); + + effectIt.effect("keeps the current favicon when a reload emits no favicon event", () => + withManager((manager) => + Effect.gen(function* () { + const origin = "http://localhost:5765"; + const favicon = Buffer.from("unchanged-reload-favicon"); + const fetch = vi.fn(async () => ({ + ok: true, + arrayBuffer: async () => + favicon.buffer.slice(favicon.byteOffset, favicon.byteOffset + favicon.byteLength), + headers: { get: (name: string) => (name === "content-type" ? "image/png" : null) }, + })); + const { webContents, listeners, setLoading } = makeFaviconWebContents({ + url: `${origin}/`, + title: "Reload without favicon event", + fetch, + }); + fromId.mockReturnValue(webContents); + const states: PreviewManager.PreviewTabState[] = []; + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + states.push(state); + }), + ); + yield* manager.createTab("tab_favicon_reload_without_event"); + yield* manager.registerWebview("tab_favicon_reload_without_event", 42); + + listeners.get("page-favicon-updated")?.({}, [`${origin}/favicon.png`]); + yield* settle(() => states.at(-1)?.favicon !== undefined); + + yield* manager.refresh("tab_favicon_reload_without_event"); + setLoading(true); + listeners.get("did-start-navigation")?.({ isMainFrame: true, isSameDocument: false }); + listeners.get("did-start-loading")?.(); + listeners.get("did-navigate")?.(); + expect(states.at(-1)?.navStatus.kind).toBe("Loading"); + setLoading(false); + listeners.get("did-stop-loading")?.(); + yield* settle(() => states.at(-1)?.navStatus.kind === "Success"); + + expect(fetch).toHaveBeenCalledOnce(); + expect(states.at(-1)?.favicon).toBe(`data:image/png;base64,${favicon.toString("base64")}`); + expect(states.at(-1)?.faviconOrigin).toBe(origin); + }), + ), + ); + + effectIt.effect("rejects favicon captures invalidated before or started during navigation", () => + withManager((manager) => + Effect.gen(function* () { + const origin = "http://localhost:5763"; + const oldUrl = `${origin}/old`; + const nextUrl = `${origin}/next`; + const bytes = Buffer.from("old-page-favicon"); + const oldFaviconUrl = `${origin}/old-favicon.png`; + const loadingFaviconUrl = `${origin}/loading-favicon.png`; + const pending = new Map void>(); + let resolveDuringLoading = false; + const fetch = vi.fn( + (url: string, init?: { signal?: AbortSignal }) => + new Promise((resolve, reject) => { + pending.set(url, resolve); + init?.signal?.addEventListener( + "abort", + () => reject(new DOMException("Aborted", "AbortError")), + { once: true }, + ); + }), + ); + const response = { + ok: true, + arrayBuffer: async () => + bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength), + headers: { get: (name: string) => (name === "content-type" ? "image/png" : null) }, + }; + const { webContents, listeners } = makeFaviconWebContents({ + url: oldUrl, + title: "Navigation race", + fetch, + }); + fromId.mockReturnValue(webContents); + const states: PreviewManager.PreviewTabState[] = []; + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.gen(function* () { + states.push(state); + if (!resolveDuringLoading || state.navStatus.kind !== "Loading") return; + resolveDuringLoading = false; + listeners.get("page-favicon-updated")?.({}, [loadingFaviconUrl]); + yield* settle(() => pending.has(loadingFaviconUrl), 0); + pending.get(oldFaviconUrl)?.(response); + pending.get(loadingFaviconUrl)?.(response); + }), + ); + yield* manager.createTab("tab_favicon_navigate_race"); + yield* manager.registerWebview("tab_favicon_navigate_race", 42); + + listeners.get("page-favicon-updated")?.({}, [oldFaviconUrl]); + yield* settle(() => fetch.mock.calls.length === 1, 0); + const oldSignal = fetch.mock.calls[0]?.[1]?.signal; + resolveDuringLoading = true; + yield* manager.navigate("tab_favicon_navigate_race", nextUrl); + yield* settle(() => false); + + expect(oldSignal?.aborted).toBe(true); + expect(states.at(-1)?.navStatus).toMatchObject({ kind: "Loading", url: nextUrl }); + expect(states.at(-1)?.favicon).toBeUndefined(); + expect(states.at(-1)?.faviconOrigin).toBeUndefined(); + expect(fetch).toHaveBeenCalledTimes(2); + }), + ), + ); + + effectIt.effect("recaptures a changed favicon after every refresh path", () => + withManager((manager) => + Effect.gen(function* () { + const origin = "http://localhost:5761"; + const faviconUrl = `${origin}/favicon.png`; + const labels = [ + "initial-favicon", + "refreshed-favicon", + "hard-reloaded-favicon", + "shortcut-refreshed-favicon", + ]; + const fetch = vi.fn(async () => { + const bytes = Buffer.from(labels[fetch.mock.calls.length - 1] ?? "unexpected"); + return { + ok: true, + arrayBuffer: async () => + bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength), + headers: { get: (name: string) => (name === "content-type" ? "image/png" : null) }, + }; + }); + const { webContents, listeners, reload, reloadIgnoringCache } = makeFaviconWebContents({ + url: `${origin}/`, + title: "Reload favicon", + fetch, + }); + fromId.mockReturnValue(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); + const faviconUpdated = listeners.get("page-favicon-updated"); + + faviconUpdated?.({}, [faviconUrl]); + yield* settle(() => fetch.mock.calls.length === 1 && states.at(-1)?.favicon !== undefined); + yield* manager.refresh("tab_favicon_reload"); + faviconUpdated?.({}, [faviconUrl]); + yield* settle(() => fetch.mock.calls.length === 2); + yield* manager.hardReload("tab_favicon_reload"); + faviconUpdated?.({}, [faviconUrl]); + yield* settle(() => fetch.mock.calls.length === 3); + const preventDefault = vi.fn(); + listeners.get("before-input-event")?.( + { preventDefault }, + { + type: "keyDown", + key: "r", + meta: true, + control: false, + shift: false, + alt: false, + }, + ); + yield* settle(() => reload.mock.calls.length === 2); + faviconUpdated?.({}, [faviconUrl]); + yield* settle(() => fetch.mock.calls.length === 4); + + expect(preventDefault).toHaveBeenCalledOnce(); + expect(reload).toHaveBeenCalledTimes(2); + expect(reloadIgnoringCache).toHaveBeenCalledOnce(); + expect(states.at(-1)?.favicon).toBe( + `data:image/png;base64,${Buffer.from("shortcut-refreshed-favicon").toString("base64")}`, + ); + }), + ), + ); + + effectIt.effect("recaptures a favicon after an A to B to A revisit", () => + withManager((manager) => + Effect.gen(function* () { + const siteA = "http://localhost:5750"; + const siteB = "http://localhost:5751"; + const fetch = vi.fn(async (faviconUrl: string) => { + const bytes = Buffer.from(`favicon-bytes-for-${faviconUrl}`); + return { + ok: true, + arrayBuffer: async () => + bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength), + headers: { get: (name: string) => (name === "content-type" ? "image/png" : null) }, + }; + }); + const { webContents, listeners, setUrl } = makeFaviconWebContents({ + url: `${siteA}/`, + title: "Site A", + fetch, + }); + fromId.mockReturnValue(webContents); + const states: PreviewManager.PreviewTabState[] = []; + + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + states.push(state); + }), + ); + yield* manager.createTab("tab_favicon_revisit"); + yield* manager.registerWebview("tab_favicon_revisit", 42); + + const faviconUpdated = listeners.get("page-favicon-updated"); + faviconUpdated?.({}, [`${siteA}/favicon.png`]); + yield* settle(() => states.at(-1)?.faviconOrigin === siteA); + + setUrl(`${siteB}/`); + listeners.get("did-navigate")?.(); + yield* settle(() => { + const navStatus = states.at(-1)?.navStatus; + return navStatus?.kind === "Success" && navStatus.url === `${siteB}/`; + }, 0); + faviconUpdated?.({}, [`${siteB}/favicon.png`]); + yield* settle(() => states.at(-1)?.faviconOrigin === siteB); + + setUrl(`${siteA}/`); + listeners.get("did-navigate")?.(); + yield* settle(() => { + const navStatus = states.at(-1)?.navStatus; + return navStatus?.kind === "Success" && navStatus.url === `${siteA}/`; + }, 0); + faviconUpdated?.({}, [`${siteA}/favicon.png`]); + yield* settle( + () => fetch.mock.calls.length === 3 && states.at(-1)?.faviconOrigin === siteA, + ); + + faviconUpdated?.({}, [`${siteA}/favicon.png`]); + yield* settle(() => fetch.mock.calls.length > 3); + + expect(fetch).toHaveBeenCalledTimes(3); + expect(states.at(-1)?.faviconOrigin).toBe(siteA); + }), + ), + ); + + effectIt.effect("ignores a favicon captured after its webview is replaced", () => + withManager((manager) => + Effect.gen(function* () { + const initial = Buffer.from("initial-favicon-bytes"); + const delayed = Buffer.from("delayed-favicon-bytes"); + let resolveFetch!: (response: unknown) => void; + const response = (bytes: Buffer) => ({ + ok: true, + arrayBuffer: async () => + bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength), + headers: { get: (name: string) => (name === "content-type" ? "image/png" : null) }, + }); + const fetch = vi.fn((_url: string, _init?: { signal?: AbortSignal }) => { + if (fetch.mock.calls.length === 1) return Promise.resolve(response(initial)); + return new Promise((resolve) => { + resolveFetch = resolve; + }); + }); + const oldWebview = makeFaviconWebContents({ + id: 42, + url: "http://localhost:5752/", + title: "Old", + fetch, + }); + const replacement = makeFaviconWebContents({ + id: 43, + url: "http://localhost:5753/", + title: "Replacement", + fetch: vi.fn(), + }); + fromId.mockImplementation((id) => + id === 42 ? oldWebview.webContents : replacement.webContents, + ); + const states: PreviewManager.PreviewTabState[] = []; + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + states.push(state); + }), + ); + yield* manager.createTab("tab_favicon_swap"); + yield* manager.registerWebview("tab_favicon_swap", 42); + oldWebview.listeners.get("page-favicon-updated")?.(null, [ + "http://localhost:5752/favicon.png", + ]); + yield* settle(() => states.at(-1)?.favicon !== undefined); + oldWebview.listeners.get("page-favicon-updated")?.(null, [ + "http://localhost:5752/favicon-next.png", + ]); + yield* settle(() => fetch.mock.calls.length === 2, 0); + const detachedSignal = fetch.mock.calls[1]?.[1]?.signal; + + yield* manager.registerWebview("tab_favicon_swap", 43); + expect(detachedSignal?.aborted).toBe(true); + oldWebview.setUrl("http://localhost:5752/late-navigation"); + oldWebview.listeners.get("did-navigate")?.(); + oldWebview.listeners.get("did-fail-load")?.( + {}, + -105, + "ERR_NAME_NOT_RESOLVED", + "http://localhost:5752/late-navigation", + true, + ); + yield* settle(() => false); + expect(states.at(-1)?.webContentsId).toBe(43); + expect(states.at(-1)?.navStatus.kind).toBe("Success"); + expect(states.at(-1)?.favicon).toBeUndefined(); + expect(states.at(-1)?.faviconOrigin).toBeUndefined(); + oldWebview.listeners.get("before-input-event")?.( + { preventDefault: vi.fn() }, + { + type: "keyDown", + key: "r", + meta: true, + control: false, + shift: false, + alt: false, + }, + ); + yield* settle(() => false); + expect(oldWebview.reload).not.toHaveBeenCalled(); + expect(replacement.reload).not.toHaveBeenCalled(); + resolveFetch(response(delayed)); + yield* settle(() => false); + + expect(states.at(-1)?.webContentsId).toBe(43); + expect(states.at(-1)?.favicon).toBeUndefined(); }), ), ); - effectIt.effect("emulates prefers-color-scheme and re-applies it across webview swaps", () => + effectIt.effect("recaptures when a detached webContents id is reused", () => withManager((manager) => Effect.gen(function* () { - const makeWebContents = (id: number) => { - const sendCommand = vi.fn(async () => undefined); + const origin = "http://localhost:5759"; + const faviconUrl = `${origin}/favicon.png`; + const response = (label: string) => { + const bytes = Buffer.from(label); return { - sendCommand, - wc: { - id, - isDestroyed: () => false, - isDevToolsOpened: () => false, - getType: () => "webview", - getURL: () => "https://example.com", - getTitle: () => "Example", - isLoading: () => false, - getZoomFactor: () => 1, - setZoomFactor: vi.fn(), - on: vi.fn(), - off: vi.fn(), - ipc: { on: vi.fn(), off: vi.fn() }, - send: webviewSend, - navigationHistory: { canGoBack: () => false, canGoForward: () => false }, - setWindowOpenHandler: vi.fn(), - debugger: { - isAttached: () => false, - attach: vi.fn(), - sendCommand, - on: vi.fn(), - off: vi.fn(), - }, - } as never, + ok: true, + arrayBuffer: async () => + bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength), + headers: { get: (name: string) => (name === "content-type" ? "image/png" : null) }, }; }; - const first = makeWebContents(42); - fromId.mockReturnValue(first.wc); + const firstFetch = vi.fn(async () => response("first-webview-favicon")); + const reusedFetch = vi.fn(async () => response("reused-webview-favicon")); + const first = makeFaviconWebContents({ + id: 42, + url: `${origin}/`, + title: "First", + fetch: firstFetch, + }); + const replacement = makeFaviconWebContents({ + id: 43, + url: "http://localhost:5760/", + title: "Replacement", + fetch: vi.fn(), + }); + const reused = makeFaviconWebContents({ + id: 42, + url: `${origin}/`, + title: "Reused", + fetch: reusedFetch, + }); + let webContents42 = first.webContents; + fromId.mockImplementation((id) => + id === 42 ? webContents42 : id === 43 ? replacement.webContents : null, + ); const states: PreviewManager.PreviewTabState[] = []; - yield* manager.subscribeStateChanges((_tabId, state) => Effect.sync(() => { states.push(state); }), ); - yield* manager.createTab("tab_scheme"); - yield* manager.registerWebview("tab_scheme", 42); - yield* Effect.yieldNow; - - yield* manager.setColorScheme("tab_scheme", "dark"); - - expect(first.sendCommand).toHaveBeenCalledWith("Emulation.setEmulatedMedia", { - features: [{ name: "prefers-color-scheme", value: "dark" }], - }); - expect(states.at(-1)?.colorScheme).toBe("dark"); - - const replacement = makeWebContents(43); - fromId.mockReturnValue(replacement.wc); - yield* manager.registerWebview("tab_scheme", 43); - yield* Effect.yieldNow; - - expect(replacement.sendCommand).toHaveBeenCalledWith("Emulation.setEmulatedMedia", { - features: [{ name: "prefers-color-scheme", value: "dark" }], - }); - expect(states.at(-1)?.colorScheme).toBe("dark"); + yield* manager.createTab("tab_favicon_reused_id"); + yield* manager.registerWebview("tab_favicon_reused_id", 42); + first.listeners.get("page-favicon-updated")?.({}, [faviconUrl]); + yield* settle( + () => firstFetch.mock.calls.length === 1 && states.at(-1)?.favicon !== undefined, + ); - yield* manager.setColorScheme("tab_scheme", "system"); + yield* manager.registerWebview("tab_favicon_reused_id", 43); + webContents42 = reused.webContents; + yield* manager.registerWebview("tab_favicon_reused_id", 42); + reused.listeners.get("page-favicon-updated")?.({}, [faviconUrl]); + yield* settle(() => reusedFetch.mock.calls.length === 1); - expect(replacement.sendCommand).toHaveBeenCalledWith("Emulation.setEmulatedMedia", { - features: [{ name: "prefers-color-scheme", value: "" }], - }); - expect(states.at(-1)?.colorScheme).toBe("system"); + expect(reusedFetch).toHaveBeenCalledOnce(); + expect(states.at(-1)?.favicon).toBe( + `data:image/png;base64,${Buffer.from("reused-webview-favicon").toString("base64")}`, + ); }), ), ); - effectIt.effect("blocks late webview and capture starts during tab close", () => + effectIt.effect("does not publish or dedupe an undecodable favicon buffer", () => withManager((manager) => Effect.gen(function* () { - const capturePage = vi.fn(async () => ({ - toJPEG: () => Buffer.from("close-race-frame"), - getSize: () => ({ width: 1280, height: 720 }), - })); - const firstWebContents = makeTestPreviewWebContents(capturePage, 42); - const replacementWebContents = makeTestPreviewWebContents(capturePage, 43); - const replacementListenerSpies = replacementWebContents as unknown as { - readonly on: ReturnType; - readonly off: ReturnType; - readonly ipc: { readonly off: ReturnType }; - }; - fromId.mockImplementation((id) => { - if (id === 42) return firstWebContents; - if (id === 43) return replacementWebContents; - return null; + createFromBuffer.mockReturnValueOnce({ + getSize: () => ({ width: 0, height: 0 }), + isEmpty: () => true, + toDataURL: () => "data:image/png;base64,", + resize: () => ({ toDataURL: () => "data:image/png;base64," }), }); - const { pictureInPictureWindow } = makeTestPictureInPictureWindow(); - browserWindowConstructor.mockImplementation(function () { - return pictureInPictureWindow; + const url = "http://localhost:5736/"; + const corrupt = Buffer.from("corrupt-image-data"); + const fetch = vi.fn(async () => ({ + ok: true, + arrayBuffer: async () => + corrupt.buffer.slice(corrupt.byteOffset, corrupt.byteOffset + corrupt.byteLength), + headers: { get: (name: string) => (name === "content-type" ? "image/png" : null) }, + })); + const { webContents, listeners } = makeFaviconWebContents({ + url, + title: "localhost:5736", + fetch, }); + fromId.mockReturnValue(webContents); + const states: PreviewManager.PreviewTabState[] = []; - yield* manager.createTab("tab_close_register_race"); - yield* manager.registerWebview("tab_close_register_race", 42); - yield* manager.openPictureInPicture("tab_close_register_race"); - - const closeCleanupPaused = yield* Deferred.make(); - const continueCloseCleanup = yield* Deferred.make(); yield* manager.subscribeStateChanges((_tabId, state) => - !state.pictureInPicture && state.webContentsId === 42 - ? Deferred.succeed(closeCleanupPaused, undefined).pipe( - Effect.andThen(Deferred.await(continueCloseCleanup)), - ) - : Effect.void, + Effect.sync(() => { + states.push(state); + }), ); + yield* manager.createTab("tab_favicon_undecodable"); + yield* manager.registerWebview("tab_favicon_undecodable", 42); - const closeFiber = yield* manager - .closeTab("tab_close_register_race") - .pipe(Effect.forkChild({ startImmediately: true })); - yield* Deferred.await(closeCleanupPaused); - const recreateFiber = yield* manager - .createTab("tab_close_register_race") - .pipe(Effect.forkChild({ startImmediately: true })); - const registrationFiber = yield* manager - .registerWebview("tab_close_register_race", 43) - .pipe(Effect.forkChild({ startImmediately: true })); - yield* Effect.yieldNow; - expect(replacementListenerSpies.on).not.toHaveBeenCalled(); - yield* manager.closeTab("tab_close_register_race"); - const recordingExit = yield* Effect.exit(manager.startRecording("tab_close_register_race")); - yield* Deferred.succeed(continueCloseCleanup, undefined); - yield* Fiber.join(closeFiber); - const recreated = yield* Fiber.join(recreateFiber); - const registrationExit = yield* Fiber.await(registrationFiber); + listeners.get("page-favicon-updated")?.({}, ["http://localhost:5736/favicon.png"]); - for (const exit of [registrationExit, recordingExit]) { - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isSuccess(exit)) continue; - expect(Option.getOrThrow(Cause.findErrorOption(exit.cause))).toMatchObject({ - _tag: "PreviewTabNotFoundError", - tabId: "tab_close_register_race", - }); - } - expect(replacementListenerSpies.on).not.toHaveBeenCalled(); - expect(replacementListenerSpies.off).not.toHaveBeenCalled(); - expect(replacementListenerSpies.ipc.off).not.toHaveBeenCalled(); - expect(capturePage).toHaveBeenCalledOnce(); - expect(recreated.webContentsId).toBeNull(); + yield* settle(() => fetch.mock.calls.length > 0); + + expect(fetch).toHaveBeenCalledOnce(); + expect(states.at(-1)?.favicon).toBeUndefined(); + + createFromBuffer.mockReturnValueOnce({ + getSize: () => ({ width: 16, height: 16 }), + isEmpty: () => false, + toDataURL: () => "data:image/png;base64,VALID", + resize: () => ({ toDataURL: () => "data:image/png;base64,VALID" }), + }); + const validBuffer = Buffer.from("valid-image-data--"); + fetch.mockImplementation(async () => ({ + ok: true, + arrayBuffer: async () => + validBuffer.buffer.slice( + validBuffer.byteOffset, + validBuffer.byteOffset + validBuffer.byteLength, + ), + headers: { get: (name: string) => (name === "content-type" ? "image/png" : null) }, + })); + listeners.get("page-favicon-updated")?.({}, ["http://localhost:5736/favicon.png"]); + + yield* settle(() => states.at(-1)?.favicon !== undefined); + + expect(fetch).toHaveBeenCalledTimes(2); + expect(states.at(-1)?.favicon).toBe("data:image/png;base64,VALID"); }), ), ); - effectIt.effect("keeps a main-frame load failure visible until a retry starts", () => + effectIt.effect("validates opaque favicon responses before publishing them", () => withManager((manager) => Effect.gen(function* () { - const url = "http://localhost:5733/"; - let loading = false; - const listeners = new Map void>(); - fromId.mockReturnValue({ - id: 42, - isDestroyed: () => false, - getType: () => "webview", - getURL: () => url, - getTitle: () => "localhost:5733", - isLoading: () => loading, - getZoomFactor: () => 1, - setZoomFactor: vi.fn(), - on: vi.fn((event: string, listener: (...args: unknown[]) => void) => { - listeners.set(event, listener); - }), - off: vi.fn(), - ipc: { on: vi.fn(), off: vi.fn() }, - send: webviewSend, - navigationHistory: { canGoBack: () => false, canGoForward: () => false }, - setWindowOpenHandler: vi.fn(), - debugger: { - isAttached: () => false, - attach: vi.fn(), - sendCommand: vi.fn(async () => undefined), - on: vi.fn(), - off: vi.fn(), + createFromBuffer + .mockReturnValueOnce({ + getSize: () => ({ width: 0, height: 0 }), + isEmpty: () => true, + toDataURL: () => "data:image/png;base64,", + resize: () => ({ toDataURL: () => "data:image/png;base64," }), + }) + .mockReturnValueOnce({ + getSize: () => ({ width: 0, height: 0 }), + isEmpty: () => true, + toDataURL: () => "data:image/png;base64,", + resize: () => ({ toDataURL: () => "data:image/png;base64," }), + }) + .mockReturnValueOnce({ + getSize: () => ({ width: 0, height: 0 }), + isEmpty: () => true, + toDataURL: () => "data:image/png;base64,", + resize: () => ({ toDataURL: () => "data:image/png;base64," }), + }) + .mockReturnValueOnce({ + getSize: () => ({ width: 64, height: 64 }), + isEmpty: () => false, + toDataURL: () => `data:image/png;base64,${"A".repeat(8_192)}`, + resize: () => ({ toDataURL: () => "data:image/png;base64,RESIZED" }), + }); + const origin = "http://localhost:5761"; + const svgUrl = `${origin}/broken.svg`; + const icoUrl = `${origin}/broken.ico`; + const validIcoUrl = `${origin}/valid.ico`; + const validSvgUrl = `${origin}/valid.svg`; + const undecodableSvgUrl = `${origin}/undecodable.svg`; + const oversizedSvgUrl = `${origin}/oversized.svg`; + const mislabeledIcoUrl = `${origin}/mislabeled.ico`; + const icoPayload = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", + "base64", + ); + const validIco = Buffer.alloc(22 + icoPayload.byteLength); + validIco.writeUInt16LE(1, 2); + validIco.writeUInt16LE(1, 4); + validIco[6] = 1; + validIco[7] = 1; + validIco.writeUInt16LE(1, 10); + validIco.writeUInt16LE(32, 12); + validIco.writeUInt32LE(icoPayload.byteLength, 14); + validIco.writeUInt32LE(22, 18); + icoPayload.copy(validIco, 22); + const validSvg = Buffer.from( + '', + ); + const undecodableSvg = Buffer.from('${"x".repeat(7_000)}`, + ); + const fetch = vi.fn(async (url: string) => { + const [mime, bytes] = + url === svgUrl + ? (["image/svg+xml", Buffer.from(" + bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength), + headers: { get: (name: string) => (name === "content-type" ? mime : null) }, + }; + }); + const { webContents, listeners, executeJavaScriptInIsolatedWorld } = makeFaviconWebContents( + { + url: `${origin}/`, + title: "localhost:5761", + fetch, + rasterizedFavicon: (code) => + [validIco, validSvg, oversizedSvg].some((bytes) => + code.includes(bytes.toString("base64")), + ) + ? "data:image/png;base64,RASTERIZED" + : null, }, - } as never); - const statuses: PreviewManager.PreviewNavStatus[] = []; - + ); + fromId.mockReturnValue(webContents); + const states: PreviewManager.PreviewTabState[] = []; yield* manager.subscribeStateChanges((_tabId, state) => Effect.sync(() => { - statuses.push(state.navStatus); + states.push(state); }), ); - yield* manager.createTab("tab_failed"); - yield* manager.registerWebview("tab_failed", 42); + yield* manager.createTab("tab_favicon_malformed_opaque"); + yield* manager.registerWebview("tab_favicon_malformed_opaque", 42); - listeners.get("did-fail-load")?.( - {}, - -105, - "ERR_NAME_NOT_RESOLVED", - "https://missing-frame.example/", - false, - ); - yield* Effect.yieldNow; - expect(statuses.at(-1)?.kind).toBe("Success"); + const faviconUpdated = listeners.get("page-favicon-updated"); + faviconUpdated?.({}, [undecodableSvgUrl]); + yield* settle(() => executeJavaScriptInIsolatedWorld.mock.calls.length === 1); + expect(states.at(-1)?.favicon).toBeUndefined(); - loading = true; - listeners.get("did-start-loading")?.(); - yield* Effect.yieldNow; - expect(statuses.at(-1)?.kind).toBe("Loading"); + faviconUpdated?.({}, [svgUrl, icoUrl, validIcoUrl]); + yield* settle(() => states.at(-1)?.favicon !== undefined); - loading = false; - listeners.get("did-fail-load")?.({}, -102, "ERR_CONNECTION_REFUSED", url, true); - listeners.get("did-stop-loading")?.(); - listeners.get("page-title-updated")?.(); - yield* Effect.yieldNow; - expect(statuses.at(-1)).toEqual({ - kind: "LoadFailed", - url, - title: "localhost:5733", - code: -102, - description: "ERR_CONNECTION_REFUSED", - }); + expect(fetch).toHaveBeenCalledTimes(4); + expect(states.at(-1)?.favicon).toBe("data:image/png;base64,RASTERIZED"); + expect(executeJavaScriptInIsolatedWorld).toHaveBeenNthCalledWith(4, 1001, [ + { code: expect.stringContaining(validIco.toString("base64")) }, + ]); - loading = true; - listeners.get("did-start-loading")?.(); - yield* Effect.yieldNow; - expect(statuses.at(-1)?.kind).toBe("Loading"); + faviconUpdated?.({}, [validSvgUrl]); + yield* settle(() => fetch.mock.calls.length === 5); - loading = false; - listeners.get("did-stop-loading")?.(); - yield* Effect.yieldNow; - expect(statuses.at(-1)?.kind).toBe("Success"); + expect(states.at(-1)?.favicon).toBe("data:image/png;base64,RASTERIZED"); + expect(executeJavaScriptInIsolatedWorld).toHaveBeenNthCalledWith(5, 1001, [ + { code: expect.stringContaining(validSvg.toString("base64")) }, + ]); - listeners.get("did-fail-load")?.({}, -102, "ERR_CONNECTION_REFUSED", url, true); - yield* Effect.yieldNow; - expect(statuses.at(-1)?.kind).toBe("LoadFailed"); + faviconUpdated?.({}, [mislabeledIcoUrl]); + yield* settle(() => fetch.mock.calls.length === 6); - listeners.get("did-navigate")?.(); - yield* Effect.yieldNow; - expect(statuses.at(-1)?.kind).toBe("Success"); + expect(states.at(-1)?.favicon).toBe("data:image/png;base64,RESIZED"); + + faviconUpdated?.({}, [oversizedSvgUrl]); + yield* settle(() => fetch.mock.calls.length === 7); + + expect(fetch).toHaveBeenCalledTimes(7); + expect(states.at(-1)?.favicon).toBe("data:image/png;base64,RASTERIZED"); + expect(executeJavaScriptInIsolatedWorld).toHaveBeenNthCalledWith(7, 1001, [ + { code: expect.stringContaining(oversizedSvg.toString("base64")) }, + ]); }), ), ); @@ -1450,7 +3161,7 @@ describe("PreviewManager", () => { const open = yield* manager .openPictureInPicture("tab_replaced_webview") .pipe(Effect.forkChild({ startImmediately: true })); - yield* Effect.yieldNow; + yield* settle(() => pictureInPictureWindow.loadURL.mock.calls.length === 1, 0); expect(pictureInPictureWindow.loadURL).toHaveBeenCalledOnce(); expect(resolveLoad).toBeDefined(); const concurrentOpen = yield* manager diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 169fe2992dc..5d303ec3cde 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -5,6 +5,8 @@ * elements live in the renderer; we only attach listeners and forward state * here). Single layer-scoped browser session partition. */ +import * as NodeCrypto from "node:crypto"; + import type { DesktopPreviewAnnotationTheme, DesktopPreviewColorScheme, @@ -27,6 +29,7 @@ import type { PreviewAutomationTypeInput, PreviewAutomationWaitForInput, } from "@t3tools/contracts"; +import { FAVICON_DATA_URL_MAX_LENGTH } from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { normalizePreviewUrl } from "@t3tools/shared/preview"; import { BrowserWindow, type Session, clipboard, nativeImage, shell, webContents } from "electron"; @@ -42,6 +45,7 @@ import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; +import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; @@ -85,6 +89,8 @@ export interface PreviewTabState { pictureInPicture: boolean; colorScheme: DesktopPreviewColorScheme; controller: "human" | "agent" | "none"; + favicon?: string; + faviconOrigin?: string; updatedAt: string; } @@ -347,6 +353,7 @@ type PreviewInputSignal = interface ManagedListeners { readonly scope: Scope.Closeable; + readonly invalidateFavicon: () => void; } type FrameCaptureConsumer = "picture-in-picture" | "recording"; @@ -452,6 +459,252 @@ const inputSignalsMatch = (left: PreviewInputSignal, right: PreviewInputSignal): ); }; +const MAX_FAVICON_RESPONSE_BYTES = 100_000; +const MAX_FAVICON_CANDIDATES = 8; +const MAX_FAVICON_HTTP_URL_LENGTH = 2_048; +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 activeFaviconRasterizations = new WeakMap>(); + +type FaviconCaptureRequest = { + readonly abortController: AbortController; + readonly candidates: ReadonlyArray; + readonly eventKey: string; + readonly generation: number; + readonly origin: string; +}; + +type PendingFaviconPublication = { + readonly dataUrl: string; + readonly faviconKey: string; + readonly generation: number; + readonly origin: string; +}; + +type FaviconPublicationResult = + | { readonly kind: "deferred" } + | { readonly kind: "published"; readonly state: PreviewTabState } + | { readonly kind: "stale" }; + +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: Array = []; + 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 safeOrigin(url: string): string | null { + try { + const parsed = new URL(url); + return parsed.protocol === "http:" || parsed.protocol === "https:" ? parsed.origin : null; + } catch { + return null; + } +} + +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; + if (protocol === "http:" || protocol === "https:") { + return url.length <= MAX_FAVICON_HTTP_URL_LENGTH; + } + return false; + } 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; + } + if (buffer.byteLength === 0 || buffer.byteLength > MAX_FAVICON_RESPONSE_BYTES) { + return null; + } + return { buffer, mime }; +} + +function faviconCaptureKey(parts: ReadonlyArray): string { + return NodeCrypto.createHash("sha256").update(JSON.stringify(parts)).digest("base64url"); +} + +function rasterizeOpaqueFavicon( + wc: Electron.WebContents, + mime: "image/svg+xml" | "image/x-icon" | "image/vnd.microsoft.icon", + buffer: Buffer, +): Promise { + const payload = buffer.toString("base64"); + const code = ` + (() => { + const rasterize = async () => { + try { + const source = Uint8Array.from(atob("${payload}"), (char) => char.charCodeAt(0)); + const bitmap = await createImageBitmap(new Blob([source], { type: "${mime}" })); + try { + const scale = Math.min(1, 32 / Math.max(bitmap.width, bitmap.height)); + const width = Math.max(1, Math.round(bitmap.width * scale)); + const height = Math.max(1, Math.round(bitmap.height * scale)); + const canvas = new OffscreenCanvas(width, height); + const context = canvas.getContext("2d"); + if (!context) return null; + context.drawImage(bitmap, 0, 0, width, height); + const output = new Uint8Array(await (await canvas.convertToBlob({ type: "image/png" })).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(); + })() + `; + if (activeFaviconRasterizations.has(wc)) return Promise.resolve(null); + const execution = wc.executeJavaScriptInIsolatedWorld(FAVICON_RASTER_WORLD_ID, [{ code }]); + activeFaviconRasterizations.set(wc, execution); + void execution + .finally(() => { + if (activeFaviconRasterizations.get(wc) === execution) { + activeFaviconRasterizations.delete(wc); + } + }) + .catch(() => undefined); + return new Promise((resolve, reject) => { + const timeout = AbortSignal.timeout(FAVICON_RASTER_TIMEOUT_MS); + const onTimeout = () => resolve(null); + timeout.addEventListener("abort", onTimeout, { once: true }); + void execution.then( + (result) => { + timeout.removeEventListener("abort", onTimeout); + resolve(result); + }, + (cause: unknown) => { + timeout.removeEventListener("abort", onTimeout); + reject(cause); + }, + ); + }); +} + +async function normalizeFaviconBuffer( + wc: Electron.WebContents, + mime: string | null, + buffer: Buffer, +): Promise { + 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 + ) { + return null; + } + const opaqueMime = + normalizedMime === "image/svg+xml" || + normalizedMime === "image/x-icon" || + normalizedMime === "image/vnd.microsoft.icon" + ? normalizedMime + : null; + if (opaqueMime !== null) { + const rasterized = await rasterizeOpaqueFavicon(wc, opaqueMime, buffer); + if ( + typeof rasterized === "string" && + rasterized.startsWith("data:image/png;base64,") && + rasterized.length <= FAVICON_DATA_URL_MAX_LENGTH + ) { + return rasterized; + } + } + const decoded = nativeImage.createFromBuffer(buffer); + if (decoded.isEmpty()) return null; + const { width, height } = decoded.getSize(); + if (width < 1 || height < 1) return null; + const normalized = + Math.max(width, height) <= 32 + ? decoded.toDataURL() + : decoded.resize(width >= height ? { width: 32 } : { height: 32 }).toDataURL(); + return normalized.length <= FAVICON_DATA_URL_MAX_LENGTH ? normalized : null; +} + const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function* ( artifactDirectory: string, pictureInPicturePreloadPath: string, @@ -489,6 +742,11 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ReadonlyMap> >(new Map()); const actionSequenceRef = yield* Ref.make(0); + const invalidateFaviconCapture = Effect.fn("PreviewManager.invalidateFaviconCapture")(function* ( + webContentsId: number, + ) { + (yield* Ref.get(attachedRef)).get(webContentsId)?.invalidateFavicon(); + }); const pointerSequenceRef = yield* Ref.make(0); const frameCaptureSessionsRef = yield* SynchronizedRef.make< ReadonlyMap @@ -504,6 +762,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function { readonly semaphore: Semaphore.Semaphore; users: number } >(); const tabLifecycleGenerations = new Map(); + const navigationLaunchTokens = new Map(); const attempt = (errorContext: PreviewOperationContext, evaluate: () => A) => Effect.try({ @@ -613,14 +872,30 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); }); - const update = Effect.fn("PreviewManager.update")(function* ( + const emitIfCurrent = Effect.fn("PreviewManager.emitIfCurrent")(function* ( + tabId: string, + state: PreviewTabState, + ) { + const tabs = yield* SynchronizedRef.get(tabsRef); + if (tabs.get(tabId) === state) yield* emit(tabId, state); + }); + + const modifyTab = Effect.fn("PreviewManager.modifyTab")(function* ( tabId: string, patch: Partial, + expectedWebContentsId?: number, + guard?: (current: PreviewTabState) => boolean, ) { 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 || + (expectedWebContentsId != null && current.webContentsId !== expectedWebContentsId) || + (guard !== undefined && !guard(current)) + ) { + return [Option.none(), tabs] as const; + } const state: PreviewTabState = { ...current, ...patch, updatedAt }; return [ Option.some(state), @@ -629,7 +904,17 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }), ] as const; }); + return next; + }); + + const update = Effect.fn("PreviewManager.update")(function* ( + tabId: string, + patch: Partial, + expectedWebContentsId?: number, + ) { + const next = yield* modifyTab(tabId, patch, expectedWebContentsId); if (Option.isSome(next)) yield* emit(tabId, next.value); + return Option.isSome(next); }); const requireWebContents = Effect.fn("PreviewManager.requireWebContents")(function* ( @@ -1204,7 +1489,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.invalidateFavicon(); + yield* Scope.close(managed.scope, Exit.void).pipe(Effect.ignore); + } }); const isAppShortcut = (input: Electron.Input): boolean => @@ -1268,8 +1556,28 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function wc: Electron.WebContents, ) { const scope = yield* Scope.fork(parentScope, "sequential"); + const faviconMutationSemaphore = yield* Semaphore.make(1); + const faviconRequests = yield* Queue.sliding(1); + let faviconGeneration = 0; + let activeFaviconEventKey: string | null = null; + let activeFaviconAbortController: AbortController | null = null; + let capturedFaviconKey: string | null = null; + let pendingFaviconPublication: PendingFaviconPublication | null = null; + let queuedFaviconAbortController: AbortController | null = null; + const invalidateFavicon = () => { + faviconGeneration += 1; + activeFaviconAbortController?.abort(); + queuedFaviconAbortController?.abort(); + activeFaviconAbortController = null; + activeFaviconEventKey = null; + capturedFaviconKey = null; + pendingFaviconPublication = null; + queuedFaviconAbortController = null; + }; const syncState = Effect.fn("PreviewManager.syncWebContentsState")(function* ( preserveLoadFailure: boolean, + resetFavicon = false, + navStatusOverride?: PreviewNavStatus, ) { if (wc.isDestroyed()) return; const zoomFactor = yield* attempt( @@ -1282,18 +1590,31 @@ 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) { + 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. const navStatus = - preserveLoadFailure && + navStatusOverride ?? + (preserveLoadFailure && current.navStatus.kind === "LoadFailed" && computedNavStatus.kind === "Success" ? current.navStatus - : computedNavStatus; + : computedNavStatus); + const preserveFavicon = + resetFavicon && + navStatus.kind !== "Idle" && + navStatus.kind !== "LoadFailed" && + current.faviconOrigin === safeOrigin(navStatus.url); + const { + favicon: _favicon, + faviconOrigin: _faviconOrigin, + ...currentWithoutFavicon + } = current; const state: PreviewTabState = { - ...current, + ...(resetFavicon && !preserveFavicon ? currentWithoutFavicon : current), navStatus, canGoBack, canGoForward, @@ -1307,10 +1628,20 @@ 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 sync = () => runFork(faviconMutationSemaphore.withPermit(syncState(true))); + const syncNavigation = () => + runFork(faviconMutationSemaphore.withPermit(syncState(false, true))); + const navigationStarted = ( + event: Electron.Event, + ) => { + if (event.isMainFrame && !event.isSameDocument) { + invalidateFavicon(); + } + }; + const syncInPageNavigation = () => + runFork(faviconMutationSemaphore.withPermit(syncState(false))); const failed = ( _event: Event, code: number, @@ -1319,18 +1650,235 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function isMainFrame: boolean, ): void => { if (code === -3 || !isMainFrame) return; + invalidateFavicon(); runFork( - update(tabId, { - navStatus: { + faviconMutationSemaphore.withPermit( + syncState(false, true, { kind: "LoadFailed", url: validatedUrl || wc.getURL(), title: wc.getTitle(), code, description, - }, - }), + }), + ), + ); + }; + const captureFaviconData = Effect.fn("PreviewManager.captureFaviconData")(function* ( + faviconUrl: string, + origin: string, + signal: AbortSignal, + ) { + const dataUrl = yield* Effect.tryPromise({ + try: async () => { + const inline = parseInlineFavicon(faviconUrl); + if (inline) { + const normalized = await normalizeFaviconBuffer(wc, inline.mime, inline.buffer); + return signal.aborted ? null : normalized; + } + const requestOrigin = safeOrigin(faviconUrl); + if (!requestOrigin) return null; + const response = await wc.session.fetch(faviconUrl, { + credentials: requestOrigin === origin ? "include" : "omit", + redirect: "error", + signal: AbortSignal.any([signal, AbortSignal.timeout(FAVICON_CAPTURE_TIMEOUT_MS)]), + }); + if (!response.ok) return null; + const buffer = await readFaviconResponse(response); + if (!buffer || signal.aborted) return null; + const mime = response.headers.get("content-type")?.split(";")[0] ?? null; + const normalized = await normalizeFaviconBuffer(wc, mime, buffer); + return signal.aborted ? null : normalized; + }, + catch: (cause) => + new PreviewOperationError({ + operation: "captureFavicon", + tabId, + webContentsId: wc.id, + cause, + }), + }).pipe( + Effect.tapError((error) => + signal.aborted + ? Effect.void + : Effect.logDebug("Favicon capture failed; leaving dedupe key untouched.", { + webContentsId: wc.id, + faviconOrigin: safeOrigin(faviconUrl), + error, + }), + ), + Effect.orElseSucceed(() => null), + ); + return dataUrl; + }); + const publishFaviconUnlocked = Effect.fn("PreviewManager.publishFavicon")(function* ( + publication: PendingFaviconPublication, + ) { + const updatedAt = yield* currentIso; + const currentOrigin = + publication.generation === faviconGeneration && !wc.isDestroyed() + ? safeOrigin(wc.getURL()) + : null; + const result = yield* SynchronizedRef.modify( + tabsRef, + (tabs): readonly [FaviconPublicationResult, ReadonlyMap] => { + const current = tabs.get(tabId); + if ( + !current || + current.webContentsId !== wc.id || + currentOrigin !== publication.origin || + publication.generation !== faviconGeneration + ) { + return [{ kind: "stale" }, tabs]; + } + if ( + current.navStatus.kind === "Loading" && + safeOrigin(current.navStatus.url) === publication.origin + ) { + return [{ kind: "deferred" }, tabs]; + } + if ( + current.navStatus.kind !== "Success" || + safeOrigin(current.navStatus.url) !== publication.origin + ) { + return [{ kind: "stale" }, tabs]; + } + const state: PreviewTabState = { + ...current, + favicon: publication.dataUrl, + faviconOrigin: publication.origin, + updatedAt, + }; + return [ + { kind: "published", state }, + replaceMap(tabs, (copy) => { + copy.set(tabId, state); + }), + ]; + }, ); + if (result.kind === "deferred") { + pendingFaviconPublication = publication; + capturedFaviconKey = publication.faviconKey; + return true; + } + if (result.kind === "stale") { + if (pendingFaviconPublication === publication) { + pendingFaviconPublication = null; + if (capturedFaviconKey === publication.faviconKey) capturedFaviconKey = null; + } + return false; + } + pendingFaviconPublication = null; + capturedFaviconKey = publication.faviconKey; + yield* emitIfCurrent(tabId, result.state); + return true; + }); + const publishPendingFavicon = Effect.fn("PreviewManager.publishPendingFavicon")(function* () { + const pending = pendingFaviconPublication; + if (pending) yield* publishFaviconUnlocked(pending); + }); + const processFaviconRequest = Effect.fn("PreviewManager.processFaviconRequest")(function* ( + request: FaviconCaptureRequest, + ) { + for (const faviconUrl of request.candidates) { + if (request.abortController.signal.aborted || request.generation !== faviconGeneration) { + return false; + } + const faviconKey = faviconCaptureKey([request.origin, faviconUrl]); + if (capturedFaviconKey === faviconKey) return true; + const dataUrl = yield* captureFaviconData( + faviconUrl, + request.origin, + request.abortController.signal, + ); + if ( + !dataUrl && + !request.abortController.signal.aborted && + request.generation === faviconGeneration + ) { + continue; + } + if (!dataUrl) return false; + const accepted = yield* faviconMutationSemaphore.withPermit( + publishFaviconUnlocked({ + dataUrl, + faviconKey, + generation: request.generation, + origin: request.origin, + }), + ); + if (accepted) return true; + if (request.generation !== faviconGeneration) return false; + } + return false; + }); + const faviconWorker = Effect.forever( + Effect.gen(function* () { + const request = yield* Queue.take(faviconRequests); + if (queuedFaviconAbortController === request.abortController) { + queuedFaviconAbortController = null; + } + activeFaviconAbortController = request.abortController; + let accepted = false; + yield* processFaviconRequest(request).pipe( + Effect.tap((result) => + Effect.sync(() => { + accepted = result; + }), + ), + Effect.ensuring( + Effect.sync(() => { + if (activeFaviconAbortController === request.abortController) { + activeFaviconAbortController = null; + } + if ( + !accepted && + request.generation === faviconGeneration && + activeFaviconEventKey === request.eventKey + ) { + activeFaviconEventKey = null; + } + }), + ), + ); + }), + ); + yield* Effect.forkIn(faviconWorker, scope); + const faviconUpdated = (_event: Event, favicons: ReadonlyArray): void => { + const origin = safeOrigin(wc.getURL()); + if (!origin) return; + const candidates = [ + ...new Set(favicons.slice(0, MAX_FAVICON_CANDIDATES).filter(isSupportedFaviconUrl)), + ]; + if (candidates.length === 0) return; + const eventKey = faviconCaptureKey([origin, ...candidates]); + if (activeFaviconEventKey === eventKey) return; + const generation = ++faviconGeneration; + if (pendingFaviconPublication?.origin === origin) { + pendingFaviconPublication = { ...pendingFaviconPublication, generation }; + } + activeFaviconAbortController?.abort(); + queuedFaviconAbortController?.abort(); + const request: FaviconCaptureRequest = { + abortController: new AbortController(), + candidates, + eventKey, + generation, + origin, + }; + activeFaviconEventKey = eventKey; + queuedFaviconAbortController = request.abortController; + Queue.offerUnsafe(faviconRequests, request); }; + const syncStopped = () => + runFork( + faviconMutationSemaphore.withPermit( + Effect.gen(function* () { + yield* syncState(true); + yield* publishPendingFavicon(); + }), + ), + ); const handleHumanInput = Effect.fn("PreviewManager.handleHumanInput")(function* ( rawSignal?: unknown, ) { @@ -1376,8 +1924,18 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function if (isPreviewRefreshShortcut(input)) { event.preventDefault(); runFork( - attempt({ operation: "shortcut.refresh", tabId, webContentsId: wc.id }, () => - wc.reload(), + withTabLifecycleLock( + tabId, + Effect.gen(function* () { + const current = yield* requireWebContents(tabId); + if (current.id !== wc.id) return; + navigationLaunchTokens.delete(tabId); + yield* invalidateFaviconCapture(current.id); + yield* attempt( + { operation: "shortcut.refresh", tabId, webContentsId: current.id }, + () => current.reload(), + ); + }), ).pipe(Effect.ignore), ); return; @@ -1387,11 +1945,14 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function yield* Scope.addFinalizer( scope, attempt({ operation: "detachListeners", tabId, webContentsId: wc.id }, () => { + invalidateFavicon(); + 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-stop-loading", syncStopped); wc.off("did-fail-load", failed as never); wc.off("before-input-event", beforeInput); wc.ipc.off(HUMAN_INPUT_CHANNEL, humanInput); @@ -1399,14 +1960,17 @@ 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-stop-loading", syncStopped); wc.on("did-fail-load", failed as never); wc.ipc.on(HUMAN_INPUT_CHANNEL, humanInput); wc.setWindowOpenHandler(({ url }) => { + invalidateFavicon(); runFork( attemptPromise({ operation: "openPreviewWindow", tabId, webContentsId: wc.id }, () => wc.loadURL(url), @@ -1418,7 +1982,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, { scope, invalidateFavicon }); }), ); }); @@ -1480,6 +2044,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }); const closeTabUnlocked = Effect.fn("PreviewManager.closeTabUnlocked")(function* (tabId: string) { + navigationLaunchTokens.delete(tabId); if (!(yield* SynchronizedRef.get(tabsRef)).has(tabId)) return; yield* Effect.all( [ @@ -1579,6 +2144,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); return; } + navigationLaunchTokens.delete(tabId); const replacedWebContentsId = tab.webContentsId != null && tab.webContentsId !== webContentsId ? tab.webContentsId : null; if (replacedWebContentsId !== null) { @@ -1627,8 +2193,13 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ] as const; } const pendingUrl = current.navStatus.kind === "Loading" ? current.navStatus.url : null; + const { + favicon: _favicon, + faviconOrigin: _faviconOrigin, + ...currentWithoutFavicon + } = current; const next: PreviewTabState = { - ...current, + ...(replacedWebContentsId === null ? current : currentWithoutFavicon), webContentsId, navStatus: pendingUrl === null ? computeNavStatus(wc) : current.navStatus, canGoBack: wc.navigationHistory.canGoBack(), @@ -1667,11 +2238,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function latestNavStatus.url === pendingUrl && wc.getURL() !== pendingUrl ) { - runFork( - attemptPromise({ operation: "registerWebview.loadPendingUrl", tabId, webContentsId }, () => - wc.loadURL(pendingUrl), - ).pipe(Effect.ignore), - ); + const launchToken = Symbol(); + navigationLaunchTokens.set(tabId, launchToken); + return { launchToken, url: pendingUrl, wc }; } }); @@ -1680,16 +2249,41 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function webContentsId: number, ) { const expectedGeneration = tabLifecycleGenerations.get(tabId); - return yield* withTabLifecycleLock( + const action = yield* withTabLifecycleLock( tabId, registerWebviewUnlocked(tabId, webContentsId, expectedGeneration), ); + if (!action) return; + const load = yield* attempt( + { operation: "registerWebview.loadPendingUrl", tabId, webContentsId }, + () => { + if (navigationLaunchTokens.get(tabId) !== action.launchToken || action.wc.isDestroyed()) { + return null; + } + navigationLaunchTokens.delete(tabId); + return action.wc.loadURL(action.url); + }, + ); + if (!load) return; + runFork( + attemptPromise( + { operation: "registerWebview.loadPendingUrl", tabId, webContentsId }, + () => load, + ).pipe(Effect.ignore), + ); }); - const navigate = Effect.fn("PreviewManager.navigate")(function* (tabId: string, rawUrl: string) { + const navigateUnlocked = Effect.fn("PreviewManager.navigateUnlocked")(function* ( + tabId: string, + rawUrl: string, + ) { const url = yield* attempt({ operation: "navigate.normalizeUrl", tabId }, () => normalizePreviewUrl(rawUrl), ); + const launchToken = Symbol(); + navigationLaunchTokens.set(tabId, launchToken); + const currentWebContentsId = (yield* SynchronizedRef.get(tabsRef)).get(tabId)?.webContentsId; + if (currentWebContentsId != null) yield* invalidateFaviconCapture(currentWebContentsId); const updatedAt = yield* currentIso; const pending = yield* SynchronizedRef.modify(tabsRef, (tabs) => { const current = tabs.get(tabId); @@ -1707,6 +2301,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function pictureInPicture: current?.pictureInPicture ?? false, colorScheme: current?.colorScheme ?? "system", controller: current?.controller ?? "none", + ...(current?.favicon && current.faviconOrigin + ? { favicon: current.favicon, faviconOrigin: current.faviconOrigin } + : {}), updatedAt, }; return [ @@ -1720,7 +2317,15 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function if (pending.webContentsId == null) return; const wc = webContents.fromId(pending.webContentsId); if (!wc) { - const detached = { ...pending, webContentsId: null }; + if (navigationLaunchTokens.get(tabId) === launchToken) { + navigationLaunchTokens.delete(tabId); + } + const { + favicon: _favicon, + faviconOrigin: _faviconOrigin, + ...pendingWithoutFavicon + } = pending; + const detached = { ...pendingWithoutFavicon, webContentsId: null }; yield* SynchronizedRef.update(tabsRef, (tabs) => tabs.get(tabId)?.webContentsId !== pending.webContentsId ? tabs @@ -1732,36 +2337,98 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function return; } if (wc.getURL() === url) { - yield* attempt({ operation: "navigate.reload", tabId, webContentsId: wc.id }, () => - wc.reload(), - ); - return; + return { kind: "reload" as const, launchToken, wc }; } - yield* attemptPromise({ operation: "navigate.loadURL", tabId, webContentsId: wc.id }, () => - wc.loadURL(url), - ); + return { kind: "load" as const, launchToken, url, wc }; }); - const withWebContents = Effect.fn("PreviewManager.withWebContents")(function* ( - operation: string, - tabId: string, - use: (wc: Electron.WebContents) => void, - ) { - const wc = yield* requireWebContents(tabId); - yield* attempt({ operation, tabId, webContentsId: wc.id }, () => use(wc)); + const navigate = Effect.fn("PreviewManager.navigate")(function* (tabId: string, rawUrl: string) { + const action = yield* withTabLifecycleLock(tabId, navigateUnlocked(tabId, rawUrl)); + if (!action) return; + const load = yield* attempt( + { + operation: action.kind === "load" ? "navigate.loadURL" : "navigate.reload", + tabId, + webContentsId: action.wc.id, + }, + () => { + if (navigationLaunchTokens.get(tabId) !== action.launchToken || action.wc.isDestroyed()) { + return null; + } + navigationLaunchTokens.delete(tabId); + if (action.kind === "reload") { + action.wc.reload(); + return null; + } + return action.wc.loadURL(action.url); + }, + ); + if (!load) return; + yield* attemptPromise( + { operation: "navigate.loadURL", tabId, webContentsId: action.wc.id }, + () => load, + ); }); const goBack = (tabId: string) => - withWebContents("goBack", tabId, (wc) => { - if (wc.navigationHistory.canGoBack()) wc.navigationHistory.goBack(); - }); + withTabLifecycleLock( + tabId, + Effect.gen(function* () { + const wc = yield* requireWebContents(tabId); + navigationLaunchTokens.delete(tabId); + yield* invalidateFaviconCapture(wc.id); + yield* attempt({ operation: "goBack", tabId, webContentsId: wc.id }, () => { + if (wc.navigationHistory.canGoBack()) wc.navigationHistory.goBack(); + }); + }), + ); const goForward = (tabId: string) => - withWebContents("goForward", tabId, (wc) => { - if (wc.navigationHistory.canGoForward()) wc.navigationHistory.goForward(); - }); - const refresh = (tabId: string) => withWebContents("refresh", tabId, (wc) => wc.reload()); - const hardReload = (tabId: string) => - withWebContents("hardReload", tabId, (wc) => wc.reloadIgnoringCache()); + withTabLifecycleLock( + tabId, + Effect.gen(function* () { + const wc = yield* requireWebContents(tabId); + navigationLaunchTokens.delete(tabId); + yield* invalidateFaviconCapture(wc.id); + yield* attempt({ operation: "goForward", tabId, webContentsId: wc.id }, () => { + if (wc.navigationHistory.canGoForward()) wc.navigationHistory.goForward(); + }); + }), + ); + const refresh = Effect.fn("PreviewManager.refresh")(function* (tabId: string) { + yield* withTabLifecycleLock( + tabId, + Effect.gen(function* () { + const wc = yield* requireWebContents(tabId); + navigationLaunchTokens.delete(tabId); + const loading = yield* attempt( + { operation: "refresh.isLoading", tabId, webContentsId: wc.id }, + () => wc.isLoading(), + ); + if (loading) { + yield* invalidateFaviconCapture(wc.id); + yield* attempt({ operation: "refresh.stop", tabId, webContentsId: wc.id }, () => + wc.stop(), + ); + return; + } + yield* invalidateFaviconCapture(wc.id); + yield* attempt({ operation: "refresh", tabId, webContentsId: wc.id }, () => wc.reload()); + }), + ); + }); + const hardReload = Effect.fn("PreviewManager.hardReload")(function* (tabId: string) { + yield* withTabLifecycleLock( + tabId, + Effect.gen(function* () { + const wc = yield* requireWebContents(tabId); + navigationLaunchTokens.delete(tabId); + yield* invalidateFaviconCapture(wc.id); + yield* attempt({ operation: "hardReload", tabId, webContentsId: wc.id }, () => + wc.reloadIgnoringCache(), + ); + }), + ); + }); const openDevTools = Effect.fn("PreviewManager.openDevTools")(function* (tabId: string) { const wc = yield* requireWebContents(tabId); diff --git a/apps/web/src/browserFaviconLogic.test.ts b/apps/web/src/browserFaviconLogic.test.ts new file mode 100644 index 00000000000..d73ee3a5b92 --- /dev/null +++ b/apps/web/src/browserFaviconLogic.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + BROWSER_FAVICON_MAX_ENTRIES, + type BrowserFaviconEntry, + evictExcessFavicons, + faviconKey, + isStorableFaviconDataUrl, + migratePersistedBrowserFaviconState, +} from "./browserFaviconLogic"; + +function entry(updatedAt = 0): BrowserFaviconEntry { + return { dataUrl: "data:image/png;base64,AAAA", updatedAt }; +} + +describe("faviconKey", () => { + it("combines project key with the canonical origin", () => { + expect(faviconKey("proj-a", "http://myapp.test:3000/admin?x=1", null)).toBe( + "proj-a http://myapp.test:3000", + ); + }); + + it("collapses loopback hosts and the environment host to the same key", () => { + const viaResolvedHost = faviconKey("proj-a", "http://192.168.64.2:3000/", "192.168.64.2"); + const viaLocalhost = faviconKey("proj-a", "http://localhost:3000/", "192.168.64.2"); + expect(viaResolvedHost).not.toBeNull(); + expect(viaResolvedHost).toBe(viaLocalhost); + }); + + it("collapses loopback even when there is no connected environment", () => { + const a = faviconKey("proj-a", "http://localhost:3000/", null); + const b = faviconKey("proj-a", "http://127.0.0.1:3000/", null); + expect(a).not.toBeNull(); + expect(a).toBe(b); + }); + + it("does not collapse an unrelated remote host or a different LAN device", () => { + const remote = faviconKey("proj-a", "http://example.com:3000/", "192.168.64.2"); + const otherLan = faviconKey("proj-a", "http://192.168.1.50:3000/", "192.168.64.2"); + const local = faviconKey("proj-a", "http://localhost:3000/", "192.168.64.2"); + expect(remote).not.toBeNull(); + expect(otherLan).not.toBeNull(); + expect(remote).not.toBe(otherLan); + expect(remote).not.toBe(local); + expect(otherLan).not.toBe(local); + }); + + it("separates ports, schemes, and projects", () => { + expect(faviconKey("proj-a", "http://localhost:3000/", null)).not.toBe( + faviconKey("proj-a", "http://localhost:5173/", null), + ); + expect(faviconKey("proj-a", "http://myapp.test/", null)).not.toBe( + faviconKey("proj-a", "https://myapp.test/", null), + ); + expect(faviconKey("proj-a", "http://localhost:3000/", null)).not.toBe( + faviconKey("proj-b", "http://localhost:3000/", null), + ); + }); + + it("rejects non-http(s) and unparseable urls", () => { + expect(faviconKey("proj-a", "ftp://example.com/", null)).toBeNull(); + expect(faviconKey("proj-a", "not a url", null)).toBeNull(); + expect(faviconKey("", "http://localhost:3000/", null)).toBeNull(); + }); +}); + +describe("isStorableFaviconDataUrl", () => { + it("accepts image data urls within the cap", () => { + expect(isStorableFaviconDataUrl("data:image/png;base64,AAAA")).toBe(true); + expect(isStorableFaviconDataUrl("data:image/svg+xml;base64,AAAA")).toBe(true); + expect(isStorableFaviconDataUrl("data:image/x-icon;base64,AAAA")).toBe(true); + }); + + it("rejects non-image data urls, other schemes, and oversized values", () => { + expect(isStorableFaviconDataUrl("data:text/html;base64,AAAA")).toBe(false); + expect(isStorableFaviconDataUrl("data:image/svg+xml,")).toBe(false); + expect(isStorableFaviconDataUrl("http://example.com/favicon.ico")).toBe(false); + expect(isStorableFaviconDataUrl(42)).toBe(false); + expect(isStorableFaviconDataUrl(`data:image/png;base64,${"A".repeat(8192)}`)).toBe(false); + }); + + it("rejects data urls with no payload", () => { + expect(isStorableFaviconDataUrl("data:image/x-icon;base64,")).toBe(false); + expect(isStorableFaviconDataUrl("data:image/png;base64,")).toBe(false); + expect(isStorableFaviconDataUrl("data:image/png;base64, ")).toBe(false); + expect(isStorableFaviconDataUrl("data:image/png;base64,%%%%")).toBe(false); + expect(isStorableFaviconDataUrl("data:image/png;base64,AAA\n")).toBe(false); + expect(isStorableFaviconDataUrl("data:image/png")).toBe(false); + }); +}); + +describe("evictExcessFavicons", () => { + it("keeps the most recently updated entries when over the cap", () => { + const byKey = Object.fromEntries( + Array.from({ length: BROWSER_FAVICON_MAX_ENTRIES + 2 }, (_, i) => [`k-${i}`, entry(i)]), + ); + const next = evictExcessFavicons(byKey); + expect(Object.keys(next)).toHaveLength(BROWSER_FAVICON_MAX_ENTRIES); + expect(next["k-0"]).toBeUndefined(); + expect(next["k-1"]).toBeUndefined(); + expect(next[`k-${BROWSER_FAVICON_MAX_ENTRIES + 1}`]).toBeDefined(); + }); +}); + +describe("migratePersistedBrowserFaviconState", () => { + it("returns empty state for junk payloads", () => { + expect(migratePersistedBrowserFaviconState(null)).toEqual({ byKey: {} }); + expect(migratePersistedBrowserFaviconState("nope")).toEqual({ byKey: {} }); + expect(migratePersistedBrowserFaviconState({ byKey: 42 })).toEqual({ byKey: {} }); + }); + + it("drops entries with invalid data urls or timestamps", () => { + const migrated = migratePersistedBrowserFaviconState({ + byKey: { + good: { dataUrl: "data:image/png;base64,AAAA", updatedAt: 10 }, + badScheme: { dataUrl: "http://example.com/i.ico", updatedAt: 10 }, + badTime: { dataUrl: "data:image/png;base64,AAAA", updatedAt: Number.NaN }, + notAnObject: "junk", + }, + }); + expect(migrated.byKey).toEqual({ + good: { dataUrl: "data:image/png;base64,AAAA", updatedAt: 10 }, + }); + }); +}); diff --git a/apps/web/src/browserFaviconLogic.ts b/apps/web/src/browserFaviconLogic.ts new file mode 100644 index 00000000000..5c1faaf4d37 --- /dev/null +++ b/apps/web/src/browserFaviconLogic.ts @@ -0,0 +1,77 @@ +import { FAVICON_DATA_URL_MAX_LENGTH } from "@t3tools/contracts"; + +import { isLocalLoopbackHost, normalizeHostname } from "./browser/browserTargetResolver"; + +export type BrowserFaviconEntry = { dataUrl: string; updatedAt: number }; + +export const BROWSER_FAVICON_MAX_DATA_URL_LENGTH = FAVICON_DATA_URL_MAX_LENGTH; +export const BROWSER_FAVICON_MAX_ENTRIES = 40; + +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 collapsesToLocal = + isLocalLoopbackHost(host) || + host === "0.0.0.0" || + (environmentHostname !== null && host === normalizeHostname(environmentHostname)); + const port = parsed.port || (parsed.protocol === "https:" ? "443" : "80"); + const canonicalHost = collapsesToLocal ? "local" : host; + return `${projectRefKey} ${parsed.protocol}//${canonicalHost}:${port}`; + } catch { + return null; + } +} + +export function isStorableFaviconDataUrl(value: unknown): value is string { + if ( + typeof value !== "string" || + !/^data:image\/[a-z0-9.+-]+;base64,/i.test(value) || + value.length > BROWSER_FAVICON_MAX_DATA_URL_LENGTH + ) { + return false; + } + const commaIndex = value.indexOf(","); + if (commaIndex === -1) return false; + const payload = value.slice(commaIndex + 1); + 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; + const kept = keys + .toSorted((a, b) => (byKey[b]?.updatedAt ?? 0) - (byKey[a]?.updatedAt ?? 0)) + .slice(0, BROWSER_FAVICON_MAX_ENTRIES); + return Object.fromEntries(kept.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 (!value || typeof value !== "object") continue; + const { dataUrl, updatedAt } = value as Record; + if (!isStorableFaviconDataUrl(dataUrl)) continue; + if (typeof updatedAt !== "number" || !Number.isFinite(updatedAt)) continue; + byKey[key] = { dataUrl, updatedAt }; + } + 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..72d2d96a683 --- /dev/null +++ b/apps/web/src/browserFaviconStore.test.ts @@ -0,0 +1,219 @@ +import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment"; +import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const { readPreparedConnection } = vi.hoisted(() => ({ + readPreparedConnection: vi.fn<() => { httpBaseUrl: string } | null>(() => ({ + httpBaseUrl: "http://192.168.64.2:3773", + })), +})); + +vi.mock("~/state/session", () => ({ + readPreparedConnection, + usePreparedConnection: () => ({ _tag: "None" }), +})); + +vi.mock("~/state/entities", () => ({ + useThreadShell: () => null, +})); + +import { faviconKey } from "./browserFaviconLogic"; +import { + flushPendingFaviconsForThread, + mergeBrowserFaviconState, + recordFaviconForProject, + recordFaviconForThread, + resetBrowserFaviconsForTests, + resolveBrowserFaviconStorage, + useBrowserFaviconStore, +} from "./browserFaviconStore"; + +const projectRef = scopeProjectRef(EnvironmentId.make("env-1"), ProjectId.make("project-1")); +const threadRef = { + environmentId: projectRef.environmentId, + threadId: ThreadId.make("thread-1"), +}; +const PNG = "data:image/png;base64,AAAA"; + +afterEach(() => vi.unstubAllGlobals()); + +describe("resolveBrowserFaviconStorage", () => { + 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"); + }); +}); + +describe("recordFaviconForProject", () => { + beforeEach(() => { + readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://192.168.64.2:3773" }); + resetBrowserFaviconsForTests(); + }); + + it("stores an icon under physical project + canonical host", () => { + recordFaviconForProject(projectRef, "http://localhost:3000/admin", PNG, 5); + expect(useBrowserFaviconStore.getState().byKey).toEqual({ + "env-1:project-1 http://local:3000": { dataUrl: PNG, updatedAt: 5 }, + }); + }); + + it("ignores invalid urls and non-image payloads", () => { + recordFaviconForProject(projectRef, "ftp://nope/", PNG, 2); + recordFaviconForProject(projectRef, "http://localhost:3000/", "http://evil/i.png", 3); + expect(useBrowserFaviconStore.getState().byKey).toEqual({}); + }); + + it("does not store a non-canonical icon before the environment connection is ready", () => { + readPreparedConnection.mockReturnValueOnce(null); + expect(recordFaviconForProject(projectRef, "http://192.168.64.2:3000/", PNG, 1)).toBe(false); + expect(useBrowserFaviconStore.getState().byKey).toEqual({}); + }); + + it("flushes an icon captured before its thread project is registered", () => { + expect(recordFaviconForThread(threadRef, "http://localhost:3000/", PNG, 1)).toBe(false); + expect(useBrowserFaviconStore.getState().byKey).toEqual({}); + + useBrowserFaviconStore + .getState() + .registerThreadProject(threadRef, scopedProjectKey(projectRef)); + + expect(useBrowserFaviconStore.getState().byKey).toEqual({ + "env-1:project-1 http://local:3000": { dataUrl: PNG, updatedAt: 1 }, + }); + expect(useBrowserFaviconStore.getState().pendingByThreadKey).toEqual({}); + }); + + it("flushes a pre-connection capture under the canonical environment key", () => { + useBrowserFaviconStore + .getState() + .registerThreadProject(threadRef, scopedProjectKey(projectRef)); + readPreparedConnection.mockReturnValue(null); + + expect(recordFaviconForThread(threadRef, "http://192.168.64.2:3000/", PNG, 1)).toBe(false); + expect(useBrowserFaviconStore.getState().byKey).toEqual({}); + expect(useBrowserFaviconStore.getState().pendingByThreadKey).not.toEqual({}); + + readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://192.168.64.2:3773" }); + expect(flushPendingFaviconsForThread(threadRef)).toBe(true); + + expect(useBrowserFaviconStore.getState().byKey).toEqual({ + "env-1:project-1 http://local:3000": { dataUrl: PNG, updatedAt: 1 }, + }); + expect(useBrowserFaviconStore.getState().pendingByThreadKey).toEqual({}); + }); + + it("keeps the newest duplicate while captures wait for the connection", () => { + useBrowserFaviconStore + .getState() + .registerThreadProject(threadRef, scopedProjectKey(projectRef)); + readPreparedConnection.mockReturnValue(null); + + recordFaviconForThread(threadRef, "http://192.168.64.2:3000/", PNG, 20); + recordFaviconForThread(threadRef, "http://192.168.64.2:3000/", PNG, 10); + + readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://192.168.64.2:3773" }); + flushPendingFaviconsForThread(threadRef); + expect(useBrowserFaviconStore.getState().byKey).toEqual({ + "env-1:project-1 http://local:3000": { dataUrl: PNG, updatedAt: 20 }, + }); + }); + + it("overwrites an existing icon for the same key", () => { + recordFaviconForProject(projectRef, "http://localhost:3000/", PNG, 5); + const next = "data:image/svg+xml;base64,BBBB"; + recordFaviconForProject(projectRef, "http://localhost:3000/other", next, 9); + expect(useBrowserFaviconStore.getState().byKey).toEqual({ + "env-1:project-1 http://local:3000": { dataUrl: next, updatedAt: 9 }, + }); + }); + + it("does not replace a newer icon with an older capture", () => { + const current = "data:image/svg+xml;base64,Q1VSUkVOVA=="; + const stale = "data:image/svg+xml;base64,U1RBTEU="; + const simultaneous = "data:image/svg+xml;base64,U0lNVUxUQU5FT1VT"; + recordFaviconForProject(projectRef, "http://localhost:3000/", current, 20); + recordFaviconForProject(projectRef, "http://localhost:3000/other", stale, 10); + recordFaviconForProject(projectRef, "http://localhost:3000/equal", simultaneous, 20); + expect(useBrowserFaviconStore.getState().byKey).toEqual({ + "env-1:project-1 http://local:3000": { dataUrl: current, updatedAt: 20 }, + }); + }); + + it("advances updatedAt on a revisit with an unchanged icon", () => { + recordFaviconForProject(projectRef, "http://localhost:3000/", PNG, 5); + recordFaviconForProject(projectRef, "http://localhost:3000/", PNG, 20); + expect(useBrowserFaviconStore.getState().byKey).toEqual({ + "env-1:project-1 http://local:3000": { dataUrl: PNG, updatedAt: 20 }, + }); + }); + + it("does not share localhost favicons across environments or physical projects", () => { + const remoteProjectRef = scopeProjectRef( + EnvironmentId.make("env-2"), + ProjectId.make("project-1"), + ); + const siblingProjectRef = scopeProjectRef( + EnvironmentId.make("env-1"), + ProjectId.make("project-2"), + ); + recordFaviconForProject(projectRef, "http://localhost:3000/", PNG, 5); + recordFaviconForProject( + remoteProjectRef, + "http://localhost:3000/", + "data:image/png;base64,BBBB", + 6, + ); + recordFaviconForProject( + siblingProjectRef, + "http://localhost:3000/", + "data:image/png;base64,CCCC", + 7, + ); + 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", + ]); + }); +}); + +describe("capture and lookup key agreement", () => { + beforeEach(() => { + resetBrowserFaviconsForTests(); + }); + + it("capturing under the resolved environment host is found by the requested localhost url", () => { + recordFaviconForProject(projectRef, "http://192.168.64.2:3773/app", PNG, 5); + + const key = faviconKey("env-1:project-1", "http://localhost:3773/app", "192.168.64.2"); + expect(key).not.toBeNull(); + expect(useBrowserFaviconStore.getState().byKey[key!]?.dataUrl).toBe(PNG); + }); +}); + +describe("mergeBrowserFaviconState", () => { + it("sanitizes same-version corrupt data and preserves actions", () => { + const merged = mergeBrowserFaviconState( + { + byKey: { + bad: { dataUrl: "http://x/i.png", updatedAt: 1 }, + good: { dataUrl: PNG, updatedAt: 2 }, + }, + }, + useBrowserFaviconStore.getState(), + ); + expect(merged.byKey).toEqual({ good: { dataUrl: PNG, updatedAt: 2 } }); + expect(typeof merged.recordFavicon).toBe("function"); + }); +}); diff --git a/apps/web/src/browserFaviconStore.ts b/apps/web/src/browserFaviconStore.ts new file mode 100644 index 00000000000..7f1897a4ab7 --- /dev/null +++ b/apps/web/src/browserFaviconStore.ts @@ -0,0 +1,232 @@ +import { scopedProjectKey, scopedThreadKey } from "@t3tools/client-runtime/environment"; +import type { ScopedProjectRef, ScopedThreadRef } from "@t3tools/contracts"; +import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; + +import * as Option from "effect/Option"; + +import { readPreparedConnection, usePreparedConnection } from "~/state/session"; + +import { + type BrowserFaviconEntry, + evictExcessFavicons, + faviconKey, + isStorableFaviconDataUrl, + migratePersistedBrowserFaviconState, +} from "./browserFaviconLogic"; +import { resolveStorage } from "./lib/storage"; + +const BROWSER_FAVICON_STORAGE_KEY = "t3code:browser-favicons:v1"; + +export interface BrowserFaviconStoreState { + byKey: Record; + projectKeyByThreadKey: Record; + pendingByThreadKey: Record; + recordFavicon: (key: string, dataUrl: string, at: number) => void; + registerThreadProject: (ref: ScopedThreadRef, projectKey: string) => void; +} + +type PendingFavicon = { url: string; dataUrl: string; at: number }; +const MAX_PENDING_FAVICONS_PER_THREAD = 10; + +function addPendingFavicon( + pendingByThreadKey: Record, + threadKey: string, + favicon: PendingFavicon, +): Record { + const current = pendingByThreadKey[threadKey] ?? []; + const duplicate = current.find( + (candidate) => candidate.url === favicon.url && candidate.dataUrl === favicon.dataUrl, + ); + const pending = current.filter( + (candidate) => candidate.url !== favicon.url || candidate.dataUrl !== favicon.dataUrl, + ); + const newest = duplicate && duplicate.at >= favicon.at ? duplicate : favicon; + return { + ...pendingByThreadKey, + [threadKey]: [...pending, newest] + .sort((left, right) => left.at - right.at) + .slice(-MAX_PENDING_FAVICONS_PER_THREAD), + }; +} + +export function resolveBrowserFaviconStorage() { + try { + return resolveStorage(typeof window !== "undefined" ? window.localStorage : undefined); + } catch { + return resolveStorage(undefined); + } +} + +export const useBrowserFaviconStore = create()( + persist( + (set, get) => ({ + byKey: {}, + projectKeyByThreadKey: {}, + pendingByThreadKey: {}, + recordFavicon: (key, dataUrl, at) => + set((state) => { + if (!isStorableFaviconDataUrl(dataUrl)) return state; + const existing = state.byKey[key]; + if (existing && at <= existing.updatedAt) return state; + if (existing?.dataUrl === dataUrl) { + return { byKey: { ...state.byKey, [key]: { ...existing, updatedAt: at } } }; + } + return { + byKey: evictExcessFavicons({ ...state.byKey, [key]: { dataUrl, updatedAt: at } }), + }; + }), + registerThreadProject: (ref, projectKey) => { + const threadKey = scopedThreadKey(ref); + const state = get(); + if (state.projectKeyByThreadKey[threadKey] !== projectKey) { + set({ + projectKeyByThreadKey: { ...state.projectKeyByThreadKey, [threadKey]: projectKey }, + }); + } + flushPendingFaviconsForThread(ref); + }, + }), + { + name: BROWSER_FAVICON_STORAGE_KEY, + version: 1, + storage: createJSONStorage(resolveBrowserFaviconStorage), + partialize: (state) => ({ + byKey: state.byKey, + projectKeyByThreadKey: state.projectKeyByThreadKey, + }), + migrate: migratePersistedBrowserFaviconState, + merge: mergeBrowserFaviconState, + }, + ), +); + +export function mergeBrowserFaviconState( + persistedState: unknown, + currentState: BrowserFaviconStoreState, +): BrowserFaviconStoreState { + const projectKeyByThreadKey = + persistedState && + typeof persistedState === "object" && + "projectKeyByThreadKey" in persistedState && + persistedState.projectKeyByThreadKey && + typeof persistedState.projectKeyByThreadKey === "object" && + !Array.isArray(persistedState.projectKeyByThreadKey) + ? Object.fromEntries( + Object.entries(persistedState.projectKeyByThreadKey) + .filter((entry): entry is [string, string] => typeof entry[1] === "string") + .slice(-100), + ) + : {}; + return { + ...currentState, + ...migratePersistedBrowserFaviconState(persistedState), + projectKeyByThreadKey: { ...projectKeyByThreadKey, ...currentState.projectKeyByThreadKey }, + }; +} + +function resolveFaviconKey( + projectKey: string, + environmentId: ScopedProjectRef["environmentId"], + url: string, +): string | null { + const connection = readPreparedConnection(environmentId); + if (!connection) return null; + return faviconKey(projectKey, url, new URL(connection.httpBaseUrl).hostname); +} + +function recordFaviconForProjectKey( + projectKey: string, + environmentId: ScopedProjectRef["environmentId"], + url: string, + dataUrl: string, + at: number, +): boolean { + if (!isStorableFaviconDataUrl(dataUrl)) return false; + const key = resolveFaviconKey(projectKey, environmentId, url); + if (!key) return false; + useBrowserFaviconStore.getState().recordFavicon(key, dataUrl, at); + return true; +} + +export function recordFaviconForProject( + ref: ScopedProjectRef, + url: string, + dataUrl: string, + at?: number, +): boolean { + return recordFaviconForProjectKey( + scopedProjectKey(ref), + ref.environmentId, + url, + dataUrl, + at ?? Date.now(), + ); +} + +export function recordFaviconForThread( + ref: ScopedThreadRef, + url: string, + dataUrl: string, + at = Date.now(), +): boolean { + if (!isStorableFaviconDataUrl(dataUrl)) return false; + const threadKey = scopedThreadKey(ref); + const state = useBrowserFaviconStore.getState(); + const projectKey = state.projectKeyByThreadKey[threadKey]; + if (projectKey && recordFaviconForProjectKey(projectKey, ref.environmentId, url, dataUrl, at)) + return true; + if (!faviconKey("pending", url, null)) return false; + useBrowserFaviconStore.setState({ + pendingByThreadKey: addPendingFavicon(state.pendingByThreadKey, threadKey, { + url, + dataUrl, + at, + }), + }); + return false; +} + +export function flushPendingFaviconsForThread(ref: ScopedThreadRef): boolean { + const threadKey = scopedThreadKey(ref); + const state = useBrowserFaviconStore.getState(); + const projectKey = state.projectKeyByThreadKey[threadKey]; + const pending = state.pendingByThreadKey[threadKey]; + if (!projectKey || !pending || !readPreparedConnection(ref.environmentId)) return false; + const remaining = pending.filter( + (favicon) => + !recordFaviconForProjectKey( + projectKey, + ref.environmentId, + favicon.url, + favicon.dataUrl, + favicon.at, + ), + ); + const pendingByThreadKey = { ...useBrowserFaviconStore.getState().pendingByThreadKey }; + if (remaining.length === 0) delete pendingByThreadKey[threadKey]; + else pendingByThreadKey[threadKey] = remaining; + useBrowserFaviconStore.setState({ pendingByThreadKey }); + return remaining.length === 0; +} + +export function useFaviconForThreadUrl(ref: ScopedThreadRef, url: string): string | null { + const preparedConnection = usePreparedConnection(ref.environmentId); + const environmentHostname = Option.isSome(preparedConnection) + ? new URL(preparedConnection.value.httpBaseUrl).hostname + : null; + return useBrowserFaviconStore((state) => { + const projectKey = state.projectKeyByThreadKey[scopedThreadKey(ref)]; + const key = projectKey ? faviconKey(projectKey, url, environmentHostname) : null; + return key ? (state.byKey[key]?.dataUrl ?? null) : null; + }); +} + +export function resetBrowserFaviconsForTests(): void { + useBrowserFaviconStore.setState({ + byKey: {}, + projectKeyByThreadKey: {}, + pendingByThreadKey: {}, + }); + useBrowserFaviconStore.persist.clearStorage(); +} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 26a5c41ded5..d7bf68b2f60 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -33,6 +33,7 @@ import { } from "@t3tools/client-runtime/state/thread-settled"; import { parseScopedThreadKey, + scopedProjectKey, scopedThreadKey, scopeProjectRef, scopeThreadRef, @@ -173,6 +174,7 @@ import { } from "~/projectScripts"; import { newDraftId, newMessageId, newThreadId } from "~/lib/utils"; import { useBrowserHistoryStore } from "~/browserHistoryStore"; +import { useBrowserFaviconStore } from "~/browserFaviconStore"; import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels"; import { NO_PROVIDER_MODEL_SELECTION } from "../providerInstances"; import { @@ -1654,6 +1656,7 @@ function ChatViewContent(props: ChatViewProps) { const activeProjectRef = activeThread ? scopeProjectRef(activeThread.environmentId, activeThread.projectId) : null; + const activePhysicalProjectKey = activeProjectRef ? scopedProjectKey(activeProjectRef) : null; const activeProject = useProject(activeProjectRef); const handleNewThreadInActiveProject = useCallback(() => { startNewThreadForProject(activeProjectRef, handleNewThread); @@ -1705,6 +1708,12 @@ function ChatViewContent(props: ChatViewProps) { // drive the environment picker in BranchToolbar. const allProjects = useProjects(); const primaryEnvironmentId = primaryEnvironment?.environmentId ?? null; + useEffect(() => { + if (!activeThreadRef || !activePhysicalProjectKey) return; + useBrowserFaviconStore + .getState() + .registerThreadProject(activeThreadRef, activePhysicalProjectKey); + }, [activePhysicalProjectKey, activeThreadRef]); useEffect(() => { if (!clientSettingsHydrated || !activeThreadRef || !activeProject) return; // Reuse the sidebar's grouping so history follows the project rows the user diff --git a/apps/web/src/components/preview/PreviewView.test.tsx b/apps/web/src/components/preview/PreviewView.test.tsx index d9671e2f2d9..379fb4f7348 100644 --- a/apps/web/src/components/preview/PreviewView.test.tsx +++ b/apps/web/src/components/preview/PreviewView.test.tsx @@ -74,6 +74,7 @@ vi.mock("~/previewStateStore", () => ({ pictureInPicture: mocks.pictureInPicture, colorScheme: "system", controller: "none", + favicon: null, }, }, recentlySeenUrls: [], diff --git a/apps/web/src/components/preview/usePreviewBridge.test.ts b/apps/web/src/components/preview/usePreviewBridge.test.ts new file mode 100644 index 00000000000..ee893e44184 --- /dev/null +++ b/apps/web/src/components/preview/usePreviewBridge.test.ts @@ -0,0 +1,116 @@ +import type { DesktopPreviewTabState } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { faviconRecordForDesktopState } from "./usePreviewBridge"; + +const PNG = "data:image/png;base64,AAAA"; +const URL = "http://localhost:3000/app"; +const ORIGIN = "http://localhost:3000"; + +function state( + navStatus: DesktopPreviewTabState["navStatus"], + overrides: Partial = {}, +): DesktopPreviewTabState { + return { + tabId: "tab-1", + webContentsId: 1, + navStatus, + canGoBack: false, + canGoForward: false, + zoomFactor: 1, + pictureInPicture: false, + colorScheme: "system", + controller: "none", + favicon: PNG, + faviconOrigin: ORIGIN, + updatedAt: "2026-08-03T12:00:00.000Z", + ...overrides, + }; +} + +const recordKey = (url: string, favicon = PNG) => JSON.stringify([ORIGIN, url, favicon]); +const recorded = { dataUrl: PNG, key: recordKey(URL), url: URL }; + +describe("faviconRecordForDesktopState", () => { + it("records the first matching favicon", () => { + expect( + faviconRecordForDesktopState({ + navigationPending: false, + lastRecordedKey: null, + state: state({ kind: "Success", url: URL, title: "App" }), + }), + ).toEqual(recorded); + }); + + it("ignores ordinary state changes carrying the same sticky favicon", () => { + expect( + faviconRecordForDesktopState({ + navigationPending: false, + lastRecordedKey: recorded.key, + state: state( + { kind: "Success", url: URL, title: "Renamed" }, + { canGoBack: true, updatedAt: "2026-08-03T12:01:00.000Z" }, + ), + }), + ).toBeNull(); + }); + + it("refreshes favicon recency when capture arrives after reload success", () => { + const { + favicon: _favicon, + faviconOrigin: _faviconOrigin, + ...successWithoutFavicon + } = state({ kind: "Success", url: URL, title: "App" }); + expect( + faviconRecordForDesktopState({ + navigationPending: true, + lastRecordedKey: recorded.key, + state: state({ kind: "Loading", url: URL, title: "App" }), + }), + ).toBeNull(); + expect( + faviconRecordForDesktopState({ + navigationPending: true, + lastRecordedKey: recorded.key, + state: successWithoutFavicon, + }), + ).toBeNull(); + expect( + faviconRecordForDesktopState({ + navigationPending: true, + lastRecordedKey: recorded.key, + state: state({ kind: "Success", url: URL, title: "App" }), + }), + ).toEqual(recorded); + }); + + it("records a changed favicon or a navigation within the same origin", () => { + const nextUrl = `${ORIGIN}/settings`; + expect( + faviconRecordForDesktopState({ + navigationPending: false, + lastRecordedKey: recorded.key, + state: state({ kind: "Success", url: nextUrl, title: "Settings" }), + }), + ).toEqual({ dataUrl: PNG, key: recordKey(nextUrl), url: nextUrl }); + + const nextIcon = "data:image/png;base64,BBBB"; + expect( + faviconRecordForDesktopState({ + navigationPending: false, + lastRecordedKey: recorded.key, + state: state({ kind: "Success", url: URL, title: "App" }, { favicon: nextIcon }), + }), + ).toEqual({ dataUrl: nextIcon, key: recordKey(URL, nextIcon), url: URL }); + }); + + it("rejects a sticky favicon from the previous origin", () => { + expect( + faviconRecordForDesktopState({ + navigationPending: true, + lastRecordedKey: recorded.key, + state: state({ kind: "Success", url: "https://example.com/", title: "Example" }), + }), + ).toBeNull(); + }); +}); diff --git a/apps/web/src/components/preview/usePreviewBridge.ts b/apps/web/src/components/preview/usePreviewBridge.ts index 259748c41d5..fe59b8c8aff 100644 --- a/apps/web/src/components/preview/usePreviewBridge.ts +++ b/apps/web/src/components/preview/usePreviewBridge.ts @@ -6,15 +6,55 @@ import type { ScopedThreadRef, ThreadId, } from "@t3tools/contracts"; -import { useEffect, useRef } from "react"; +import { parseScopedThreadKey, scopedThreadKey } from "@t3tools/client-runtime/environment"; +import { useEffect, useMemo, useRef } from "react"; +import { flushPendingFaviconsForThread, recordFaviconForThread } from "~/browserFaviconStore"; import { useBrowserPointerStore } from "~/browser/browserPointerStore"; import { applyPreviewDesktopState, type DesktopPreviewOverlay } from "~/previewStateStore"; import { previewEnvironment } from "~/state/preview"; +import { usePreparedConnection } from "~/state/session"; import { useAtomCommand } from "~/state/use-atom-command"; import { previewBridge } from "./previewBridge"; +function originOf(url: string): string | null { + try { + return new URL(url).origin; + } catch { + return null; + } +} + +export interface FaviconRecord { + readonly dataUrl: string; + readonly key: string; + readonly url: string; +} + +export function faviconRecordForDesktopState(input: { + readonly navigationPending: boolean; + readonly lastRecordedKey: string | null; + readonly state: DesktopPreviewTabState; +}): FaviconRecord | null { + const { navigationPending, lastRecordedKey, state } = input; + if ( + !state.favicon || + !state.faviconOrigin || + state.navStatus.kind !== "Success" || + originOf(state.navStatus.url) !== state.faviconOrigin + ) { + return null; + } + const key = JSON.stringify([state.faviconOrigin, state.navStatus.url, state.favicon]); + if (!navigationPending && key === lastRecordedKey) return null; + return { + dataUrl: state.favicon, + key, + url: state.navStatus.url, + }; +} + /** * Mirrors low-latency desktop state into the store and reflects navigation * events back to the server. Webview lifetime is owned by ElectronBrowserHost. @@ -28,26 +68,52 @@ export function usePreviewBridge(input: { const clearBrowserPointer = useBrowserPointerStore((state) => state.clear); const reportStatus = useAtomCommand(previewEnvironment.reportStatus, "preview status report"); const bridge = previewBridge; - + const threadKey = scopedThreadKey(threadRef); + const stableThreadRef = useMemo(() => { + const parsed = parseScopedThreadKey(threadKey); + if (!parsed) throw new Error(`Invalid scoped thread key: ${threadKey}`); + return parsed; + }, [threadKey]); + const preparedConnection = usePreparedConnection(stableThreadRef.environmentId); // One bridge subscription does both jobs (mirror state + forward to // server) so the desktop bridge keeps a single listener entry per tab. const lastReportedUrl = useRef(null); const lastReportedKind = useRef(null); const lastDesktopNavStatus = useRef(null); + const lastRecordedFaviconKey = useRef(null); + const faviconNavigationPending = useRef(false); useEffect(() => { if (!bridge || typeof window === "undefined") return; lastReportedUrl.current = null; lastReportedKind.current = null; lastDesktopNavStatus.current = null; + lastRecordedFaviconKey.current = null; + faviconNavigationPending.current = false; const unsubscribe = bridge.onStateChange((changedTabId, state) => { if (changedTabId !== runtimeTabId) return; - if (shouldClearBrowserPointer(lastDesktopNavStatus.current, state.navStatus)) { + const previousNavStatus = lastDesktopNavStatus.current; + if (shouldClearBrowserPointer(previousNavStatus, state.navStatus)) { clearBrowserPointer(runtimeTabId); } lastDesktopNavStatus.current = state.navStatus; - applyPreviewDesktopState(threadRef, tabId, projectDesktopState(state)); + if (state.navStatus.kind === "Loading" && previousNavStatus?.kind !== "Loading") { + faviconNavigationPending.current = true; + } + applyPreviewDesktopState(stableThreadRef, tabId, projectDesktopState(state)); + const faviconRecord = faviconRecordForDesktopState({ + navigationPending: faviconNavigationPending.current, + lastRecordedKey: lastRecordedFaviconKey.current, + state, + }); + if ( + faviconRecord && + recordFaviconForThread(stableThreadRef, faviconRecord.url, faviconRecord.dataUrl) + ) { + lastRecordedFaviconKey.current = faviconRecord.key; + faviconNavigationPending.current = false; + } const reported = buildReportInput({ - threadId: threadRef.threadId, + threadId: stableThreadRef.threadId, tabId, state, lastReportedUrl: lastReportedUrl.current, @@ -57,12 +123,15 @@ export function usePreviewBridge(input: { lastReportedUrl.current = reported.lastReportedUrl; lastReportedKind.current = reported.lastReportedKind; void reportStatus({ - environmentId: threadRef.environmentId, + environmentId: stableThreadRef.environmentId, input: reported.input, }); }); return unsubscribe; - }, [bridge, clearBrowserPointer, reportStatus, runtimeTabId, tabId, threadRef]); + }, [bridge, clearBrowserPointer, reportStatus, runtimeTabId, stableThreadRef, tabId, threadKey]); + useEffect(() => { + flushPendingFaviconsForThread(stableThreadRef); + }, [preparedConnection, stableThreadRef]); } function shouldClearBrowserPointer( @@ -76,6 +145,7 @@ function shouldClearBrowserPointer( } function projectDesktopState(state: DesktopPreviewTabState): DesktopPreviewOverlay { + const navOrigin = state.navStatus.kind === "Idle" ? null : originOf(state.navStatus.url); return { hasWebContents: state.webContentsId !== null, canGoBack: state.canGoBack, @@ -85,6 +155,7 @@ function projectDesktopState(state: DesktopPreviewTabState): DesktopPreviewOverl pictureInPicture: state.pictureInPicture, colorScheme: state.colorScheme, controller: state.controller, + favicon: state.favicon && state.faviconOrigin === navOrigin ? state.favicon : null, }; } diff --git a/apps/web/src/previewStateStore.test.ts b/apps/web/src/previewStateStore.test.ts index 50bda95c911..975ef59f4be 100644 --- a/apps/web/src/previewStateStore.test.ts +++ b/apps/web/src/previewStateStore.test.ts @@ -322,6 +322,7 @@ describe("previewStateStore (single-tab)", () => { pictureInPicture: false, colorScheme: "system", controller: "none", + favicon: null, }); const state = readThreadPreviewState(ref); expect(state.desktopOverlay?.canGoBack).toBe(true); @@ -342,6 +343,7 @@ describe("previewStateStore (single-tab)", () => { pictureInPicture: false, colorScheme: "system", controller: "none", + favicon: null, }); setActivePreviewTab(ref, first.tabId); @@ -390,6 +392,7 @@ describe("previewStateStore (single-tab)", () => { pictureInPicture: false, colorScheme: "system", controller: "none", + favicon: null, }); reconcilePreviewServerSessions(ref, { sessions: [active], serverEpoch, revision: 1 }); @@ -504,6 +507,7 @@ describe("previewStateStore (single-tab)", () => { pictureInPicture: false, colorScheme: "system", controller: "none", + favicon: null, }); const restarted = makeSnapshot({ navStatus: { _tag: "Success", url: "https://new.example", title: "New" }, diff --git a/apps/web/src/previewStateStore.ts b/apps/web/src/previewStateStore.ts index f3dced0a759..df2a855bb90 100644 --- a/apps/web/src/previewStateStore.ts +++ b/apps/web/src/previewStateStore.ts @@ -28,6 +28,7 @@ export interface DesktopPreviewOverlay { pictureInPicture: boolean; colorScheme: DesktopPreviewColorScheme; controller: "human" | "agent" | "none"; + favicon: string | null; } export interface ThreadPreviewState { diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 64ef755eedf..9f04de4ca0d 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -524,6 +524,9 @@ export type DesktopPreviewColorScheme = "system" | "light" | "dark"; export const DesktopPreviewColorSchemeSchema: Schema.Codec = Schema.Literals(["system", "light", "dark"]); +/** Shared desktop/web cap for persisted favicon data URLs. */ +export const FAVICON_DATA_URL_MAX_LENGTH = 8192; + export interface DesktopPreviewTabState { tabId: string; webContentsId: number | null; @@ -536,6 +539,8 @@ export interface DesktopPreviewTabState { pictureInPicture: boolean; colorScheme: DesktopPreviewColorScheme; controller: "human" | "agent" | "none"; + favicon?: string; + faviconOrigin?: string; updatedAt: string; } @@ -574,6 +579,8 @@ export const DesktopPreviewTabStateSchema: Schema.Codec pictureInPicture: Schema.Boolean, colorScheme: DesktopPreviewColorSchemeSchema, controller: Schema.Literals(["human", "agent", "none"]), + favicon: Schema.optionalKey(Schema.String), + faviconOrigin: Schema.optionalKey(Schema.String), updatedAt: Schema.String, }); From b14ae2ad2f230029424dec9e4fc047a695e529c8 Mon Sep 17 00:00:00 2001 From: Chris Deeming Date: Mon, 3 Aug 2026 15:31:36 +0100 Subject: [PATCH 2/2] feat(desktop): show site favicons on splash rows and browser tabs Recently used and Local servers rows now show the captured favicon, falling back to the generic icon when there is none. The tab strip previously resolved favicons through Google's s2 service by domain, which cannot reach a developer's machine, so every local site showed a globe. It now prefers the locally captured icon and falls back to the previous behaviour, which also means no request is made to Google for sites already captured. --- apps/desktop/src/preview/Manager.test.ts | 623 +++++++----------- apps/desktop/src/preview/Manager.ts | 165 +++-- .../src/browser/browserTargetResolver.test.ts | 22 + apps/web/src/browser/browserTargetResolver.ts | 52 +- apps/web/src/components/ChatView.tsx | 2 + .../src/components/RightPanelTabs.test.tsx | 116 ++++ apps/web/src/components/RightPanelTabs.tsx | 49 +- .../preview/PreviewEmptyState.test.tsx | 11 +- .../components/preview/PreviewEmptyState.tsx | 6 +- .../preview/PreviewFaviconIcon.test.tsx | 37 ++ .../components/preview/PreviewFaviconIcon.tsx | 29 + .../preview/PreviewLocalServerCard.tsx | 9 +- .../preview/PreviewRecentUrlCard.tsx | 8 +- .../src/components/preview/PreviewView.tsx | 1 + apps/web/src/lib/favicon.test.ts | 27 + apps/web/src/lib/favicon.ts | 3 + packages/contracts/src/ipc.ts | 1 - 17 files changed, 661 insertions(+), 500 deletions(-) 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/lib/favicon.test.ts diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index b1f725fa312..3fd4a3222ca 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -60,7 +60,6 @@ describe("isPreviewRefreshShortcut", () => { const { browserWindowConstructor, - createFromBuffer, createFromPath, fromId, getFocusedWebContents, @@ -71,25 +70,6 @@ const { writeImage, } = vi.hoisted(() => ({ browserWindowConstructor: vi.fn(), - createFromBuffer: vi.fn( - ( - buffer: Buffer, - ): { - readonly getSize: () => { readonly width: number; readonly height: number }; - readonly isEmpty: () => boolean; - readonly toDataURL: () => string; - readonly resize: (size: { width?: number; height?: number }) => { - readonly toDataURL: () => string; - }; - } => ({ - getSize: () => ({ width: 16, height: 16 }), - isEmpty: () => false, - toDataURL: () => `data:image/png;base64,${buffer.toString("base64")}`, - resize: () => ({ - toDataURL: () => `data:image/png;base64,${buffer.toString("base64")}`, - }), - }), - ), createFromPath: vi.fn((): { readonly isEmpty: () => boolean } => ({ isEmpty: () => false })), fromId: vi.fn((_id?: number) => null), getFocusedWebContents: vi.fn(() => null), @@ -106,7 +86,6 @@ vi.mock("electron", () => ({ writeImage, }, nativeImage: { - createFromBuffer, createFromPath, }, shell: { @@ -219,6 +198,7 @@ const makeFaviconWebContents = (options: { const { id = 42, url, title, fetch } = options; let currentUrl = url; let loading = options.loading ?? false; + let destroyed = false; const listeners = new Map void>(); const reload = vi.fn(); const reloadIgnoringCache = vi.fn(); @@ -232,12 +212,16 @@ const makeFaviconWebContents = (options: { const executeJavaScriptInIsolatedWorld = vi.fn( async (_worldId: number, scripts: ReadonlyArray<{ code: string }>) => { const result = options.rasterizedFavicon; - return typeof result === "function" ? result(scripts[0]?.code ?? "") : (result ?? null); + const code = scripts[0]?.code ?? ""; + if (typeof result === "function") return result(code); + if (result !== undefined) return result; + const payload = /atob\("([^"]+)"\)/u.exec(code)?.[1]; + return payload ? `data:image/png;base64,${payload}` : null; }, ); const webContents = { id, - isDestroyed: () => false, + isDestroyed: () => destroyed, getType: () => "webview", getURL: () => currentUrl, getTitle: () => title, @@ -280,12 +264,12 @@ const makeFaviconWebContents = (options: { setLoading: (nextLoading: boolean) => { loading = nextLoading; }, + setDestroyed: (nextDestroyed: boolean) => { + destroyed = nextDestroyed; + }, }; }; -// Lets pending microtasks (fetch resolution, favicon publication) drain -// before an assertion runs. `extra` gives incorrect-publication paths a few -// more ticks to surface before we assert on their absence. const settle = function* (until: () => boolean, extra = 5) { for (let i = 0; i < 20 && !until(); i++) { yield* Effect.promise(() => Promise.resolve()); @@ -486,6 +470,42 @@ describe("PreviewManager", () => { ), ); + effectIt.effect("requeues navigation when the registered webview was destroyed", () => + withManager((manager) => + Effect.gen(function* () { + const previous = makeFaviconWebContents({ + url: "http://localhost:3200/current", + title: "Previous webview", + fetch: vi.fn(), + }); + fromId.mockReturnValue(previous.webContents); + yield* manager.createTab("tab_destroyed_navigation"); + yield* manager.registerWebview("tab_destroyed_navigation", 42); + previous.setDestroyed(true); + + yield* manager.navigate("tab_destroyed_navigation", "http://localhost:3200/next"); + + expect(yield* manager.automationStatus("tab_destroyed_navigation")).toMatchObject({ + available: false, + url: "http://localhost:3200/next", + loading: true, + }); + + const replacement = makeFaviconWebContents({ + id: 43, + url: "about:blank", + title: "Replacement webview", + fetch: vi.fn(), + }); + fromId.mockReturnValue(replacement.webContents); + yield* manager.registerWebview("tab_destroyed_navigation", 43); + yield* settle(() => replacement.loadURL.mock.calls.length === 1, 0); + + expect(replacement.loadURL).toHaveBeenCalledWith("http://localhost:3200/next"); + }), + ), + ); + effectIt.effect("does not hold the tab lifecycle lock while a page load is pending", () => withManager((manager) => Effect.gen(function* () { @@ -541,6 +561,105 @@ describe("PreviewManager", () => { ), ); + effectIt.effect("does not fail a navigation superseded by a newer load", () => + withManager((manager) => + Effect.gen(function* () { + let rejectFirstLoad!: (cause: Error) => void; + let loadCount = 0; + const { webContents, loadURL } = makeFaviconWebContents({ + url: "http://localhost:3203/current", + title: "Superseded navigation", + fetch: vi.fn(), + loadURL: () => { + loadCount += 1; + return loadCount === 1 + ? new Promise((_resolve, reject) => { + rejectFirstLoad = reject; + }) + : Promise.resolve(); + }, + }); + fromId.mockReturnValue(webContents); + yield* manager.createTab("tab_superseded_navigation"); + yield* manager.registerWebview("tab_superseded_navigation", 42); + + const first = yield* manager + .navigate("tab_superseded_navigation", "http://localhost:3203/first") + .pipe(Effect.forkChild({ startImmediately: true })); + yield* settle(() => loadURL.mock.calls.length === 1, 0); + yield* manager.navigate("tab_superseded_navigation", "http://localhost:3203/second"); + rejectFirstLoad(new Error("ERR_ABORTED (-3) loading the superseded URL")); + + yield* Fiber.join(first); + expect(loadURL).toHaveBeenCalledTimes(2); + }), + ), + ); + + effectIt.effect("still reports non-abort navigation failures", () => + withManager((manager) => + Effect.gen(function* () { + const { webContents } = makeFaviconWebContents({ + url: "http://localhost:3204/current", + title: "Failed navigation", + fetch: vi.fn(), + loadURL: () => Promise.reject(new Error("ERR_CONNECTION_REFUSED")), + }); + fromId.mockReturnValue(webContents); + yield* manager.createTab("tab_failed_navigation"); + yield* manager.registerWebview("tab_failed_navigation", 42); + + const exit = yield* Effect.exit( + manager.navigate("tab_failed_navigation", "http://localhost:3204/failed"), + ); + + expect(Exit.isFailure(exit)).toBe(true); + }), + ), + ); + + effectIt.effect("starts navigation before a queued toolbar action can supersede it", () => + withManager((manager) => + Effect.gen(function* () { + const targetUrl = "http://localhost:3203/next"; + const { webContents, loadURL, reload } = makeFaviconWebContents({ + url: "http://localhost:3203/current", + title: "Queued toolbar action", + fetch: vi.fn(), + }); + fromId.mockReturnValue(webContents); + yield* manager.createTab("tab_queued_toolbar_action"); + yield* manager.registerWebview("tab_queued_toolbar_action", 42); + + const loadingPublished = yield* Deferred.make(); + yield* manager.subscribeStateChanges((tabId, state) => { + if ( + tabId !== "tab_queued_toolbar_action" || + state.navStatus.kind !== "Loading" || + state.navStatus.url !== targetUrl + ) { + return Effect.void; + } + return Deferred.succeed(loadingPublished, undefined).pipe(Effect.asVoid); + }); + const toolbarAction = yield* Deferred.await(loadingPublished).pipe( + Effect.andThen(manager.refresh("tab_queued_toolbar_action")), + Effect.forkChild({ startImmediately: true }), + ); + + yield* manager.navigate("tab_queued_toolbar_action", targetUrl); + yield* Fiber.join(toolbarAction); + + expect(loadURL).toHaveBeenCalledOnce(); + expect(loadURL).toHaveBeenCalledWith(targetUrl); + expect(reload).toHaveBeenCalledOnce(); + expect(loadURL.mock.invocationCallOrder[0]).toBeLessThan( + reload.mock.invocationCallOrder[0]!, + ); + }), + ), + ); + effectIt.effect("stops a pending load without replacing the current favicon", () => withManager((manager) => Effect.gen(function* () { @@ -1058,63 +1177,7 @@ describe("PreviewManager", () => { ), ); - effectIt.effect("decodes headerless and generic binary favicon responses", () => - withManager((manager) => - Effect.gen(function* () { - const origin = "http://localhost:5744"; - const headerlessUrl = `${origin}/headerless.ico`; - const genericUrl = `${origin}/generic.ico`; - const unsupportedUrl = `${origin}/not-an-image`; - const fetch = vi.fn(async (url: string) => { - const bytes = Buffer.from(url); - const contentType = - url === headerlessUrl - ? null - : url === genericUrl - ? "application/octet-stream" - : "text/html"; - return { - ok: true, - arrayBuffer: async () => - bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength), - headers: { get: (name: string) => (name === "content-type" ? contentType : null) }, - }; - }); - const { webContents, listeners } = makeFaviconWebContents({ - url: `${origin}/`, - title: "Generic favicon responses", - fetch, - }); - fromId.mockReturnValue(webContents); - const states: PreviewManager.PreviewTabState[] = []; - yield* manager.subscribeStateChanges((_tabId, state) => - Effect.sync(() => { - states.push(state); - }), - ); - yield* manager.createTab("tab_favicon_generic_mime"); - yield* manager.registerWebview("tab_favicon_generic_mime", 42); - - const faviconUpdated = listeners.get("page-favicon-updated"); - faviconUpdated?.({}, [headerlessUrl]); - const headerlessFavicon = `data:image/png;base64,${Buffer.from(headerlessUrl).toString("base64")}`; - yield* settle(() => states.at(-1)?.favicon === headerlessFavicon); - - faviconUpdated?.({}, [genericUrl]); - const genericFavicon = `data:image/png;base64,${Buffer.from(genericUrl).toString("base64")}`; - yield* settle(() => states.at(-1)?.favicon === genericFavicon); - - const decodeCount = createFromBuffer.mock.calls.length; - faviconUpdated?.({}, [unsupportedUrl]); - yield* settle(() => false); - - expect(createFromBuffer).toHaveBeenCalledTimes(decodeCount); - expect(states.at(-1)?.favicon).toBe(genericFavicon); - }), - ), - ); - - effectIt.effect("publishes a loading-time favicon after a later candidate fails", () => + effectIt.effect("recaptures a discarded loading-time favicon after a newer event fails", () => withManager((manager) => Effect.gen(function* () { const origin = "http://localhost:5738"; @@ -1154,16 +1217,21 @@ describe("PreviewManager", () => { expect(states.at(-1)?.navStatus.kind).toBe("Loading"); expect(states.at(-1)?.favicon).toBeUndefined(); listeners.get("page-favicon-updated")?.({}, [faviconUrl]); - yield* settle(() => false); - expect(fetch).toHaveBeenCalledOnce(); - listeners.get("page-favicon-updated")?.({}, [failedUrl]); yield* settle(() => fetch.mock.calls.length === 2); + listeners.get("page-favicon-updated")?.({}, [failedUrl]); + yield* settle(() => fetch.mock.calls.length === 3); setLoading(false); listeners.get("did-stop-loading")?.(); - yield* settle(() => states.at(-1)?.favicon !== undefined); + yield* settle(() => states.at(-1)?.navStatus.kind === "Success"); expect(states.at(-1)?.navStatus.kind).toBe("Success"); + expect(states.at(-1)?.favicon).toBeUndefined(); + + listeners.get("page-favicon-updated")?.({}, [faviconUrl]); + yield* settle(() => states.at(-1)?.favicon !== undefined); + + expect(fetch).toHaveBeenCalledTimes(4); expect(states.at(-1)?.favicon).toBe(`data:image/png;base64,${bytes.toString("base64")}`); }), ), @@ -1238,119 +1306,17 @@ describe("PreviewManager", () => { ), ); - effectIt.effect("normalizes animated favicon formats to a static PNG", () => - withManager((manager) => - Effect.gen(function* () { - const bytes = Buffer.from("animated-favicon"); - const fetch = vi.fn(async () => ({ - ok: true, - arrayBuffer: async () => - bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength), - headers: { get: (name: string) => (name === "content-type" ? "image/gif" : null) }, - })); - const { webContents, listeners } = makeFaviconWebContents({ - url: "http://localhost:5742/", - title: "Animated favicon", - fetch, - }); - fromId.mockReturnValue(webContents); - const states: PreviewManager.PreviewTabState[] = []; - yield* manager.subscribeStateChanges((_tabId, state) => - Effect.sync(() => { - states.push(state); - }), - ); - yield* manager.createTab("tab_favicon_animated"); - yield* manager.registerWebview("tab_favicon_animated", 42); - - listeners.get("page-favicon-updated")?.({}, ["http://localhost:5742/favicon.gif"]); - yield* settle(() => states.at(-1)?.favicon !== undefined); - - expect(states.at(-1)?.favicon).toBe(`data:image/png;base64,${bytes.toString("base64")}`); - }), - ), - ); - - effectIt.effect("preserves aspect ratio while bounding bitmap favicons", () => - withManager((manager) => - Effect.gen(function* () { - const landscapeResize = vi.fn(() => ({ - toDataURL: () => "data:image/png;base64,LANDSCAPE", - })); - const portraitResize = vi.fn(() => ({ - toDataURL: () => "data:image/png;base64,PORTRAIT", - })); - const smallResize = vi.fn(() => ({ - toDataURL: () => "data:image/png;base64,RESIZED_SMALL", - })); - createFromBuffer - .mockReturnValueOnce({ - getSize: () => ({ width: 64, height: 16 }), - isEmpty: () => false, - toDataURL: () => "data:image/png;base64,UNRESIZED_LANDSCAPE", - resize: landscapeResize, - }) - .mockReturnValueOnce({ - getSize: () => ({ width: 16, height: 64 }), - isEmpty: () => false, - toDataURL: () => "data:image/png;base64,UNRESIZED_PORTRAIT", - resize: portraitResize, - }) - .mockReturnValueOnce({ - getSize: () => ({ width: 24, height: 12 }), - isEmpty: () => false, - toDataURL: () => "data:image/png;base64,SMALL", - resize: smallResize, - }); - const origin = "http://localhost:5743"; - const fetch = vi.fn(async (url: string) => { - const bytes = Buffer.from(url); - return { - ok: true, - arrayBuffer: async () => - bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength), - headers: { get: (name: string) => (name === "content-type" ? "image/png" : null) }, - }; - }); - const { webContents, listeners } = makeFaviconWebContents({ - url: `${origin}/`, - title: "Non-square favicon", - fetch, - }); - fromId.mockReturnValue(webContents); - const states: PreviewManager.PreviewTabState[] = []; - yield* manager.subscribeStateChanges((_tabId, state) => - Effect.sync(() => { - states.push(state); - }), - ); - yield* manager.createTab("tab_favicon_aspect_ratio"); - yield* manager.registerWebview("tab_favicon_aspect_ratio", 42); - - const faviconUpdated = listeners.get("page-favicon-updated"); - faviconUpdated?.({}, [`${origin}/landscape.png`]); - yield* settle(() => states.at(-1)?.favicon === "data:image/png;base64,LANDSCAPE"); - faviconUpdated?.({}, [`${origin}/portrait.png`]); - yield* settle(() => states.at(-1)?.favicon === "data:image/png;base64,PORTRAIT"); - faviconUpdated?.({}, [`${origin}/small.png`]); - yield* settle(() => states.at(-1)?.favicon === "data:image/png;base64,SMALL"); - - expect(landscapeResize).toHaveBeenCalledWith({ width: 32 }); - expect(portraitResize).toHaveBeenCalledWith({ height: 32 }); - expect(smallResize).not.toHaveBeenCalled(); - }), - ), - ); - effectIt.effect("bounds favicon candidates and URL length before fetching", () => withManager((manager) => Effect.gen(function* () { const fetch = vi.fn(async () => ({ ok: false })); - const { webContents, listeners } = makeFaviconWebContents({ - url: "http://localhost:5740/", - title: "Bounded favicons", - fetch, - }); + const { webContents, listeners, executeJavaScriptInIsolatedWorld } = makeFaviconWebContents( + { + url: "http://localhost:5740/", + title: "Bounded favicons", + fetch, + }, + ); fromId.mockReturnValue(webContents); yield* manager.createTab("tab_favicon_bounds"); yield* manager.registerWebview("tab_favicon_bounds", 42); @@ -1359,12 +1325,16 @@ describe("PreviewManager", () => { { length: 10 }, (_, index) => `http://localhost:5740/favicon-${index}.png`, ); - listeners.get("page-favicon-updated")?.({}, candidates); + const unsupported = Array.from( + { length: 10 }, + (_, index) => `file:///favicon-${index}.png`, + ); + listeners.get("page-favicon-updated")?.({}, [...unsupported, ...candidates]); yield* settle(() => fetch.mock.calls.length === 8); listeners.get("page-favicon-updated")?.({}, [ `http://localhost:5740/${"x".repeat(2_100)}.png`, ]); - const decodesBeforeOversizedInline = createFromBuffer.mock.calls.length; + const decodesBeforeOversizedInline = executeJavaScriptInIsolatedWorld.mock.calls.length; listeners.get("page-favicon-updated")?.({}, [ `data:image/png;base64,${"A".repeat(140_000)}`, ]); @@ -1372,7 +1342,9 @@ describe("PreviewManager", () => { expect(fetch).toHaveBeenCalledTimes(8); expect(fetch).not.toHaveBeenCalledWith(candidates[8], expect.anything()); - expect(createFromBuffer).toHaveBeenCalledTimes(decodesBeforeOversizedInline); + expect(executeJavaScriptInIsolatedWorld).toHaveBeenCalledTimes( + decodesBeforeOversizedInline, + ); }), ), ); @@ -1999,69 +1971,6 @@ describe("PreviewManager", () => { ), ); - effectIt.effect("recaptures a favicon after an A to B to A revisit", () => - withManager((manager) => - Effect.gen(function* () { - const siteA = "http://localhost:5750"; - const siteB = "http://localhost:5751"; - const fetch = vi.fn(async (faviconUrl: string) => { - const bytes = Buffer.from(`favicon-bytes-for-${faviconUrl}`); - return { - ok: true, - arrayBuffer: async () => - bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength), - headers: { get: (name: string) => (name === "content-type" ? "image/png" : null) }, - }; - }); - const { webContents, listeners, setUrl } = makeFaviconWebContents({ - url: `${siteA}/`, - title: "Site A", - fetch, - }); - fromId.mockReturnValue(webContents); - const states: PreviewManager.PreviewTabState[] = []; - - yield* manager.subscribeStateChanges((_tabId, state) => - Effect.sync(() => { - states.push(state); - }), - ); - yield* manager.createTab("tab_favicon_revisit"); - yield* manager.registerWebview("tab_favicon_revisit", 42); - - const faviconUpdated = listeners.get("page-favicon-updated"); - faviconUpdated?.({}, [`${siteA}/favicon.png`]); - yield* settle(() => states.at(-1)?.faviconOrigin === siteA); - - setUrl(`${siteB}/`); - listeners.get("did-navigate")?.(); - yield* settle(() => { - const navStatus = states.at(-1)?.navStatus; - return navStatus?.kind === "Success" && navStatus.url === `${siteB}/`; - }, 0); - faviconUpdated?.({}, [`${siteB}/favicon.png`]); - yield* settle(() => states.at(-1)?.faviconOrigin === siteB); - - setUrl(`${siteA}/`); - listeners.get("did-navigate")?.(); - yield* settle(() => { - const navStatus = states.at(-1)?.navStatus; - return navStatus?.kind === "Success" && navStatus.url === `${siteA}/`; - }, 0); - faviconUpdated?.({}, [`${siteA}/favicon.png`]); - yield* settle( - () => fetch.mock.calls.length === 3 && states.at(-1)?.faviconOrigin === siteA, - ); - - faviconUpdated?.({}, [`${siteA}/favicon.png`]); - yield* settle(() => fetch.mock.calls.length > 3); - - expect(fetch).toHaveBeenCalledTimes(3); - expect(states.at(-1)?.faviconOrigin).toBe(siteA); - }), - ), - ); - effectIt.effect("ignores a favicon captured after its webview is replaced", () => withManager((manager) => Effect.gen(function* () { @@ -2220,12 +2129,6 @@ describe("PreviewManager", () => { effectIt.effect("does not publish or dedupe an undecodable favicon buffer", () => withManager((manager) => Effect.gen(function* () { - createFromBuffer.mockReturnValueOnce({ - getSize: () => ({ width: 0, height: 0 }), - isEmpty: () => true, - toDataURL: () => "data:image/png;base64,", - resize: () => ({ toDataURL: () => "data:image/png;base64," }), - }); const url = "http://localhost:5736/"; const corrupt = Buffer.from("corrupt-image-data"); const fetch = vi.fn(async () => ({ @@ -2234,10 +2137,12 @@ describe("PreviewManager", () => { corrupt.buffer.slice(corrupt.byteOffset, corrupt.byteOffset + corrupt.byteLength), headers: { get: (name: string) => (name === "content-type" ? "image/png" : null) }, })); + let rasterizations = 0; const { webContents, listeners } = makeFaviconWebContents({ url, title: "localhost:5736", fetch, + rasterizedFavicon: () => (++rasterizations === 1 ? null : "data:image/png;base64,VALID"), }); fromId.mockReturnValue(webContents); const states: PreviewManager.PreviewTabState[] = []; @@ -2257,12 +2162,6 @@ describe("PreviewManager", () => { expect(fetch).toHaveBeenCalledOnce(); expect(states.at(-1)?.favicon).toBeUndefined(); - createFromBuffer.mockReturnValueOnce({ - getSize: () => ({ width: 16, height: 16 }), - isEmpty: () => false, - toDataURL: () => "data:image/png;base64,VALID", - resize: () => ({ toDataURL: () => "data:image/png;base64,VALID" }), - }); const validBuffer = Buffer.from("valid-image-data--"); fetch.mockImplementation(async () => ({ ok: true, @@ -2283,96 +2182,32 @@ describe("PreviewManager", () => { ), ); - effectIt.effect("validates opaque favicon responses before publishing them", () => + effectIt.effect("decodes favicon buffers outside the main process", () => withManager((manager) => Effect.gen(function* () { - createFromBuffer - .mockReturnValueOnce({ - getSize: () => ({ width: 0, height: 0 }), - isEmpty: () => true, - toDataURL: () => "data:image/png;base64,", - resize: () => ({ toDataURL: () => "data:image/png;base64," }), - }) - .mockReturnValueOnce({ - getSize: () => ({ width: 0, height: 0 }), - isEmpty: () => true, - toDataURL: () => "data:image/png;base64,", - resize: () => ({ toDataURL: () => "data:image/png;base64," }), - }) - .mockReturnValueOnce({ - getSize: () => ({ width: 0, height: 0 }), - isEmpty: () => true, - toDataURL: () => "data:image/png;base64,", - resize: () => ({ toDataURL: () => "data:image/png;base64," }), - }) - .mockReturnValueOnce({ - getSize: () => ({ width: 64, height: 64 }), - isEmpty: () => false, - toDataURL: () => `data:image/png;base64,${"A".repeat(8_192)}`, - resize: () => ({ toDataURL: () => "data:image/png;base64,RESIZED" }), - }); const origin = "http://localhost:5761"; - const svgUrl = `${origin}/broken.svg`; - const icoUrl = `${origin}/broken.ico`; - const validIcoUrl = `${origin}/valid.ico`; - const validSvgUrl = `${origin}/valid.svg`; - const undecodableSvgUrl = `${origin}/undecodable.svg`; - const oversizedSvgUrl = `${origin}/oversized.svg`; - const mislabeledIcoUrl = `${origin}/mislabeled.ico`; - const icoPayload = Buffer.from( - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", - "base64", - ); - const validIco = Buffer.alloc(22 + icoPayload.byteLength); - validIco.writeUInt16LE(1, 2); - validIco.writeUInt16LE(1, 4); - validIco[6] = 1; - validIco[7] = 1; - validIco.writeUInt16LE(1, 10); - validIco.writeUInt16LE(32, 12); - validIco.writeUInt32LE(icoPayload.byteLength, 14); - validIco.writeUInt32LE(22, 18); - icoPayload.copy(validIco, 22); - const validSvg = Buffer.from( - '', - ); - const undecodableSvg = Buffer.from('${"x".repeat(7_000)}`, - ); + const faviconUrl = `${origin}/favicon.png`; + const bytes = Buffer.from("compressed-favicon"); + const oversizedUrl = `${origin}/oversized.png`; + const oversized = Buffer.alloc(24); + Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(oversized); + oversized.writeUInt32BE(16_384, 16); + oversized.writeUInt32BE(16_384, 20); const fetch = vi.fn(async (url: string) => { - const [mime, bytes] = - url === svgUrl - ? (["image/svg+xml", Buffer.from(" - bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength), - headers: { get: (name: string) => (name === "content-type" ? mime : null) }, + source.buffer.slice(source.byteOffset, source.byteOffset + source.byteLength), + headers: { get: (name: string) => (name === "content-type" ? "image/png" : null) }, }; }); const { webContents, listeners, executeJavaScriptInIsolatedWorld } = makeFaviconWebContents( { url: `${origin}/`, - title: "localhost:5761", + title: "Safe favicon decoding", fetch, - rasterizedFavicon: (code) => - [validIco, validSvg, oversizedSvg].some((bytes) => - code.includes(bytes.toString("base64")), - ) - ? "data:image/png;base64,RASTERIZED" - : null, + rasterizedFavicon: "data:image/png;base64,RASTERIZED", }, ); fromId.mockReturnValue(webContents); @@ -2382,44 +2217,70 @@ describe("PreviewManager", () => { states.push(state); }), ); - yield* manager.createTab("tab_favicon_malformed_opaque"); - yield* manager.registerWebview("tab_favicon_malformed_opaque", 42); + yield* manager.createTab("tab_safe_favicon_decode"); + yield* manager.registerWebview("tab_safe_favicon_decode", 42); - const faviconUpdated = listeners.get("page-favicon-updated"); - faviconUpdated?.({}, [undecodableSvgUrl]); - yield* settle(() => executeJavaScriptInIsolatedWorld.mock.calls.length === 1); - expect(states.at(-1)?.favicon).toBeUndefined(); + listeners.get("page-favicon-updated")?.({}, [oversizedUrl]); + yield* settle(() => fetch.mock.calls.length === 1); + expect(executeJavaScriptInIsolatedWorld).not.toHaveBeenCalled(); - faviconUpdated?.({}, [svgUrl, icoUrl, validIcoUrl]); + listeners.get("page-favicon-updated")?.({}, [faviconUrl]); yield* settle(() => states.at(-1)?.favicon !== undefined); - expect(fetch).toHaveBeenCalledTimes(4); - expect(states.at(-1)?.favicon).toBe("data:image/png;base64,RASTERIZED"); - expect(executeJavaScriptInIsolatedWorld).toHaveBeenNthCalledWith(4, 1001, [ - { code: expect.stringContaining(validIco.toString("base64")) }, + expect(executeJavaScriptInIsolatedWorld).toHaveBeenCalledWith(1001, [ + { + code: expect.stringMatching( + new RegExp(`${bytes.toString("base64")}.*resizeWidth: 32.*resizeHeight: 32`, "s"), + ), + }, ]); - - faviconUpdated?.({}, [validSvgUrl]); - yield* settle(() => fetch.mock.calls.length === 5); - expect(states.at(-1)?.favicon).toBe("data:image/png;base64,RASTERIZED"); - expect(executeJavaScriptInIsolatedWorld).toHaveBeenNthCalledWith(5, 1001, [ - { code: expect.stringContaining(validSvg.toString("base64")) }, - ]); - - faviconUpdated?.({}, [mislabeledIcoUrl]); - yield* settle(() => fetch.mock.calls.length === 6); - - expect(states.at(-1)?.favicon).toBe("data:image/png;base64,RESIZED"); + }), + ), + ); - faviconUpdated?.({}, [oversizedSvgUrl]); - yield* settle(() => fetch.mock.calls.length === 7); + effectIt.effect("allows favicon capture after a rasterization timeout", () => + withManager((manager) => + Effect.gen(function* () { + const timeoutControllers: AbortController[] = []; + const timeout = vi.spyOn(AbortSignal, "timeout").mockImplementation(() => { + const controller = new AbortController(); + timeoutControllers.push(controller); + return controller.signal; + }); + const { webContents, listeners, executeJavaScriptInIsolatedWorld } = makeFaviconWebContents( + { + url: "http://localhost:5766/", + title: "Raster timeout", + fetch: vi.fn(), + }, + ); + executeJavaScriptInIsolatedWorld + .mockImplementationOnce(() => new Promise(() => undefined)) + .mockResolvedValueOnce("data:image/png;base64,RECOVERED"); + fromId.mockReturnValue(webContents); + const states: PreviewManager.PreviewTabState[] = []; - expect(fetch).toHaveBeenCalledTimes(7); - expect(states.at(-1)?.favicon).toBe("data:image/png;base64,RASTERIZED"); - expect(executeJavaScriptInIsolatedWorld).toHaveBeenNthCalledWith(7, 1001, [ - { code: expect.stringContaining(oversizedSvg.toString("base64")) }, - ]); + try { + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + states.push(state); + }), + ); + yield* manager.createTab("tab_raster_timeout"); + yield* manager.registerWebview("tab_raster_timeout", 42); + + listeners.get("page-favicon-updated")?.({}, ["data:image/png;base64,RklSU1Q="]); + yield* settle(() => executeJavaScriptInIsolatedWorld.mock.calls.length === 1); + timeoutControllers.at(-1)?.abort(); + listeners.get("page-favicon-updated")?.({}, ["data:image/png;base64,U0VDT05E"]); + yield* settle(() => states.at(-1)?.favicon !== undefined); + + expect(executeJavaScriptInIsolatedWorld).toHaveBeenCalledTimes(2); + expect(states.at(-1)?.favicon).toBe("data:image/png;base64,RECOVERED"); + } finally { + timeout.mockRestore(); + } }), ), ); diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 5d303ec3cde..938e11fade9 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -5,8 +5,6 @@ * elements live in the renderer; we only attach listeners and forward state * here). Single layer-scoped browser session partition. */ -import * as NodeCrypto from "node:crypto"; - import type { DesktopPreviewAnnotationTheme, DesktopPreviewColorScheme, @@ -421,6 +419,13 @@ export const isPreviewRefreshShortcut = (input: Electron.Input): boolean => !input.shift && !input.alt; +const isAbortedNavigationCause = (cause: unknown): boolean => { + if (cause instanceof Error && cause.message.includes("ERR_ABORTED")) return true; + if (typeof cause !== "object" || cause === null) return false; + const error = cause as { readonly code?: unknown; readonly errno?: unknown }; + return error.code === "ERR_ABORTED" || error.code === -3 || error.errno === -3; +}; + const isPreviewInputSignal = (value: unknown): value is PreviewInputSignal => { if (typeof value !== "object" || value === null || !("kind" in value)) return false; if (value.kind === "pointer") { @@ -460,12 +465,14 @@ const inputSignalsMatch = (left: PreviewInputSignal, right: PreviewInputSignal): }; const MAX_FAVICON_RESPONSE_BYTES = 100_000; +const MAX_FAVICON_SOURCE_PIXELS = 1_048_576; const MAX_FAVICON_CANDIDATES = 8; const MAX_FAVICON_HTTP_URL_LENGTH = 2_048; 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]); const activeFaviconRasterizations = new WeakMap>(); type FaviconCaptureRequest = { @@ -596,29 +603,39 @@ function parseInlineFavicon( } function faviconCaptureKey(parts: ReadonlyArray): string { - return NodeCrypto.createHash("sha256").update(JSON.stringify(parts)).digest("base64url"); + return JSON.stringify(parts); } -function rasterizeOpaqueFavicon( +function hasSafePngDimensions(buffer: Buffer): boolean { + if (!buffer.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)) return true; + if (buffer.byteLength < 24) return false; + const width = buffer.readUInt32BE(16); + const height = buffer.readUInt32BE(20); + return width > 0 && height > 0 && width * height <= MAX_FAVICON_SOURCE_PIXELS; +} + +function rasterizeFavicon( wc: Electron.WebContents, - mime: "image/svg+xml" | "image/x-icon" | "image/vnd.microsoft.icon", + mime: string | null, buffer: Buffer, ): Promise { 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: "${mime}" })); + const bitmap = await createImageBitmap(new Blob([source], { type: "${blobType}" }), { + resizeWidth: 32, + resizeHeight: 32, + resizeQuality: "high", + }); try { - const scale = Math.min(1, 32 / Math.max(bitmap.width, bitmap.height)); - const width = Math.max(1, Math.round(bitmap.width * scale)); - const height = Math.max(1, Math.round(bitmap.height * scale)); - const canvas = new OffscreenCanvas(width, height); + const canvas = new OffscreenCanvas(bitmap.width, bitmap.height); const context = canvas.getContext("2d"); if (!context) return null; - context.drawImage(bitmap, 0, 0, width, height); + context.drawImage(bitmap, 0, 0); const output = new Uint8Array(await (await canvas.convertToBlob({ type: "image/png" })).arrayBuffer()); let binary = ""; for (const byte of output) binary += String.fromCharCode(byte); @@ -636,16 +653,18 @@ function rasterizeOpaqueFavicon( if (activeFaviconRasterizations.has(wc)) return Promise.resolve(null); const execution = wc.executeJavaScriptInIsolatedWorld(FAVICON_RASTER_WORLD_ID, [{ code }]); activeFaviconRasterizations.set(wc, execution); - void execution - .finally(() => { - if (activeFaviconRasterizations.get(wc) === execution) { - activeFaviconRasterizations.delete(wc); - } - }) - .catch(() => undefined); + const clearExecution = () => { + if (activeFaviconRasterizations.get(wc) === execution) { + activeFaviconRasterizations.delete(wc); + } + }; + void execution.finally(clearExecution).catch(() => undefined); return new Promise((resolve, reject) => { const timeout = AbortSignal.timeout(FAVICON_RASTER_TIMEOUT_MS); - const onTimeout = () => resolve(null); + const onTimeout = () => { + clearExecution(); + resolve(null); + }; timeout.addEventListener("abort", onTimeout, { once: true }); void execution.then( (result) => { @@ -678,31 +697,13 @@ async function normalizeFaviconBuffer( ) { return null; } - const opaqueMime = - normalizedMime === "image/svg+xml" || - normalizedMime === "image/x-icon" || - normalizedMime === "image/vnd.microsoft.icon" - ? normalizedMime - : null; - if (opaqueMime !== null) { - const rasterized = await rasterizeOpaqueFavicon(wc, opaqueMime, buffer); - if ( - typeof rasterized === "string" && - rasterized.startsWith("data:image/png;base64,") && - rasterized.length <= FAVICON_DATA_URL_MAX_LENGTH - ) { - return rasterized; - } - } - const decoded = nativeImage.createFromBuffer(buffer); - if (decoded.isEmpty()) return null; - const { width, height } = decoded.getSize(); - if (width < 1 || height < 1) return null; - const normalized = - Math.max(width, height) <= 32 - ? decoded.toDataURL() - : decoded.resize(width >= height ? { width: 32 } : { height: 32 }).toDataURL(); - return normalized.length <= FAVICON_DATA_URL_MAX_LENGTH ? normalized : null; + if (!hasSafePngDimensions(buffer)) return null; + const rasterized = await rasterizeFavicon(wc, normalizedMime, buffer); + return typeof rasterized === "string" && + rasterized.startsWith("data:image/png;base64,") && + rasterized.length <= FAVICON_DATA_URL_MAX_LENGTH + ? rasterized + : null; } const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function* ( @@ -1758,13 +1759,11 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); if (result.kind === "deferred") { pendingFaviconPublication = publication; - capturedFaviconKey = publication.faviconKey; return true; } if (result.kind === "stale") { if (pendingFaviconPublication === publication) { pendingFaviconPublication = null; - if (capturedFaviconKey === publication.faviconKey) capturedFaviconKey = null; } return false; } @@ -1847,16 +1846,15 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const faviconUpdated = (_event: Event, favicons: ReadonlyArray): void => { const origin = safeOrigin(wc.getURL()); if (!origin) return; - const candidates = [ - ...new Set(favicons.slice(0, MAX_FAVICON_CANDIDATES).filter(isSupportedFaviconUrl)), - ]; + const candidates = [...new Set(favicons.filter(isSupportedFaviconUrl))].slice( + 0, + MAX_FAVICON_CANDIDATES, + ); if (candidates.length === 0) return; const eventKey = faviconCaptureKey([origin, ...candidates]); if (activeFaviconEventKey === eventKey) return; const generation = ++faviconGeneration; - if (pendingFaviconPublication?.origin === origin) { - pendingFaviconPublication = { ...pendingFaviconPublication, generation }; - } + pendingFaviconPublication = null; activeFaviconAbortController?.abort(); queuedFaviconAbortController?.abort(); const request: FaviconCaptureRequest = { @@ -2238,9 +2236,12 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function latestNavStatus.url === pendingUrl && wc.getURL() !== pendingUrl ) { - const launchToken = Symbol(); - navigationLaunchTokens.set(tabId, launchToken); - return { launchToken, url: pendingUrl, wc }; + navigationLaunchTokens.delete(tabId); + const load = yield* attempt( + { operation: "registerWebview.loadPendingUrl", tabId, webContentsId }, + () => wc.loadURL(pendingUrl), + ); + return { load, wc }; } }); @@ -2254,21 +2255,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function registerWebviewUnlocked(tabId, webContentsId, expectedGeneration), ); if (!action) return; - const load = yield* attempt( - { operation: "registerWebview.loadPendingUrl", tabId, webContentsId }, - () => { - if (navigationLaunchTokens.get(tabId) !== action.launchToken || action.wc.isDestroyed()) { - return null; - } - navigationLaunchTokens.delete(tabId); - return action.wc.loadURL(action.url); - }, - ); - if (!load) return; runFork( attemptPromise( { operation: "registerWebview.loadPendingUrl", tabId, webContentsId }, - () => load, + () => action.load, ).pipe(Effect.ignore), ); }); @@ -2316,7 +2306,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function yield* emit(tabId, pending); if (pending.webContentsId == null) return; const wc = webContents.fromId(pending.webContentsId); - if (!wc) { + if (!wc || wc.isDestroyed()) { if (navigationLaunchTokens.get(tabId) === launchToken) { navigationLaunchTokens.delete(tabId); } @@ -2336,37 +2326,32 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function yield* emit(tabId, detached); return; } + if (navigationLaunchTokens.get(tabId) !== launchToken) return; + navigationLaunchTokens.delete(tabId); if (wc.getURL() === url) { - return { kind: "reload" as const, launchToken, wc }; + yield* attempt({ operation: "navigate.reload", tabId, webContentsId: wc.id }, () => + wc.reload(), + ); + return; } - return { kind: "load" as const, launchToken, url, wc }; + const load = yield* attempt( + { operation: "navigate.loadURL", tabId, webContentsId: wc.id }, + () => wc.loadURL(url), + ); + return { load, wc }; }); const navigate = Effect.fn("PreviewManager.navigate")(function* (tabId: string, rawUrl: string) { const action = yield* withTabLifecycleLock(tabId, navigateUnlocked(tabId, rawUrl)); if (!action) return; - const load = yield* attempt( - { - operation: action.kind === "load" ? "navigate.loadURL" : "navigate.reload", - tabId, - webContentsId: action.wc.id, - }, - () => { - if (navigationLaunchTokens.get(tabId) !== action.launchToken || action.wc.isDestroyed()) { - return null; - } - navigationLaunchTokens.delete(tabId); - if (action.kind === "reload") { - action.wc.reload(); - return null; - } - return action.wc.loadURL(action.url); - }, - ); - if (!load) return; yield* attemptPromise( { operation: "navigate.loadURL", tabId, webContentsId: action.wc.id }, - () => load, + () => action.load, + ).pipe( + Effect.catchIf( + (error) => isAbortedNavigationCause(error.cause), + () => Effect.void, + ), ); }); diff --git a/apps/web/src/browser/browserTargetResolver.test.ts b/apps/web/src/browser/browserTargetResolver.test.ts index 6f89d86df88..323e1ea01cf 100644 --- a/apps/web/src/browser/browserTargetResolver.test.ts +++ b/apps/web/src/browser/browserTargetResolver.test.ts @@ -8,6 +8,28 @@ vi.mock("~/state/session", () => ({ readPreparedConnection })); describe("browser target resolver", () => { beforeEach(() => readPreparedConnection.mockReset()); + it.each([ + "0.0.0.0", + "devbox", + "devbox.localhost", + "::", + "::ffff:192.168.1.20", + "[::ffff:c0a8:114]", + "198.18.0.1", + "198.19.255.254", + "printer.home.arpa.", + ])("treats %s as a private network host", async (host) => { + const { isPrivateNetworkHost } = await import("./browserTargetResolver"); + expect(isPrivateNetworkHost(host)).toBe(true); + }); + + it("does not widen private address ranges beyond their boundaries", async () => { + const { isPrivateNetworkHost } = await import("./browserTargetResolver"); + expect(isPrivateNetworkHost("::ffff:808:808")).toBe(false); + expect(isPrivateNetworkHost("198.17.255.255")).toBe(false); + expect(isPrivateNetworkHost("198.20.0.0")).toBe(false); + }); + it("maps environment ports onto a private network host", async () => { readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://192.168.1.25:3773" }); const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); diff --git a/apps/web/src/browser/browserTargetResolver.ts b/apps/web/src/browser/browserTargetResolver.ts index 3c3be59b457..a80dd86aa59 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,51 @@ 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 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); + 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); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index d7bf68b2f60..93067e416ab 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -6418,6 +6418,7 @@ function ChatViewContent(props: ChatViewProps) { activeSurfaceId={activeRightPanelSurface?.id ?? null} pendingSurfaceIds={pendingFileSurfaceIds} previewSessions={activePreviewState.sessions} + desktopByTabId={activePreviewState.desktopByTabId} terminalLabelsById={activeTerminalLabelsById} onActivate={activateRightPanelSurface} onCloseSurface={closeRightPanelSurface} @@ -6447,6 +6448,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..bac453c8207 --- /dev/null +++ b/apps/web/src/components/RightPanelTabs.test.tsx @@ -0,0 +1,116 @@ +import type { PreviewSessionSnapshot } from "@t3tools/contracts"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vite-plus/test"; + +import { resolvePreviewFaviconUrl, RightPanelTabs } from "./RightPanelTabs"; + +const previewSurface = { + id: "browser:tab-1" as const, + kind: "preview" as const, + resourceId: "tab-1", +}; +const secondPreviewSurface = { + id: "browser:tab-2" as const, + kind: "preview" as const, + resourceId: "tab-2", +}; + +const previewSessions: 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: new Date().toISOString(), + }, + "tab-2": { + threadId: "thread-1", + tabId: "tab-2", + navStatus: { _tag: "Success", url: "http://24x.xf.local/admin", title: "Admin" }, + canGoBack: false, + canGoForward: false, + updatedAt: new Date().toISOString(), + }, +}; + +function desktopOverlay(favicon: string | null) { + return { + hasWebContents: true, + canGoBack: false, + canGoForward: false, + loading: false, + zoomFactor: 1, + pictureInPicture: false, + colorScheme: "system" as const, + controller: "none" as const, + favicon, + }; +} + +function renderTabs(favicon: string | null = null, secondFavicon?: string) { + return renderToStaticMarkup( + {}} + onCloseSurface={() => {}} + onCloseOtherSurfaces={() => {}} + onCloseSurfacesToRight={() => {}} + onCloseAllSurfaces={() => {}} + onCopyFilePath={() => {}} + onAddBrowser={() => {}} + onAddTerminal={() => {}} + onAddDiff={() => {}} + onAddFiles={() => {}} + onAddAgents={() => {}} + liveAgentCount={0} + browserAvailable + diffAvailable={false} + filesAvailable={false} + > +
content
+ , + ); +} + +describe("RightPanelTabs preview favicon", () => { + it("renders the live tab favicon and skips the Google s2 URL", () => { + const html = renderTabs("data:image/png;base64,AAAA"); + expect(html).toContain("data:image/png;base64,AAAA"); + expect(html).not.toContain("s2/favicons"); + expect(html).toContain("object-contain"); + }); + + it("does not send a private hostname to the Google s2 service", () => { + const html = renderTabs(); + expect(html).not.toContain("s2/favicons"); + }); + + it("keeps route-specific favicons isolated between live tabs on one origin", () => { + const html = renderTabs("data:image/png;base64,AAAA", "data:image/png;base64,BBBB"); + expect(html).toContain("data:image/png;base64,AAAA"); + expect(html).toContain("data:image/png;base64,BBBB"); + }); + + it("tries the Google URL after a captured favicon fails", () => { + expect( + resolvePreviewFaviconUrl({ + capturedUrl: "data:image/png;base64,AAAA", + googleUrl: "https://www.google.com/s2/favicons?domain=example.com", + failedCapturedUrl: "data:image/png;base64,AAAA", + failedGoogleUrl: null, + }), + ).toContain("s2/favicons"); + }); +}); diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index 0b1700e7349..de607745a4d 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -12,6 +12,7 @@ import { } 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"; @@ -33,6 +34,7 @@ interface RightPanelTabsProps { activeSurfaceId: string | null; pendingSurfaceIds: ReadonlySet; previewSessions: Readonly>; + desktopByTabId: Readonly>; terminalLabelsById: ReadonlyMap; onActivate: (surface: RightPanelSurface) => void; onCloseSurface: (surface: RightPanelSurface) => void; @@ -246,18 +248,45 @@ function surfaceTitle( } } -function PreviewFavicon({ url }: { url: string | null }) { - const faviconUrl = faviconUrlForOrigin(url, 32); - const [failedUrl, setFailedUrl] = useState(null); - if (!faviconUrl || failedUrl === faviconUrl) return ; +export function resolvePreviewFaviconUrl(input: { + capturedUrl: string | null; + googleUrl: string | null; + failedCapturedUrl: string | null; + failedGoogleUrl: string | null; +}): string | null { + if (input.capturedUrl && input.capturedUrl !== input.failedCapturedUrl) return input.capturedUrl; + if (input.googleUrl && input.googleUrl !== input.failedGoogleUrl) return input.googleUrl; + return null; +} + +function PreviewFavicon({ + capturedFaviconUrl, + url, +}: { + capturedFaviconUrl: string | null; + url: string | null; +}) { + const googleFaviconUrl = faviconUrlForOrigin(url, 32); + const [failedCapturedUrl, setFailedCapturedUrl] = useState(null); + const [failedGoogleUrl, setFailedGoogleUrl] = useState(null); + const faviconUrl = resolvePreviewFaviconUrl({ + capturedUrl: capturedFaviconUrl, + googleUrl: googleFaviconUrl, + failedCapturedUrl, + failedGoogleUrl, + }); + if (!faviconUrl) return ; return ( setFailedUrl(faviconUrl)} + className="size-3 shrink-0 rounded-sm object-contain" + onError={() => { + if (faviconUrl === capturedFaviconUrl) setFailedCapturedUrl(faviconUrl); + else setFailedGoogleUrl(faviconUrl); + }} /> ); } @@ -265,17 +294,22 @@ function PreviewFavicon({ url }: { url: string | null }) { function SurfaceIcon({ surface, sessions, + desktopByTabId, theme, }: { surface: RightPanelSurface; sessions: Readonly>; + desktopByTabId: Readonly>; theme: "light" | "dark"; }) { switch (surface.kind) { case "preview": { const snapshot = surface.resourceId ? sessions[surface.resourceId] : null; const url = !snapshot || snapshot.navStatus._tag === "Idle" ? null : snapshot.navStatus.url; - return ; + const capturedFaviconUrl = snapshot + ? (desktopByTabId[snapshot.tabId]?.favicon ?? null) + : null; + return ; } case "diff": return ; @@ -429,6 +463,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { {pending ? ( diff --git a/apps/web/src/components/preview/PreviewEmptyState.test.tsx b/apps/web/src/components/preview/PreviewEmptyState.test.tsx index 86cab6dbe2b..0e4f70cc1d5 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"; @@ -20,9 +20,17 @@ vi.mock("./useDiscoveredLocalServers", () => ({ useDiscoveredLocalServers: () => mocks.servers, })); +vi.mock("~/browserFaviconStore", () => ({ + useFaviconForThreadUrl: () => null, +})); + 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 +49,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..3660d2df18d --- /dev/null +++ b/apps/web/src/components/preview/PreviewFaviconIcon.test.tsx @@ -0,0 +1,37 @@ +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 { PreviewFaviconIcon } from "./PreviewFaviconIcon"; + +const threadRef = { + environmentId: EnvironmentId.make("env-1"), + threadId: ThreadId.make("thread-1"), +}; + +describe("PreviewFaviconIcon", () => { + it("renders the stored favicon when one exists", () => { + mocks.favicon = "data:image/png;base64,AAAA"; + const html = renderToStaticMarkup( + , + ); + expect(html).toContain("data:image/png;base64,AAAA"); + expect(html).toContain(" { + mocks.favicon = null; + const html = renderToStaticMarkup( + , + ); + expect(html).not.toContain("; + return ; +} + +function PreviewFaviconImage({ src, className }: Pick & { src: string }) { + const [failed, setFailed] = useState(false); + if (failed) return ; + return ( + setFailed(true)} + /> + ); +} 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 (