fix(frontend): guard message copy clipboard access (#3211)

* fix(frontend): guard message copy clipboard access

* fix(frontend): reuse clipboard guard across copy actions
This commit is contained in:
Admire
2026-05-26 09:37:51 +08:00
committed by GitHub
parent 11dd5b0683
commit f68bcb771c
6 changed files with 223 additions and 21 deletions
@@ -1,6 +1,7 @@
"use client"; "use client";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { writeTextToClipboard } from "@/core/clipboard";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { CheckIcon, CopyIcon } from "lucide-react"; import { CheckIcon, CopyIcon } from "lucide-react";
import { import {
@@ -146,20 +147,20 @@ export const CodeBlockCopyButton = ({
const [isCopied, setIsCopied] = useState(false); const [isCopied, setIsCopied] = useState(false);
const { code } = useContext(CodeBlockContext); const { code } = useContext(CodeBlockContext);
const copyToClipboard = async () => { const copyToClipboard = () => {
if (typeof window === "undefined" || !navigator?.clipboard?.writeText) { void (async () => {
const didCopy = await writeTextToClipboard(code);
if (!didCopy) {
onError?.(new Error("Clipboard API not available")); onError?.(new Error("Clipboard API not available"));
return; return;
} }
try {
await navigator.clipboard.writeText(code);
setIsCopied(true); setIsCopied(true);
onCopy?.(); onCopy?.();
setTimeout(() => setIsCopied(false), timeout); setTimeout(() => setIsCopied(false), timeout);
} catch (error) { })().catch((error) => {
onError?.(error as Error); onError?.(error as Error);
} });
}; };
const Icon = isCopied ? CheckIcon : CopyIcon; const Icon = isCopied ? CheckIcon : CopyIcon;
@@ -38,6 +38,7 @@ import {
HTML_PREVIEW_SCROLL_MESSAGE_SOURCE, HTML_PREVIEW_SCROLL_MESSAGE_SOURCE,
} from "@/core/artifacts/preview"; } from "@/core/artifacts/preview";
import { urlOfArtifact } from "@/core/artifacts/utils"; import { urlOfArtifact } from "@/core/artifacts/utils";
import { writeTextToClipboard } from "@/core/clipboard";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { findToolCallResult } from "@/core/messages/utils"; import { findToolCallResult } from "@/core/messages/utils";
import { installSkill } from "@/core/skills/api"; import { installSkill } from "@/core/skills/api";
@@ -237,14 +238,20 @@ export function ArtifactFileDetail({
icon={CopyIcon} icon={CopyIcon}
label={t.clipboard.copyToClipboard} label={t.clipboard.copyToClipboard}
disabled={!content} disabled={!content}
onClick={async () => { onClick={() => {
try { void (async () => {
await navigator.clipboard.writeText(visibleContent ?? ""); const didCopy = await writeTextToClipboard(
toast.success(t.clipboard.copiedToClipboard); visibleContent ?? "",
} catch (error) { );
toast.error("Failed to copy to clipboard"); if (!didCopy) {
console.error(error); toast.error(t.clipboard.failedToCopyToClipboard);
return;
} }
toast.success(t.clipboard.copiedToClipboard);
})().catch(() => {
toast.error(t.clipboard.failedToCopyToClipboard);
});
}} }}
tooltip={t.clipboard.copyToClipboard} tooltip={t.clipboard.copyToClipboard}
/> />
@@ -1,7 +1,9 @@
import { CheckIcon, CopyIcon } from "lucide-react"; import { CheckIcon, CopyIcon } from "lucide-react";
import { useCallback, useState, type ComponentProps } from "react"; import { useCallback, useState, type ComponentProps } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { writeTextToClipboard } from "@/core/clipboard";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { Tooltip } from "./tooltip"; import { Tooltip } from "./tooltip";
@@ -15,10 +17,19 @@ export function CopyButton({
const { t } = useI18n(); const { t } = useI18n();
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const handleCopy = useCallback(() => { const handleCopy = useCallback(() => {
void navigator.clipboard.writeText(clipboardData); void (async () => {
const didCopy = await writeTextToClipboard(clipboardData);
if (!didCopy) {
toast.error(t.clipboard.failedToCopyToClipboard);
return;
}
setCopied(true); setCopied(true);
setTimeout(() => setCopied(false), 2000); setTimeout(() => setCopied(false), 2000);
}, [clipboardData]); })().catch(() => {
toast.error(t.clipboard.failedToCopyToClipboard);
});
}, [clipboardData, t.clipboard.failedToCopyToClipboard]);
return ( return (
<Tooltip content={t.clipboard.copyToClipboard}> <Tooltip content={t.clipboard.copyToClipboard}>
<Button <Button
@@ -43,6 +43,7 @@ import {
SidebarMenuItem, SidebarMenuItem,
} from "@/components/ui/sidebar"; } from "@/components/ui/sidebar";
import { getAPIClient } from "@/core/api"; import { getAPIClient } from "@/core/api";
import { writeTextToClipboard } from "@/core/clipboard";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { import {
exportThreadAsJSON, exportThreadAsJSON,
@@ -126,7 +127,12 @@ export function RecentChatList() {
const baseUrl = isLocalhost ? VERCEL_URL : window.location.origin; const baseUrl = isLocalhost ? VERCEL_URL : window.location.origin;
const shareUrl = `${baseUrl}${pathOfThread(thread)}`; const shareUrl = `${baseUrl}${pathOfThread(thread)}`;
try { try {
await navigator.clipboard.writeText(shareUrl); const didCopy = await writeTextToClipboard(shareUrl);
if (!didCopy) {
toast.error(t.clipboard.failedToCopyToClipboard);
return;
}
toast.success(t.clipboard.linkCopied); toast.success(t.clipboard.linkCopied);
} catch { } catch {
toast.error(t.clipboard.failedToCopyToClipboard); toast.error(t.clipboard.failedToCopyToClipboard);
+31
View File
@@ -0,0 +1,31 @@
export async function writeTextToClipboard(text: string): Promise<boolean> {
try {
const clipboard = globalThis.navigator?.clipboard;
if (clipboard?.writeText) {
await clipboard.writeText(text);
return true;
}
const document = globalThis.document;
if (!document?.body?.appendChild || !document.execCommand) {
return false;
}
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.setAttribute("readonly", "");
textarea.style.position = "fixed";
textarea.style.top = "-9999px";
textarea.style.left = "-9999px";
document.body.appendChild(textarea);
textarea.select();
try {
return document.execCommand("copy");
} finally {
textarea.remove();
}
} catch {
return false;
}
}
+146
View File
@@ -0,0 +1,146 @@
import { afterEach, expect, test, vi } from "vitest";
import { writeTextToClipboard } from "@/core/clipboard";
const originalNavigator = globalThis.navigator;
const hadOriginalNavigator = "navigator" in globalThis;
const originalDocument = globalThis.document;
const hadOriginalDocument = "document" in globalThis;
afterEach(() => {
vi.restoreAllMocks();
if (!hadOriginalNavigator) {
Reflect.deleteProperty(globalThis, "navigator");
} else {
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: originalNavigator,
});
}
if (!hadOriginalDocument) {
Reflect.deleteProperty(globalThis, "document");
} else {
Object.defineProperty(globalThis, "document", {
configurable: true,
value: originalDocument,
});
}
});
test("writes text with the Clipboard API when available", async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: {
clipboard: {
writeText,
},
},
});
await expect(writeTextToClipboard("hello")).resolves.toBe(true);
expect(writeText).toHaveBeenCalledWith("hello");
});
test("returns false when Clipboard API is unavailable", async () => {
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: {},
});
Object.defineProperty(globalThis, "document", {
configurable: true,
value: undefined,
});
await expect(writeTextToClipboard("hello")).resolves.toBe(false);
});
test("falls back to execCommand when Clipboard API is unavailable", async () => {
const textarea = {
remove: vi.fn(),
select: vi.fn(),
setAttribute: vi.fn(),
style: {},
value: "",
};
const appendChild = vi.fn();
const execCommand = vi.fn().mockReturnValue(true);
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: {},
});
Object.defineProperty(globalThis, "document", {
configurable: true,
value: {
body: {
appendChild,
},
createElement: vi.fn().mockReturnValue(textarea),
execCommand,
},
});
await expect(writeTextToClipboard("hello")).resolves.toBe(true);
expect(textarea.value).toBe("hello");
expect(appendChild).toHaveBeenCalledWith(textarea);
expect(textarea.select).toHaveBeenCalled();
expect(execCommand).toHaveBeenCalledWith("copy");
expect(textarea.remove).toHaveBeenCalled();
});
test("returns false when execCommand fallback fails", async () => {
const textarea = {
remove: vi.fn(),
select: vi.fn(),
setAttribute: vi.fn(),
style: {},
value: "",
};
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: {},
});
Object.defineProperty(globalThis, "document", {
configurable: true,
value: {
body: {
appendChild: vi.fn(),
},
createElement: vi.fn().mockReturnValue(textarea),
execCommand: vi.fn().mockReturnValue(false),
},
});
await expect(writeTextToClipboard("hello")).resolves.toBe(false);
expect(textarea.remove).toHaveBeenCalled();
});
test("returns false when navigator is unavailable", async () => {
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: undefined,
});
Object.defineProperty(globalThis, "document", {
configurable: true,
value: undefined,
});
await expect(writeTextToClipboard("hello")).resolves.toBe(false);
});
test("returns false when Clipboard API rejects", async () => {
const writeText = vi.fn().mockRejectedValue(new Error("denied"));
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: {
clipboard: {
writeText,
},
},
});
await expect(writeTextToClipboard("hello")).resolves.toBe(false);
});