From 8d2412e0f650f88b078b5efd6a7520b87fe408fc Mon Sep 17 00:00:00 2001 From: Kalven Schraut Date: Tue, 15 Sep 2026 11:11:49 -0500 Subject: [PATCH] fix(web): keep screenshots when attaching preview annotations The element picker captured its crop in Electron, then dropped it in the renderer: the conversion fetched the data URL, and the desktop Content Security Policy only allows http, https, ws, and wss connections, so every attach ended with "The annotation was kept without the screenshot". Decode the data URL locally with the existing dataUrlToFile helper and remove the now-redundant conversion timeout. Failed decodes still keep the annotation without promising an attached image. Ported from upstream pingdotgg/t3code#10374. Co-Authored-By: Claude Fable 5.1 --- .../components/preview/PreviewView.test.tsx | 4 +- .../src/components/preview/PreviewView.tsx | 2 +- apps/web/src/lib/previewAnnotation.test.ts | 45 ++++++++++--------- apps/web/src/lib/previewAnnotation.ts | 45 +++++-------------- 4 files changed, 40 insertions(+), 56 deletions(-) diff --git a/apps/web/src/components/preview/PreviewView.test.tsx b/apps/web/src/components/preview/PreviewView.test.tsx index ea28a93235ee..e8609db7d16e 100644 --- a/apps/web/src/components/preview/PreviewView.test.tsx +++ b/apps/web/src/components/preview/PreviewView.test.tsx @@ -343,7 +343,7 @@ describe("PreviewView navigation", () => { mocks.closePictureInPicture.mockClear(); mocks.pickElement.mockReset(); mocks.capturePreviewAnnotationScreenshot.mockReset(); - mocks.capturePreviewAnnotationScreenshot.mockResolvedValue({ status: "none" }); + mocks.capturePreviewAnnotationScreenshot.mockReturnValue({ status: "none" }); mocks.addPreviewAnnotation.mockClear(); vi.mocked(toastManager.add).mockClear(); mocks.addImage.mockClear(); @@ -605,7 +605,7 @@ describe("PreviewView navigation", () => { }; const onSendAnnotation = vi.fn(); mocks.pickElement.mockResolvedValue({ annotation, submission: "send" }); - mocks.capturePreviewAnnotationScreenshot.mockResolvedValue({ status: "failed" }); + mocks.capturePreviewAnnotationScreenshot.mockReturnValue({ status: "failed" }); renderToStaticMarkup( { afterEach(() => { - vi.useRealTimers(); vi.unstubAllGlobals(); }); - it("returns the crop when the fetch resolves", async () => { - vi.stubGlobal("fetch", async () => new Response(new Blob(["png"], { type: "image/png" }))); - const capture = await capturePreviewAnnotationScreenshot(annotation); + it("decodes the screenshot when desktop CSP blocks data URL fetches", async () => { + vi.stubGlobal("fetch", () => { + throw new TypeError("Refused to connect because it violates Content Security Policy"); + }); + const capture = capturePreviewAnnotationScreenshot(annotation); expect(capture.status).toBe("captured"); + if (capture.status !== "captured") throw new Error("Screenshot was dropped"); + expect(capture.file.name).toBe("preview-annotation-annotation_1.png"); + expect(capture.file.type).toBe("image/png"); + expect(new Uint8Array(await capture.file.arrayBuffer())).toEqual(new Uint8Array([0])); }); - it("reports none when the annotation carries no crop", async () => { - const capture = await capturePreviewAnnotationScreenshot({ ...annotation, screenshot: null }); - expect(capture).toEqual({ status: "none" }); - }); - - it("fails instead of hanging when the crop never arrives", async () => { - vi.useFakeTimers(); - vi.stubGlobal("fetch", () => new Promise(() => {})); - const capturePromise = capturePreviewAnnotationScreenshot(annotation, 1_000); - await vi.advanceTimersByTimeAsync(1_000); - expect(await capturePromise).toEqual({ status: "failed" }); + it("reports none when the annotation carries no crop", () => { + expect(capturePreviewAnnotationScreenshot({ ...annotation, screenshot: null })).toEqual({ + status: "none", + }); }); - it("fails when the crop fetch throws", async () => { - vi.stubGlobal("fetch", async () => { - throw new Error("data url unreadable"); - }); - expect(await capturePreviewAnnotationScreenshot(annotation)).toEqual({ status: "failed" }); + it.each([ + "data:image/png;base64,not!base64", + "data:image/png;base64,", + "data:image/png,not-base64", + "https://example.com/screenshot.png", + ])("reports a failed conversion for an invalid screenshot: %s", (dataUrl) => { + expect( + capturePreviewAnnotationScreenshot({ + ...annotation, + screenshot: { ...annotation.screenshot!, dataUrl }, + }), + ).toEqual({ status: "failed" }); }); }); diff --git a/apps/web/src/lib/previewAnnotation.ts b/apps/web/src/lib/previewAnnotation.ts index 1b4a449e5098..242129c65943 100644 --- a/apps/web/src/lib/previewAnnotation.ts +++ b/apps/web/src/lib/previewAnnotation.ts @@ -1,49 +1,28 @@ import type { PreviewAnnotationPayload } from "@t3tools/contracts"; - -async function previewAnnotationScreenshotFile( - annotation: PreviewAnnotationPayload, -): Promise { - if (!annotation.screenshot) return null; - const response = await fetch(annotation.screenshot.dataUrl); - const blob = await response.blob(); - return new File([blob], `preview-annotation-${annotation.id}.png`, { - type: blob.type || "image/png", - }); -} - -/** Upper bound on turning a picked element's crop into a composer attachment. */ -const PREVIEW_ANNOTATION_CAPTURE_TIMEOUT_MS = 5_000; +import { dataUrlToFile } from "./imageCompression"; export type PreviewAnnotationCapture = /** The crop is ready to attach. */ | { readonly status: "captured"; readonly file: File } /** The pick carried no crop, which is normal for comment-only annotations. */ | { readonly status: "none" } - /** The crop stalled or threw. Send the annotation without it. */ + /** The crop could not be decoded. Send the annotation without it. */ | { readonly status: "failed" }; -/** - * Bounded wrapper around `previewAnnotationScreenshotFile`. The picker holds the - * composer while this runs, so it must always settle: a stalled crop resolves as - * `failed` instead of leaving the caller waiting. - */ -export async function capturePreviewAnnotationScreenshot( +/** Decode locally because the desktop CSP does not allow fetching data URLs. */ +export function capturePreviewAnnotationScreenshot( annotation: PreviewAnnotationPayload, - timeoutMs: number = PREVIEW_ANNOTATION_CAPTURE_TIMEOUT_MS, -): Promise { +): PreviewAnnotationCapture { if (!annotation.screenshot) return { status: "none" }; - let timer: ReturnType | undefined; + const { dataUrl } = annotation.screenshot; + const match = /^data:(image\/[^;,]+);base64,.+$/s.exec(dataUrl); + if (!match?.[1]) return { status: "failed" }; try { - const file = await Promise.race([ - previewAnnotationScreenshotFile(annotation), - new Promise((resolve) => { - timer = setTimeout(() => resolve(null), timeoutMs); - }), - ]); - return file ? { status: "captured", file } : { status: "failed" }; + return { + status: "captured", + file: dataUrlToFile(dataUrl, `preview-annotation-${annotation.id}.png`, match[1]), + }; } catch { return { status: "failed" }; - } finally { - clearTimeout(timer); } }