diff --git a/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx b/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx index 0ffc8b10a94..0516cabf2c6 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx @@ -17,6 +17,7 @@ import { type AttachmentFailure, attachmentFailureFromError, getChatFileURL, + handleAttachmentDownloadClick, isAbortError, probeAttachmentFailure, } from "../../utils/chatAttachments"; @@ -54,17 +55,40 @@ const ATTACHMENT_FALLBACK_EXTENSIONS: Record = { "application/vnd.openxmlformats-officedocument.wordprocessingml.document": "docx", "application/x-tar": "tar", + "application/xml": "xml", "image/jpeg": "jpg", + "image/svg+xml": "svg", + "text/csv": "csv", "text/markdown": "md", "text/plain": "txt", }; -const sanitizeAttachmentExtension = (value: string): string => { - const sanitized = value +const sanitizeAttachmentExtension = (value: string): string => + value .replace(/[^a-z0-9]/gi, "") .slice(0, 4) - .toLowerCase(); - return sanitized || "file"; + .toLowerCase() || "file"; + +const getMediaTypeExtension = (mediaType: string): string | null => { + if (mediaType === "application/octet-stream") { + return null; + } + const mapped = ATTACHMENT_FALLBACK_EXTENSIONS[mediaType]; + if (mapped) { + return mapped; + } + const [type, subtype = ""] = mediaType.split("/"); + // Unmapped non-image subtypes are not assumed to be filename extensions. + return type === "image" && /^[a-z0-9]{1,8}$/i.test(subtype) + ? subtype.toLowerCase() + : null; +}; + +const getNameExtension = (name: string): string | null => { + const lastDot = name.lastIndexOf("."); + return lastDot > 0 && lastDot < name.length - 1 + ? name.slice(lastDot + 1) + : null; }; const getAttachmentExtension = ( @@ -74,20 +98,14 @@ const getAttachmentExtension = ( if (mapped) { return mapped; } - const trimmedName = block.name?.trim(); - if (trimmedName) { - const lastDot = trimmedName.lastIndexOf("."); - // Keep dotfiles like `.env` out of the extension path, while still - // allowing ordinary `name.ext` filenames to contribute a fallback. - if (lastDot > 0 && lastDot < trimmedName.length - 1) { - return sanitizeAttachmentExtension(trimmedName.slice(lastDot + 1)); - } - } - const subtype = block.media_type.split("/")[1] ?? ""; - if (subtype.endsWith("+json")) { - return "json"; + const nameExtension = getNameExtension(block.name?.trim() ?? ""); + if (nameExtension) { + return sanitizeAttachmentExtension(nameExtension); } - return sanitizeAttachmentExtension(subtype); + return ( + getMediaTypeExtension(block.media_type) ?? + sanitizeAttachmentExtension(block.media_type.split("/")[1] ?? "") + ); }; const isTextPreviewAttachmentMediaType = (mediaType: string): boolean => @@ -123,11 +141,16 @@ const getAttachmentDownloadName = ( block: Pick, ): string => { const name = block.name?.trim(); - if (name) { + if (!name) { + const extension = getAttachmentExtension(block); + return extension === "file" ? "attachment" : `attachment.${extension}`; + } + // Kept even when the name's extension disagrees with the media type. + if (name.startsWith(".") || getNameExtension(name)) { return name; } - const extension = getAttachmentExtension(block); - return extension === "file" ? "attachment" : `attachment.${extension}`; + const mediaExtension = getMediaTypeExtension(block.media_type); + return mediaExtension ? `${name}.${mediaExtension}` : name; }; const getAttachmentBadgeLabel = ( @@ -141,24 +164,35 @@ const DownloadOverlay: FC<{ href: string; displayName: string; downloadName: string; -}> = ({ href, displayName, downloadName }) => ( - event.stopPropagation()} - aria-label={`Download ${displayName}`} - className="invisible absolute right-1 top-1 flex size-6 items-center justify-center rounded bg-surface-primary/80 text-content-secondary opacity-0 shadow-sm backdrop-blur-sm transition-opacity hover:text-content-primary group-hover/attachment:visible group-hover/attachment:opacity-100 group-focus-within/attachment:visible group-focus-within/attachment:opacity-100 [@media(hover:none)]:visible [@media(hover:none)]:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-content-link" - > - -); + mediaType: string; +}> = ({ href, displayName, downloadName, mediaType }) => { + return ( + { + event.stopPropagation(); + void handleAttachmentDownloadClick(event, { + href, + fileName: downloadName, + mediaType, + }); + }} + aria-label={`Download ${displayName}`} + className="invisible absolute right-1 top-1 flex size-6 items-center justify-center rounded bg-surface-primary/80 text-content-secondary opacity-0 shadow-sm backdrop-blur-sm transition-opacity hover:text-content-primary group-hover/attachment:visible group-hover/attachment:opacity-100 group-focus-within/attachment:visible group-focus-within/attachment:opacity-100 [@media(hover:none)]:visible [@media(hover:none)]:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-content-link" + > + + ); +}; const AttachmentPreviewFrame: FC<{ href: string | null; displayName: string; downloadName: string; + mediaType: string; children: ReactNode; -}> = ({ href, displayName, downloadName, children }) => { +}> = ({ href, displayName, downloadName, mediaType, children }) => { return (
{children} @@ -167,6 +201,7 @@ const AttachmentPreviewFrame: FC<{ href={href} displayName={displayName} downloadName={downloadName} + mediaType={mediaType} /> ) : null}
@@ -385,6 +420,7 @@ const RemoteTextAttachmentButton: FC<{ href={frameHref} displayName={fileName ?? "Pasted text"} downloadName={downloadName} + mediaType={mediaType ?? ""} > {button} @@ -530,7 +566,14 @@ const FileCard: FC<{ event.stopPropagation()} + onClick={(event) => { + event.stopPropagation(); + void handleAttachmentDownloadClick(event, { + href, + fileName: downloadName, + mediaType: block.media_type, + }); + }} aria-label={`Download ${displayName}`} className="inline-flex h-16 max-w-sm items-center gap-3 rounded-md border border-solid border-border-default bg-surface-tertiary px-3 py-2 no-underline transition-colors hover:bg-surface-quaternary" > @@ -616,6 +659,7 @@ export const AttachmentBlock: FC<{ href={href} displayName={displayName} downloadName={downloadName} + mediaType={block.media_type} > {button} @@ -641,6 +685,7 @@ export const AttachmentBlock: FC<{ href={href} displayName={displayName} downloadName={downloadName} + mediaType={block.media_type} > {image} diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx index a08c233e5d3..c525904099c 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx @@ -147,6 +147,11 @@ const ATTACHMENT_RESPONSES = new Map([ }, ], ["storybook-text-error", { body: "Temporary failure", status: 503 }], + [ + "storybook-ios-share-report", + { status: 200, body: "pdf-bytes", contentType: "application/pdf" }, + ], + ["storybook-ios-error-report", { status: 500, body: "" }], ]); let attachmentFetchCounts = new Map(); @@ -1294,6 +1299,83 @@ export const AssistantMessageWithUnnamedDownloadableFile: Story = { }, }; +export const AssistantMessageWithMismatchedExtensionFile: Story = { + args: { + ...defaultArgs, + parsedMessages: buildMessages([ + { + ...baseMessage, + id: 1, + role: "assistant", + content: [ + { type: "text", text: "Here are the release notes." }, + { + type: "file", + media_type: "application/pdf", + file_id: "storybook-mismatched-notes", + name: "release-notes.txt", + }, + ], + }, + ]), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const downloadLink = canvas.getByRole("link", { + name: "Download release-notes.txt", + }); + expect(downloadLink).toHaveAttribute("download", "release-notes.txt"); + }, +}; + +const iosDownloadStoryArgs: Story["args"] = buildStoryArgs( + buildUserMessage({ + text: "I attached the deployment report.", + files: [ + buildFilePart({ + media_type: "application/pdf", + file_id: "storybook-ios-share-report", + name: "deployment-report.pdf", + }), + ], + }), +); + +export const DownloadInIOSStandaloneSharesFile: Story = { + args: iosDownloadStoryArgs, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const share = fn().mockResolvedValue(undefined); + // Read-only Navigator values must be shadowed with removable own + // properties. + const overrides: Record = { + userAgent: + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15", + standalone: true, + share, + canShare: fn().mockReturnValue(true), + }; + for (const [key, value] of Object.entries(overrides)) { + Object.defineProperty(navigator, key, { value, configurable: true }); + } + try { + await userEvent.click( + canvas.getByRole("link", { name: "Download deployment-report.pdf" }), + ); + await waitFor(() => expect(share).toHaveBeenCalledTimes(1)); + const shared: { files: File[] } = share.mock.calls[0][0]; + expect(shared.files).toHaveLength(1); + expect(shared.files[0].name).toBe("deployment-report.pdf"); + expect(shared.files[0].type).toBe("application/pdf"); + expect(getAttachmentFetchCount("storybook-ios-share-report")).toBe(1); + } finally { + for (const key of Object.keys(overrides)) { + Reflect.deleteProperty(navigator, key); + } + } + }, +}; + /** Images and file-references coexist without interfering. */ export const UserMessageWithImagesAndFileRefs: Story = { args: { diff --git a/site/src/pages/AgentsPage/utils/chatAttachments.test.ts b/site/src/pages/AgentsPage/utils/chatAttachments.test.ts index 19647576869..12d1cffd5f8 100644 --- a/site/src/pages/AgentsPage/utils/chatAttachments.test.ts +++ b/site/src/pages/AgentsPage/utils/chatAttachments.test.ts @@ -1,10 +1,251 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("sonner", () => ({ + toast: { + error: vi.fn(), + }, +})); + +import { toast } from "sonner"; import { + handleAttachmentDownloadClick, isChatAttachmentFile, renameChatFileForUpload, sanitizeChatFileName, } from "./chatAttachments"; +describe("handleAttachmentDownloadClick", () => { + const overriddenNavigatorKeys = new Set(); + const iPhoneUserAgent = + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15"; + const target = { + href: "/api/experimental/chats/files/file-1", + fileName: "01-agents-list.png", + mediaType: "image/png", + }; + + const overrideNavigator = (key: string, value: unknown) => { + Object.defineProperty(navigator, key, { value, configurable: true }); + overriddenNavigatorKeys.add(key); + }; + + const enterIOSStandalonePWA = () => { + overrideNavigator("userAgent", iPhoneUserAgent); + overrideNavigator("standalone", true); + }; + + const mockFileSharing = (share: ReturnType) => { + overrideNavigator("share", share); + overrideNavigator( + "canShare", + vi.fn(() => true), + ); + }; + + const mockAttachmentFetch = (body = "png-bytes", mediaType = "image/png") => + vi + .spyOn(globalThis, "fetch") + .mockResolvedValue( + new Response(new Blob([body], { type: mediaType }), { status: 200 }), + ); + + const click = (downloadTarget = target) => { + const event = { preventDefault: vi.fn() }; + return { + event, + pending: handleAttachmentDownloadClick(event, downloadTarget), + }; + }; + + afterEach(() => { + for (const key of overriddenNavigatorKeys) { + Reflect.deleteProperty(navigator, key); + } + overriddenNavigatorKeys.clear(); + vi.restoreAllMocks(); + vi.mocked(toast.error).mockClear(); + }); + + it.each([ + ["outside iOS", () => {}], + [ + "in the iOS browser", + () => overrideNavigator("userAgent", iPhoneUserAgent), + ], + ])("keeps the native anchor download %s", (_label, setup) => { + setup(); + const { event, pending } = click(); + + expect(pending).toBeUndefined(); + expect(event.preventDefault).not.toHaveBeenCalled(); + }); + + it("shares the fetched attachment in an iOS standalone PWA", async () => { + enterIOSStandalonePWA(); + const share = vi.fn().mockResolvedValue(undefined); + mockFileSharing(share); + mockAttachmentFetch(); + + const { event, pending } = click(); + await pending; + + expect(event.preventDefault).toHaveBeenCalled(); + expect(globalThis.fetch).toHaveBeenCalledWith(target.href); + const shared: { files: File[] } = share.mock.calls[0][0]; + expect(shared.files).toHaveLength(1); + expect(shared.files[0]).toMatchObject({ + name: "01-agents-list.png", + type: "image/png", + }); + }); + + it("recognizes iPadOS with a macOS user agent", async () => { + overrideNavigator( + "userAgent", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15", + ); + overrideNavigator("maxTouchPoints", 5); + overrideNavigator("standalone", true); + const share = vi.fn().mockResolvedValue(undefined); + mockFileSharing(share); + mockAttachmentFetch(); + + const { event, pending } = click(); + await pending; + + expect(event.preventDefault).toHaveBeenCalled(); + expect(share).toHaveBeenCalledTimes(1); + }); + + it("keeps the native anchor when file sharing is unavailable", () => { + enterIOSStandalonePWA(); + const fetchSpy = vi.spyOn(globalThis, "fetch"); + + const { event, pending } = click(); + + expect(pending).toBeUndefined(); + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("stays quiet when the user dismisses the share sheet", async () => { + enterIOSStandalonePWA(); + mockFileSharing( + vi.fn().mockRejectedValue(new DOMException("canceled", "AbortError")), + ); + mockAttachmentFetch(); + + await click().pending; + + expect(toast.error).not.toHaveBeenCalled(); + }); + + it("shows the fetch failure", async () => { + enterIOSStandalonePWA(); + const share = vi.fn().mockResolvedValue(undefined); + mockFileSharing(share); + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response("nope", { status: 503 }), + ); + + await click().pending; + + expect(share).not.toHaveBeenCalled(); + expect(toast.error).toHaveBeenCalledWith( + "Couldn't download 01-agents-list.png", + { description: "HTTP 503" }, + ); + }); + + it("offers a Save retry when user activation expires", async () => { + enterIOSStandalonePWA(); + const share = vi + .fn() + .mockRejectedValueOnce( + new DOMException("activation expired", "NotAllowedError"), + ) + .mockResolvedValue(undefined); + mockFileSharing(share); + mockAttachmentFetch(); + + await click().pending; + + expect(toast.error).toHaveBeenCalledWith( + "Couldn't download 01-agents-list.png", + expect.objectContaining({ + description: "The file is ready to save.", + action: expect.objectContaining({ label: "Save" }), + }), + ); + + const action: unknown = vi.mocked(toast.error).mock.calls[0][1]?.action; + if ( + action === null || + typeof action !== "object" || + !("onClick" in action) || + typeof action.onClick !== "function" + ) { + throw new Error("expected the toast to carry a Save action"); + } + action.onClick(); + expect(share).toHaveBeenCalledTimes(2); + const first: { files: File[] } = share.mock.calls[0][0]; + const retry: { files: File[] } = share.mock.calls[1][0]; + expect(retry.files[0]).toBe(first.files[0]); + }); + + it("shows a plain failure toast after a permanent share failure", async () => { + enterIOSStandalonePWA(); + mockFileSharing(vi.fn().mockRejectedValue(new Error("share failed"))); + mockAttachmentFetch(); + + await click().pending; + + expect(toast.error).toHaveBeenCalledWith( + "Couldn't download 01-agents-list.png", + { description: "share failed" }, + ); + }); + + it("shares inline data without fetching", async () => { + enterIOSStandalonePWA(); + const share = vi.fn().mockResolvedValue(undefined); + mockFileSharing(share); + const fetchSpy = vi.spyOn(globalThis, "fetch"); + + await click({ + href: `data:image/png;base64,${btoa("png-bytes")}`, + fileName: "inline.png", + mediaType: "image/png", + }).pending; + + expect(fetchSpy).not.toHaveBeenCalled(); + const shared: { files: File[] } = share.mock.calls[0][0]; + expect(shared.files[0]).toMatchObject({ + name: "inline.png", + type: "image/png", + size: "png-bytes".length, + }); + }); + + it("shows a decode error for corrupt inline data", async () => { + enterIOSStandalonePWA(); + const share = vi.fn().mockResolvedValue(undefined); + mockFileSharing(share); + + await click({ + href: "data:image/png;base64,%%%", + fileName: "inline.png", + mediaType: "image/png", + }).pending; + + expect(share).not.toHaveBeenCalled(); + expect(toast.error).toHaveBeenCalledWith("Couldn't download inline.png", { + description: "The attachment data could not be decoded.", + }); + }); +}); + describe("isChatAttachmentFile", () => { it("accepts allowlisted MIME types", () => { const file = new File(["png"], "image.png", { type: "image/png" }); diff --git a/site/src/pages/AgentsPage/utils/chatAttachments.ts b/site/src/pages/AgentsPage/utils/chatAttachments.ts index 956753ffea5..9f3c65ce33c 100644 --- a/site/src/pages/AgentsPage/utils/chatAttachments.ts +++ b/site/src/pages/AgentsPage/utils/chatAttachments.ts @@ -1,5 +1,7 @@ -import { isApiErrorResponse } from "#/api/errors"; +import { toast } from "sonner"; +import { getErrorMessage, isApiErrorResponse } from "#/api/errors"; import { ChatAttachmentMediaTypes } from "#/api/typesGenerated"; +import { decodeDataURL } from "./dataUrls"; const undisplayableAttachmentDetail = "File exists but could not be displayed."; @@ -62,6 +64,122 @@ export async function probeAttachmentFailure( return classifyAttachmentFailureResponse(response); } +type IOSNavigator = Navigator & { standalone?: boolean }; + +const isIOS = (): boolean => + /iPad|iPhone|iPod/.test(navigator.userAgent) || + // iPadOS 13+ reports a macOS user agent; the touchscreen is the tell. + (navigator.userAgent.includes("Mac") && navigator.maxTouchPoints > 1); + +const isStandaloneDisplayMode = (): boolean => { + const nav: IOSNavigator = navigator; + return ( + matchMedia("(display-mode: standalone)").matches || nav.standalone === true + ); +}; + +const canShareFile = (file: File): boolean => + typeof navigator.share === "function" && + typeof navigator.canShare === "function" && + navigator.canShare({ files: [file] }); + +type AttachmentDownloadTarget = { + href: string; + fileName: string; + mediaType: string; +}; + +const fileFromDataURL = ({ + href, + fileName, + mediaType, +}: AttachmentDownloadTarget): File | null => { + const decoded = decodeDataURL(href); + return decoded + ? new File([decoded.bytes], fileName, { + type: decoded.mediaType || mediaType || "application/octet-stream", + }) + : null; +}; + +const shareFileViaSheet = (file: File, fileName: string): Promise => + navigator.share({ files: [file] }).catch((error: unknown) => { + if (error instanceof DOMException && error.name === "AbortError") { + return; + } + if (error instanceof DOMException && error.name === "NotAllowedError") { + // Fetching may outlast transient user activation. The toast action + // supplies a fresh gesture for the retry. + toast.error(`Couldn't download ${fileName}`, { + description: "The file is ready to save.", + action: { + label: "Save", + onClick: () => void shareFileViaSheet(file, fileName), + }, + }); + return; + } + toast.error(`Couldn't download ${fileName}`, { + description: getErrorMessage(error, "Sharing failed."), + }); + }); + +const shareAttachmentFile = async ( + target: AttachmentDownloadTarget, +): Promise => { + let file: File; + if (target.href.startsWith("data:")) { + const decoded = fileFromDataURL(target); + if (!decoded) { + toast.error(`Couldn't download ${target.fileName}`, { + description: "The attachment data could not be decoded.", + }); + return; + } + file = decoded; + } else { + try { + const response = await fetch(target.href); + if (!response.ok) { + throw new Error( + response.statusText + ? `${response.status} ${response.statusText}` + : `HTTP ${response.status}`, + ); + } + const blob = await response.blob(); + file = new File([blob], target.fileName, { + type: blob.type || target.mediaType || "application/octet-stream", + }); + } catch (error) { + toast.error(`Couldn't download ${target.fileName}`, { + description: getErrorMessage(error, "The file could not be fetched."), + }); + return; + } + } + await shareFileViaSheet(file, target.fileName); +}; + +/** + * Uses the share sheet in iOS standalone mode to avoid a QuickLook navigation + * with no reliable return path. Otherwise, the native anchor handles the download. + */ +export const handleAttachmentDownloadClick = ( + event: { preventDefault: () => void }, + target: AttachmentDownloadTarget, +): Promise | undefined => { + if (!isIOS() || !isStandaloneDisplayMode()) { + return undefined; + } + const probe = new File(["0"], target.fileName, { type: target.mediaType }); + if (!canShareFile(probe)) { + return undefined; + } + event.preventDefault(); + return shareAttachmentFile(target); +}; + // Filename extensions to list in the file-picker's `accept` attribute // alongside the MIME types. Browsers and operating systems do not always // map these extensions to a registered MIME type (Markdown is the common diff --git a/site/src/pages/AgentsPage/utils/chatDraftAttachmentStorage.ts b/site/src/pages/AgentsPage/utils/chatDraftAttachmentStorage.ts index 586ecd6267c..aa38557407e 100644 --- a/site/src/pages/AgentsPage/utils/chatDraftAttachmentStorage.ts +++ b/site/src/pages/AgentsPage/utils/chatDraftAttachmentStorage.ts @@ -1,3 +1,5 @@ +import { decodeDataURL } from "./dataUrls"; + type ChatDraftAttachmentRecord = { clientId: string; fileName: string; @@ -279,35 +281,22 @@ const fileFromDataURL = ( payload: string, metadata: { fileName: string; fileType: string; lastModified: number }, ): File | null => { - const commaIndex = payload.indexOf(","); - if (commaIndex === -1 || !payload.startsWith("data:")) { - return null; - } - const header = payload.slice(0, commaIndex); - if (!header.toLowerCase().includes(";base64")) { + const decoded = decodeDataURL(payload); + // FileReader stores drafts as base64, so other encodings indicate corruption. + if (!decoded?.isBase64) { return null; } - const payloadMediaType = header.slice("data:".length).split(";")[0]; if ( metadata.fileType && - payloadMediaType && - payloadMediaType.toLowerCase() !== metadata.fileType.toLowerCase() + decoded.mediaType && + decoded.mediaType.toLowerCase() !== metadata.fileType.toLowerCase() ) { return null; } - try { - const binary = atob(payload.slice(commaIndex + 1)); - const bytes = new Uint8Array(binary.length); - for (let index = 0; index < binary.length; index++) { - bytes[index] = binary.charCodeAt(index); - } - return new File([bytes], metadata.fileName, { - type: metadata.fileType, - lastModified: metadata.lastModified, - }); - } catch { - return null; - } + return new File([decoded.bytes], metadata.fileName, { + type: metadata.fileType, + lastModified: metadata.lastModified, + }); }; const fileForRecord = (record: ChatDraftAttachmentRecord): File | null => { diff --git a/site/src/pages/AgentsPage/utils/dataUrls.ts b/site/src/pages/AgentsPage/utils/dataUrls.ts new file mode 100644 index 00000000000..e4280c3601b --- /dev/null +++ b/site/src/pages/AgentsPage/utils/dataUrls.ts @@ -0,0 +1,31 @@ +type DecodedDataURL = { + mediaType: string; + isBase64: boolean; + bytes: Uint8Array; +}; + +// Data URLs are decoded by hand because the production CSP excludes data: +// from connect-src (so fetch cannot read them) and the Safari 16 support +// baseline predates Uint8Array.fromBase64. +export const decodeDataURL = (url: string): DecodedDataURL | null => { + const commaIndex = url.indexOf(","); + const scheme = url.slice(0, "data:".length).toLowerCase(); + if (scheme !== "data:" || commaIndex === -1) { + return null; + } + const params = url.slice("data:".length, commaIndex).split(";"); + const payload = url.slice(commaIndex + 1); + const isBase64 = params.at(-1)?.toLowerCase() === "base64"; + try { + const bytes = isBase64 + ? Uint8Array.from(atob(payload), (char) => char.charCodeAt(0)) + : new TextEncoder().encode(decodeURIComponent(payload)); + return { + mediaType: params[0].trim(), + isBase64, + bytes, + }; + } catch { + return null; + } +};