diff --git a/skyvern-frontend/src/components/BrowserStream.tsx b/skyvern-frontend/src/components/BrowserStream.tsx index b80ccde9..1a4cf311 100644 --- a/skyvern-frontend/src/components/BrowserStream.tsx +++ b/skyvern-frontend/src/components/BrowserStream.tsx @@ -37,6 +37,7 @@ import { newWssBaseUrl, getRuntimeApiKey, } from "@/util/env"; +import { copyText } from "@/util/copyText"; import { cn } from "@/util/utils"; import { RotateThrough } from "./RotateThrough"; @@ -698,14 +699,21 @@ function BrowserStream({ case "copied-text": { const text = message.text; - navigator.clipboard - .writeText(text) - .then(() => { - toast({ - title: "Copied to Clipboard", - description: - "The text has been copied to your clipboard. NOTE: copy-paste only works in the web page - not in the browser (like the address bar).", - }); + copyText(text) + .then((success) => { + if (success) { + toast({ + title: "Copied to Clipboard", + description: + "The text has been copied to your clipboard. NOTE: copy-paste only works in the web page - not in the browser (like the address bar).", + }); + } else { + toast({ + variant: "destructive", + title: "Failed to write to Clipboard", + description: "The text could not be copied to your clipboard.", + }); + } }) .catch((err) => { console.error("Failed to write to clipboard:", err); diff --git a/skyvern-frontend/src/components/CopyButton.tsx b/skyvern-frontend/src/components/CopyButton.tsx index a4dfc1b2..2c12ddd5 100644 --- a/skyvern-frontend/src/components/CopyButton.tsx +++ b/skyvern-frontend/src/components/CopyButton.tsx @@ -2,15 +2,16 @@ import { useState } from "react"; import { CheckIcon, CopyIcon } from "@radix-ui/react-icons"; import { Button } from "@/components/ui/button"; +import { copyText } from "@/util/copyText"; function CopyButton({ value }: { value: string }) { const [copied, setCopied] = useState(false); - const handleCopy = () => { + const handleCopy = async () => { if (copied) { return; } - window.navigator.clipboard.writeText(value); + await copyText(value); setCopied(true); setTimeout(() => setCopied(false), 1500); }; diff --git a/skyvern-frontend/src/routes/workflows/editor/Workspace.tsx b/skyvern-frontend/src/routes/workflows/editor/Workspace.tsx index 60af0c0c..09577a87 100644 --- a/skyvern-frontend/src/routes/workflows/editor/Workspace.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/Workspace.tsx @@ -73,6 +73,7 @@ import { import { useWorkflowParametersStore } from "@/store/WorkflowParametersStore"; import { getCode, getOrderedBlockLabels } from "@/routes/workflows/utils"; import { DebuggerBlockRuns } from "@/routes/workflows/debugger/DebuggerBlockRuns"; +import { copyText } from "@/util/copyText"; import { cn } from "@/util/utils"; import { FlowRenderer, type FlowRendererProps } from "./FlowRenderer"; @@ -196,8 +197,8 @@ function CopyAndExplainCode({ code }: { code: string }) { function CopyText({ className, text }: { className?: string; text: string }) { const [wasCopied, setWasCopied] = useState(false); - function handleCopy(code: string) { - navigator.clipboard.writeText(code); + async function handleCopy(code: string) { + await copyText(code); setWasCopied(true); setTimeout(() => setWasCopied(false), 2000); } diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/HttpRequestNode/CurlImportDialog.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/HttpRequestNode/CurlImportDialog.tsx index e3569358..00ee4977 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/HttpRequestNode/CurlImportDialog.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/HttpRequestNode/CurlImportDialog.tsx @@ -21,6 +21,7 @@ import { } from "@radix-ui/react-icons"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Badge } from "@/components/ui/badge"; +import { copyText } from "@/util/copyText"; type Props = { onImport: (data: { @@ -156,12 +157,20 @@ export function CurlImportDialog({ onImport, children }: Props) { } }; - const copyExample = (example: string) => { - navigator.clipboard.writeText(example); - toast({ - title: "Copied", - description: "Example copied to clipboard", - }); + const copyExample = async (example: string) => { + const success = await copyText(example); + if (success) { + toast({ + title: "Copied", + description: "Example copied to clipboard", + }); + } else { + toast({ + title: "Error", + description: "Failed to copy example", + variant: "destructive", + }); + } }; return ( diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/HttpRequestNode/HttpUtils.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/HttpRequestNode/HttpUtils.tsx index 6a3ea657..9f94732d 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/HttpRequestNode/HttpUtils.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/HttpRequestNode/HttpUtils.tsx @@ -8,6 +8,7 @@ import { } from "@radix-ui/react-icons"; import { useState } from "react"; import { toast } from "@/components/ui/use-toast"; +import { copyText } from "@/util/copyText"; import { cn } from "@/util/utils"; // HTTP Method Badge Component @@ -140,16 +141,16 @@ export function CopyToCurlButton({ }; const handleCopy = async () => { - try { - const curlCommand = generateCurlCommand(); - await navigator.clipboard.writeText(curlCommand); + const curlCommand = generateCurlCommand(); + const success = await copyText(curlCommand); + if (success) { setCopied(true); toast({ title: "Copied!", description: "cURL command copied to clipboard", }); setTimeout(() => setCopied(false), 2000); - } catch (error) { + } else { toast({ title: "Error", description: "Failed to copy cURL command", diff --git a/skyvern-frontend/src/util/copyText.ts b/skyvern-frontend/src/util/copyText.ts index 16795e58..f9b05531 100644 --- a/skyvern-frontend/src/util/copyText.ts +++ b/skyvern-frontend/src/util/copyText.ts @@ -1,25 +1,39 @@ /** - * Progressively enhanced text copying + * Progressively enhanced text copying with HTTP fallback * https://web.dev/patterns/clipboard/copy-text + * + * Uses navigator.clipboard when in a secure context (HTTPS), + * falls back to textarea + execCommand for HTTP contexts. */ -async function copyText(text: string): Promise { - if ("clipboard" in navigator) { - return navigator.clipboard.writeText(text); - } else { - const textArea = document.createElement("textarea"); - textArea.value = text; - textArea.style.opacity = "0"; - document.body.appendChild(textArea); - textArea.focus(); - textArea.select(); - const success = document.execCommand("copy"); - document.body.removeChild(textArea); - if (success) { - return Promise.resolve(); - } else { - return Promise.reject(); +async function copyText(text: string): Promise { + // Prefer navigator.clipboard when in a secure context + if (window.isSecureContext && navigator.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(text); + return true; + } catch (err) { + // Fall through to fallback } } + + // Fallback for HTTP or when clipboard API fails + const textArea = document.createElement("textarea"); + textArea.value = text; + textArea.style.position = "fixed"; + textArea.style.opacity = "0"; + textArea.style.left = "-9999px"; + document.body.appendChild(textArea); + textArea.focus(); + textArea.select(); + + try { + // execCommand is deprecated but remains the only option for HTTP contexts + // where navigator.clipboard is unavailable. Browser support remains strong. + const success = document.execCommand("copy"); + return success; + } finally { + document.body.removeChild(textArea); + } } export { copyText };