diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index a6ef30c2742..3fd4a3222ca 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -186,6 +186,101 @@ 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; + let destroyed = 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; + 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: () => destroyed, + 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; + }, + setDestroyed: (nextDestroyed: boolean) => { + destroyed = nextDestroyed; + }, + }; +}; + +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 +470,258 @@ 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* () { + 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("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* () { + 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 +813,1474 @@ 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("recaptures a discarded loading-time favicon after a newer event 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(() => 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)?.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")}`); + }), + ), + ); + + 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("bounds favicon candidates and URL length before fetching", () => + withManager((manager) => + Effect.gen(function* () { + const fetch = vi.fn(async () => ({ ok: false })); + 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); + + const candidates = Array.from( + { length: 10 }, + (_, index) => `http://localhost:5740/favicon-${index}.png`, + ); + 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 = executeJavaScriptInIsolatedWorld.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(executeJavaScriptInIsolatedWorld).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("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 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 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; + 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[] = []; - 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(); + + 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("decodes favicon buffers outside the main process", () => 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(), + const origin = "http://localhost:5761"; + 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 source = url === oversizedUrl ? oversized : bytes; + return { + ok: true, + arrayBuffer: async () => + 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: "Safe favicon decoding", + fetch, + rasterizedFavicon: "data:image/png;base64,RASTERIZED", }, - } 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); - - 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"); + yield* manager.createTab("tab_safe_favicon_decode"); + yield* manager.registerWebview("tab_safe_favicon_decode", 42); - 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", - }); + listeners.get("page-favicon-updated")?.({}, [oversizedUrl]); + yield* settle(() => fetch.mock.calls.length === 1); + expect(executeJavaScriptInIsolatedWorld).not.toHaveBeenCalled(); - loading = true; - listeners.get("did-start-loading")?.(); - yield* Effect.yieldNow; - expect(statuses.at(-1)?.kind).toBe("Loading"); + listeners.get("page-favicon-updated")?.({}, [faviconUrl]); + yield* settle(() => states.at(-1)?.favicon !== undefined); - loading = false; - listeners.get("did-stop-loading")?.(); - yield* Effect.yieldNow; - expect(statuses.at(-1)?.kind).toBe("Success"); + expect(executeJavaScriptInIsolatedWorld).toHaveBeenCalledWith(1001, [ + { + code: expect.stringMatching( + new RegExp(`${bytes.toString("base64")}.*resizeWidth: 32.*resizeHeight: 32`, "s"), + ), + }, + ]); + expect(states.at(-1)?.favicon).toBe("data:image/png;base64,RASTERIZED"); + }), + ), + ); - listeners.get("did-fail-load")?.({}, -102, "ERR_CONNECTION_REFUSED", url, true); - yield* Effect.yieldNow; - expect(statuses.at(-1)?.kind).toBe("LoadFailed"); + 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[] = []; - listeners.get("did-navigate")?.(); - yield* Effect.yieldNow; - expect(statuses.at(-1)?.kind).toBe("Success"); + 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(); + } }), ), ); @@ -1450,7 +3022,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..938e11fade9 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -27,6 +27,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 +43,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 +87,8 @@ export interface PreviewTabState { pictureInPicture: boolean; colorScheme: DesktopPreviewColorScheme; controller: "human" | "agent" | "none"; + favicon?: string; + faviconOrigin?: string; updatedAt: string; } @@ -347,6 +351,7 @@ type PreviewInputSignal = interface ManagedListeners { readonly scope: Scope.Closeable; + readonly invalidateFavicon: () => void; } type FrameCaptureConsumer = "picture-in-picture" | "recording"; @@ -414,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") { @@ -452,6 +464,248 @@ 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 = { + 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 JSON.stringify(parts); +} + +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: 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: "${blobType}" }), { + resizeWidth: 32, + resizeHeight: 32, + resizeQuality: "high", + }); + try { + const canvas = new OffscreenCanvas(bitmap.width, bitmap.height); + const context = canvas.getContext("2d"); + if (!context) return null; + 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); + 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); + 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 = () => { + clearExecution(); + 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; + } + 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* ( artifactDirectory: string, pictureInPicturePreloadPath: string, @@ -489,6 +743,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 +763,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 +873,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 +905,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 +1490,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 +1557,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 +1591,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 +1629,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 +1651,232 @@ 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; + return true; + } + if (result.kind === "stale") { + if (pendingFaviconPublication === publication) { + pendingFaviconPublication = 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.filter(isSupportedFaviconUrl))].slice( + 0, + MAX_FAVICON_CANDIDATES, + ); + if (candidates.length === 0) return; + const eventKey = faviconCaptureKey([origin, ...candidates]); + if (activeFaviconEventKey === eventKey) return; + const generation = ++faviconGeneration; + pendingFaviconPublication = null; + 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 +1922,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 +1943,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 +1958,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 +1980,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 +2042,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 +2142,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 +2191,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 +2236,12 @@ 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), + navigationLaunchTokens.delete(tabId); + const load = yield* attempt( + { operation: "registerWebview.loadPendingUrl", tabId, webContentsId }, + () => wc.loadURL(pendingUrl), ); + return { load, wc }; } }); @@ -1680,16 +2250,30 @@ 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; + runFork( + attemptPromise( + { operation: "registerWebview.loadPendingUrl", tabId, webContentsId }, + () => action.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 +2291,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 [ @@ -1719,8 +2306,16 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function yield* emit(tabId, pending); if (pending.webContentsId == null) return; const wc = webContents.fromId(pending.webContentsId); - if (!wc) { - const detached = { ...pending, webContentsId: null }; + if (!wc || wc.isDestroyed()) { + 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 @@ -1731,37 +2326,94 @@ 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) { yield* attempt({ operation: "navigate.reload", tabId, webContentsId: wc.id }, () => wc.reload(), ); return; } - yield* attemptPromise({ operation: "navigate.loadURL", tabId, webContentsId: wc.id }, () => - wc.loadURL(url), + const load = yield* attempt( + { operation: "navigate.loadURL", tabId, webContentsId: wc.id }, + () => wc.loadURL(url), ); + return { load, 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; + yield* attemptPromise( + { operation: "navigate.loadURL", tabId, webContentsId: action.wc.id }, + () => action.load, + ).pipe( + Effect.catchIf( + (error) => isAbortedNavigationCause(error.cause), + () => Effect.void, + ), + ); }); 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/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/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..93067e416ab 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 @@ -6409,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} @@ -6438,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 (