mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
fix(site/src/pages/AgentsPage): stop attachment downloads from trapping iOS PWAs (#27853)
On iOS, tapping an attachment download link inside the installed (standalone) PWA hands the file to QuickLook, which renders on top of the app with no reliable way back. Users had to force-quit the PWA to recover. ## Approach In iOS standalone mode, when the device supports file sharing, the download click is intercepted and the fetched attachment is handed to the native share sheet (`navigator.share` with a `File`), where the user can pick Save to Files or any other target. This is the platform-intended path for saving files from a PWA; no library covers it (FileSaver.js is broken in iOS PWAs, and browser-fs-access falls back to the same broken anchor path). Everything else keeps native behavior: - Non-iOS platforms and regular iOS Safari tabs keep the plain `<a download>` anchor. - iOS standalone devices without file sharing keep the anchor too. - Inline `data:` attachments are decoded locally because the production CSP (`connect-src 'self'`) blocks fetching them. ## Error model Deliberately small: a share-sheet dismissal (`AbortError`) is silent, an expired click gesture (`NotAllowedError`, which happens when the fetch outlasts transient activation) shows a toast whose Save action retries with a fresh gesture, and any other failure shows a plain error toast. An earlier revision carried pending-state tracking, abort-signal threading through unmount, and a blob-URL tab fallback; those guards were removed on purpose to keep the change proportionate to the defect. Also hardens chat file uploads slightly: filenames are sanitized before upload and the file-picker `accept` attribute mirrors the server's attachment allowlist. ## Testing - Storybook interaction tests cover the share happy path and native-anchor behavior; unit tests cover the intercept gate (including iPadOS detection), failure toasts, retry action, and inline-data decode. - Validated on an iPhone against a dev deployment: share sheet opens, Save to Files works, and the app remains usable afterwards. > Mux implemented this on Mike's behalf. <!-- mux-attribution: model=claude-sonnet-4-5 thinking=high -->
This commit is contained in:
@@ -17,6 +17,7 @@ import {
|
||||
type AttachmentFailure,
|
||||
attachmentFailureFromError,
|
||||
getChatFileURL,
|
||||
handleAttachmentDownloadClick,
|
||||
isAbortError,
|
||||
probeAttachmentFailure,
|
||||
} from "../../utils/chatAttachments";
|
||||
@@ -54,17 +55,40 @@ const ATTACHMENT_FALLBACK_EXTENSIONS: Record<string, string> = {
|
||||
"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 nameExtension = getNameExtension(block.name?.trim() ?? "");
|
||||
if (nameExtension) {
|
||||
return sanitizeAttachmentExtension(nameExtension);
|
||||
}
|
||||
const subtype = block.media_type.split("/")[1] ?? "";
|
||||
if (subtype.endsWith("+json")) {
|
||||
return "json";
|
||||
}
|
||||
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<FileAttachmentBlock, "media_type" | "name">,
|
||||
): 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 }) => (
|
||||
<a
|
||||
href={href}
|
||||
download={downloadName}
|
||||
onClick={(event) => 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"
|
||||
>
|
||||
<DownloadIcon aria-hidden="true" className="size-3.5" />
|
||||
</a>
|
||||
);
|
||||
mediaType: string;
|
||||
}> = ({ href, displayName, downloadName, mediaType }) => {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
download={downloadName}
|
||||
onClick={(event) => {
|
||||
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"
|
||||
>
|
||||
<DownloadIcon aria-hidden="true" className="size-3.5" />
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
const AttachmentPreviewFrame: FC<{
|
||||
href: string | null;
|
||||
displayName: string;
|
||||
downloadName: string;
|
||||
mediaType: string;
|
||||
children: ReactNode;
|
||||
}> = ({ href, displayName, downloadName, children }) => {
|
||||
}> = ({ href, displayName, downloadName, mediaType, children }) => {
|
||||
return (
|
||||
<div className="group/attachment relative inline-flex flex-col items-start">
|
||||
{children}
|
||||
@@ -167,6 +201,7 @@ const AttachmentPreviewFrame: FC<{
|
||||
href={href}
|
||||
displayName={displayName}
|
||||
downloadName={downloadName}
|
||||
mediaType={mediaType}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -385,6 +420,7 @@ const RemoteTextAttachmentButton: FC<{
|
||||
href={frameHref}
|
||||
displayName={fileName ?? "Pasted text"}
|
||||
downloadName={downloadName}
|
||||
mediaType={mediaType ?? ""}
|
||||
>
|
||||
{button}
|
||||
</AttachmentPreviewFrame>
|
||||
@@ -530,7 +566,14 @@ const FileCard: FC<{
|
||||
<a
|
||||
href={href}
|
||||
download={downloadName}
|
||||
onClick={(event) => 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}
|
||||
</AttachmentPreviewFrame>
|
||||
@@ -641,6 +685,7 @@ export const AttachmentBlock: FC<{
|
||||
href={href}
|
||||
displayName={displayName}
|
||||
downloadName={downloadName}
|
||||
mediaType={block.media_type}
|
||||
>
|
||||
{image}
|
||||
</AttachmentPreviewFrame>
|
||||
|
||||
+82
@@ -147,6 +147,11 @@ const ATTACHMENT_RESPONSES = new Map<string, AttachmentResponse>([
|
||||
},
|
||||
],
|
||||
["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<string, number>();
|
||||
@@ -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<string, unknown> = {
|
||||
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: {
|
||||
|
||||
@@ -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<string>();
|
||||
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<typeof vi.fn>) => {
|
||||
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" });
|
||||
|
||||
@@ -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<void> =>
|
||||
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<void> => {
|
||||
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<void> | 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
|
||||
|
||||
@@ -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:")) {
|
||||
const decoded = decodeDataURL(payload);
|
||||
// FileReader stores drafts as base64, so other encodings indicate corruption.
|
||||
if (!decoded?.isBase64) {
|
||||
return null;
|
||||
}
|
||||
const header = payload.slice(0, commaIndex);
|
||||
if (!header.toLowerCase().includes(";base64")) {
|
||||
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 => {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
type DecodedDataURL = {
|
||||
mediaType: string;
|
||||
isBase64: boolean;
|
||||
bytes: Uint8Array<ArrayBuffer>;
|
||||
};
|
||||
|
||||
// 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;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user