From 0fb7a1130ed07b5b6974e9c71563cb5066cf97b5 Mon Sep 17 00:00:00 2001 From: Moviw <1973559479@qq.com> Date: Thu, 18 Jun 2026 22:10:46 +0900 Subject: [PATCH] fix: safely copy text without Clipboard API --- .../agents/connect-agents-empty.tsx | 21 ++-- .../go/web/components/chat/chat-message.tsx | 3 +- .../layout/workspace-switcher-menu.tsx | 15 ++- .../go/web/hooks/use-copy-to-clipboard.ts | 7 +- packages/go/web/lib/clipboard.test.ts | 98 +++++++++++++++++++ packages/go/web/lib/clipboard.ts | 25 +++++ .../src/renderer/components/chat/Markdown.tsx | 3 +- .../components/credentials/CredentialCard.tsx | 3 +- .../launcher/src/renderer/lib/clipboard.ts | 25 +++++ .../install/detail/detail-quick-start.tsx | 3 +- .../detail/detail-unmanaged-notice.tsx | 3 +- .../pages/install/detail/use-agent-detail.ts | 3 +- .../src/renderer/pages/logs/index.tsx | 4 +- .../settings/sections/runtime-section.tsx | 3 +- .../src/renderer/pages/workspaces/index.tsx | 3 +- sdk/studio/src/components/chat/CodeBlock.tsx | 15 +-- sdk/studio/src/hooks/use-copy-to-clipboard.ts | 7 +- .../src/pages/admin/ConnectionGuide.tsx | 15 +-- .../src/pages/admin/TransportConfig.tsx | 11 ++- .../dashboard/NetworkStatusPanel.tsx | 14 +-- .../components/ServiceAgentDetail.tsx | 3 +- sdk/studio/src/stores/llmLogStore.ts | 16 +-- .../src/utils/__tests__/clipboard.test.ts | 90 +++++++++++++++++ sdk/studio/src/utils/clipboard.ts | 25 +++++ .../frontend/components/chat/chat-message.tsx | 3 +- .../frontend/components/chat/empty-state.tsx | 11 ++- .../components/connect/connect-agent-view.tsx | 13 ++- .../components/knowledge/knowledge-view.tsx | 3 +- .../frontend/components/layout/user-menu.tsx | 16 +-- .../frontend/hooks/use-copy-to-clipboard.ts | 7 +- workspace/frontend/lib/clipboard.ts | 25 +++++ 31 files changed, 382 insertions(+), 111 deletions(-) create mode 100644 packages/go/web/lib/clipboard.test.ts create mode 100644 packages/go/web/lib/clipboard.ts create mode 100644 packages/launcher/src/renderer/lib/clipboard.ts create mode 100644 sdk/studio/src/utils/__tests__/clipboard.test.ts create mode 100644 sdk/studio/src/utils/clipboard.ts create mode 100644 workspace/frontend/lib/clipboard.ts diff --git a/packages/go/web/components/agents/connect-agents-empty.tsx b/packages/go/web/components/agents/connect-agents-empty.tsx index 3d186893c..2ade643d1 100644 --- a/packages/go/web/components/agents/connect-agents-empty.tsx +++ b/packages/go/web/components/agents/connect-agents-empty.tsx @@ -10,6 +10,7 @@ import { useState } from 'react'; import { Cpu, Copy, Check, Eye, EyeOff, RefreshCw, ArrowUpRight, Download } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { useWorkspace } from '@/lib/workspace-context'; +import { copyTextToClipboard } from '@/lib/clipboard'; // Public download page for the desktop Launcher (placeholder — point at the // real release page before shipping). @@ -120,10 +121,12 @@ function StepCard({ function CommandRow({ command }: { command: string }) { const [copied, setCopied] = useState(false); - const copy = () => { - navigator.clipboard.writeText(command); - setCopied(true); - setTimeout(() => setCopied(false), 1400); + const copy = async () => { + try { + await copyTextToClipboard(command); + setCopied(true); + setTimeout(() => setCopied(false), 1400); + } catch {} }; return (
@@ -150,10 +153,12 @@ function LabeledValue({ }) { const [revealed, setRevealed] = useState(false); const [copied, setCopied] = useState(false); - const copy = () => { - navigator.clipboard.writeText(value); - setCopied(true); - setTimeout(() => setCopied(false), 1400); + const copy = async () => { + try { + await copyTextToClipboard(value); + setCopied(true); + setTimeout(() => setCopied(false), 1400); + } catch {} }; const shown = secret && !revealed ? '•'.repeat(Math.min(value.length, 24)) : value; return ( diff --git a/packages/go/web/components/chat/chat-message.tsx b/packages/go/web/components/chat/chat-message.tsx index 832e15e09..a05a2b5e4 100644 --- a/packages/go/web/components/chat/chat-message.tsx +++ b/packages/go/web/components/chat/chat-message.tsx @@ -11,6 +11,7 @@ import { MarkdownContent } from './markdown-content'; import { workspaceApi } from '@/lib/api'; import { useLayout } from '@/components/layout/layout-context'; import { useWorkspace } from '@/lib/workspace-context'; +import { copyTextToClipboard } from '@/lib/clipboard'; interface Attachment { fileId: string; @@ -122,7 +123,7 @@ export const ChatMessage = memo(function ChatMessage({ message, agents = [] }: C const handleCopy = async () => { try { - await navigator.clipboard.writeText(message.content); + await copyTextToClipboard(message.content); setCopied(true); setTimeout(() => setCopied(false), 2000); } catch { diff --git a/packages/go/web/components/layout/workspace-switcher-menu.tsx b/packages/go/web/components/layout/workspace-switcher-menu.tsx index 3103e7021..c4715366b 100644 --- a/packages/go/web/components/layout/workspace-switcher-menu.tsx +++ b/packages/go/web/components/layout/workspace-switcher-menu.tsx @@ -42,6 +42,7 @@ import { Switch } from '@/components/ui/switch'; import { useWorkspace } from '@/lib/workspace-context'; import { useOpenAgentsAuth } from '@/lib/openagents-auth-context'; import { cn } from '@/lib/utils'; +import { copyTextToClipboard } from '@/lib/clipboard'; import { WorkspaceHistory, parseWorkspaceURL, @@ -176,15 +177,19 @@ function WorkspaceSelectorDialog({ connectTo(parsed.workspaceId, parsed.token); }; - const handleCopyToken = () => { + const handleCopyToken = async () => { if (!token) { toast.error('No workspace token available'); return; } - navigator.clipboard.writeText(token); - setTokenCopied(true); - toast.success('Workspace token copied'); - setTimeout(() => setTokenCopied(false), 1500); + try { + await copyTextToClipboard(token); + setTokenCopied(true); + toast.success('Workspace token copied'); + setTimeout(() => setTokenCopied(false), 1500); + } catch { + toast.error('Failed to copy workspace token'); + } }; // Top three recents, excluding the current workspace (Swift renders diff --git a/packages/go/web/hooks/use-copy-to-clipboard.ts b/packages/go/web/hooks/use-copy-to-clipboard.ts index 0e1e9e740..eb1924b74 100644 --- a/packages/go/web/hooks/use-copy-to-clipboard.ts +++ b/packages/go/web/hooks/use-copy-to-clipboard.ts @@ -1,6 +1,7 @@ 'use client'; import * as React from 'react'; +import { copyTextToClipboard as copyText } from '@/lib/clipboard'; export function useCopyToClipboard({ timeout = 2000, @@ -12,13 +13,9 @@ export function useCopyToClipboard({ const [isCopied, setIsCopied] = React.useState(false); const copyToClipboard = (value: string) => { - if (typeof window === 'undefined' || !navigator.clipboard.writeText) { - return; - } - if (!value) return; - navigator.clipboard.writeText(value).then(() => { + copyText(value).then(() => { setIsCopied(true); if (onCopy) { diff --git a/packages/go/web/lib/clipboard.test.ts b/packages/go/web/lib/clipboard.test.ts new file mode 100644 index 000000000..1043e19aa --- /dev/null +++ b/packages/go/web/lib/clipboard.test.ts @@ -0,0 +1,98 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { copyTextToClipboard } from './clipboard'; + +function installFallback(execResult: boolean) { + const remove = vi.fn(); + const textArea = { + value: '', + style: {} as Record, + setAttribute: vi.fn(), + select: vi.fn(), + setSelectionRange: vi.fn(), + remove, + }; + const appendChild = vi.fn(); + const execCommand = vi.fn(() => execResult); + + vi.stubGlobal('document', { + body: { appendChild }, + createElement: vi.fn(() => textArea), + execCommand, + }); + + return { appendChild, execCommand, remove, textArea }; +} + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe('copyTextToClipboard', () => { + it('uses the Clipboard API when available', async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + vi.stubGlobal('navigator', { clipboard: { writeText } }); + const fallback = installFallback(true); + + await copyTextToClipboard('hello'); + + expect(writeText).toHaveBeenCalledWith('hello'); + expect(fallback.appendChild).not.toHaveBeenCalled(); + }); + + it('falls back when navigator.clipboard is unavailable', async () => { + vi.stubGlobal('navigator', {}); + const fallback = installFallback(true); + + await copyTextToClipboard('fallback'); + + expect(fallback.textArea.value).toBe('fallback'); + expect(fallback.textArea.setAttribute).toHaveBeenCalledWith('readonly', ''); + expect(fallback.textArea.select).toHaveBeenCalledOnce(); + expect(fallback.textArea.setSelectionRange).toHaveBeenCalledWith(0, 8); + expect(fallback.execCommand).toHaveBeenCalledWith('copy'); + expect(fallback.remove).toHaveBeenCalledOnce(); + }); + + it('falls back when navigator is unavailable', async () => { + vi.stubGlobal('navigator', undefined); + const fallback = installFallback(true); + + await copyTextToClipboard('server-safe'); + + expect(fallback.execCommand).toHaveBeenCalledWith('copy'); + expect(fallback.remove).toHaveBeenCalledOnce(); + }); + + it('falls back when Clipboard API writing is rejected', async () => { + const writeText = vi.fn().mockRejectedValue(new Error('denied')); + vi.stubGlobal('navigator', { clipboard: { writeText } }); + const fallback = installFallback(true); + + await copyTextToClipboard('retry'); + + expect(fallback.execCommand).toHaveBeenCalledWith('copy'); + expect(fallback.remove).toHaveBeenCalledOnce(); + }); + + it('rejects when the fallback reports failure', async () => { + vi.stubGlobal('navigator', {}); + const fallback = installFallback(false); + + await expect(copyTextToClipboard('nope')).rejects.toThrow( + 'Failed to copy text to clipboard', + ); + expect(fallback.remove).toHaveBeenCalledOnce(); + }); + + it('cleans up when the fallback throws', async () => { + vi.stubGlobal('navigator', {}); + const fallback = installFallback(true); + fallback.execCommand.mockImplementation(() => { + throw new Error('blocked'); + }); + + await expect(copyTextToClipboard('nope')).rejects.toThrow('blocked'); + expect(fallback.remove).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/go/web/lib/clipboard.ts b/packages/go/web/lib/clipboard.ts new file mode 100644 index 000000000..d76fca3f3 --- /dev/null +++ b/packages/go/web/lib/clipboard.ts @@ -0,0 +1,25 @@ +export async function copyTextToClipboard(text: string): Promise { + if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(text); + return; + } catch {} + } + + const textArea = document.createElement('textarea'); + textArea.value = text; + textArea.setAttribute('readonly', ''); + textArea.style.position = 'fixed'; + textArea.style.left = '-9999px'; + document.body.appendChild(textArea); + + try { + textArea.select(); + textArea.setSelectionRange(0, text.length); + if (!document.execCommand('copy')) { + throw new Error('Failed to copy text to clipboard'); + } + } finally { + textArea.remove(); + } +} diff --git a/packages/launcher/src/renderer/components/chat/Markdown.tsx b/packages/launcher/src/renderer/components/chat/Markdown.tsx index e5666f823..5c74eaa64 100644 --- a/packages/launcher/src/renderer/components/chat/Markdown.tsx +++ b/packages/launcher/src/renderer/components/chat/Markdown.tsx @@ -1,6 +1,7 @@ import React, { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { cn } from '../../lib/utils' +import { copyTextToClipboard } from '../../lib/clipboard' // Lightweight Markdown renderer — supports the subset called out in stage3.md: // headings, paragraphs, bold/italic, inline code, fenced code blocks with copy, @@ -161,7 +162,7 @@ function CodeBlock({ code, lang }: { code: string; lang?: string }): React.JSX.E const [copied, setCopied] = useState(false) const copy = async (): Promise => { try { - await navigator.clipboard.writeText(code) + await copyTextToClipboard(code) setCopied(true) setTimeout(() => setCopied(false), 1500) } catch {} diff --git a/packages/launcher/src/renderer/components/credentials/CredentialCard.tsx b/packages/launcher/src/renderer/components/credentials/CredentialCard.tsx index 96e7a6f4a..62f470163 100644 --- a/packages/launcher/src/renderer/components/credentials/CredentialCard.tsx +++ b/packages/launcher/src/renderer/components/credentials/CredentialCard.tsx @@ -9,6 +9,7 @@ import { PlatformLogo } from "../connections/PlatformLogo" import { getPlatform } from "../connections/platforms" import { CredentialUsage } from "./CredentialUsage" import type { CredentialSummary } from "../../types" +import { copyTextToClipboard } from "../../lib/clipboard" interface Props { @@ -39,7 +40,7 @@ export function CredentialCard({ const copySecret = async (): Promise => { if (!revealed) return try { - await navigator.clipboard.writeText(revealed) + await copyTextToClipboard(revealed) setCopied(true) setTimeout(() => setCopied(false), 1500) } catch {} diff --git a/packages/launcher/src/renderer/lib/clipboard.ts b/packages/launcher/src/renderer/lib/clipboard.ts new file mode 100644 index 000000000..613c0e384 --- /dev/null +++ b/packages/launcher/src/renderer/lib/clipboard.ts @@ -0,0 +1,25 @@ +export async function copyTextToClipboard(text: string): Promise { + if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(text) + return + } catch {} + } + + const textArea = document.createElement("textarea") + textArea.value = text + textArea.setAttribute("readonly", "") + textArea.style.position = "fixed" + textArea.style.left = "-9999px" + document.body.appendChild(textArea) + + try { + textArea.select() + textArea.setSelectionRange(0, text.length) + if (!document.execCommand("copy")) { + throw new Error("Failed to copy text to clipboard") + } + } finally { + textArea.remove() + } +} diff --git a/packages/launcher/src/renderer/pages/install/detail/detail-quick-start.tsx b/packages/launcher/src/renderer/pages/install/detail/detail-quick-start.tsx index 8dfebdc2a..868e72b00 100644 --- a/packages/launcher/src/renderer/pages/install/detail/detail-quick-start.tsx +++ b/packages/launcher/src/renderer/pages/install/detail/detail-quick-start.tsx @@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next" import { Button } from "@renderer/components/ui/button" import type { CatalogEntry } from "@renderer/types" import type { ToastType } from "@renderer/hooks/useToast" +import { copyTextToClipboard } from "@renderer/lib/clipboard" interface Props { entry: CatalogEntry @@ -53,7 +54,7 @@ export function DetailQuickStart({ entry, showToast }: Props): React.JSX.Element async function copy(cmd: string): Promise { try { - await navigator.clipboard.writeText(cmd) + await copyTextToClipboard(cmd) setCopied(cmd) window.setTimeout(() => setCopied((c) => (c === cmd ? null : c)), 1500) } catch { diff --git a/packages/launcher/src/renderer/pages/install/detail/detail-unmanaged-notice.tsx b/packages/launcher/src/renderer/pages/install/detail/detail-unmanaged-notice.tsx index 648dab717..c1e09767c 100644 --- a/packages/launcher/src/renderer/pages/install/detail/detail-unmanaged-notice.tsx +++ b/packages/launcher/src/renderer/pages/install/detail/detail-unmanaged-notice.tsx @@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next" import { Button } from "@renderer/components/ui/button" import { globalUninstallCommand } from "../../../../shared/npm-install-spec" import type { CatalogEntry } from "@renderer/types" +import { copyTextToClipboard } from "@renderer/lib/clipboard" function detectPlatform(): "macos" | "linux" | "windows" { if (typeof navigator === "undefined") return "linux" @@ -42,7 +43,7 @@ export function UnmanagedNotice({ async function copy(): Promise { if (!command) return try { - await navigator.clipboard.writeText(command) + await copyTextToClipboard(command) setCopied(true) window.setTimeout(() => setCopied(false), 1500) } catch { diff --git a/packages/launcher/src/renderer/pages/install/detail/use-agent-detail.ts b/packages/launcher/src/renderer/pages/install/detail/use-agent-detail.ts index 6c3266ad1..23673128b 100644 --- a/packages/launcher/src/renderer/pages/install/detail/use-agent-detail.ts +++ b/packages/launcher/src/renderer/pages/install/detail/use-agent-detail.ts @@ -18,6 +18,7 @@ import type { InstalledAgentRecord, } from "@renderer/types" import type { ToastType } from "@renderer/hooks/useToast" +import { copyTextToClipboard } from "@renderer/lib/clipboard" import type { VersionEntry } from "./detail-versions" @@ -241,7 +242,7 @@ export function useAgentDetail({ const copyLog = useCallback(async () => { try { - await navigator.clipboard.writeText(job?.log || "") + await copyTextToClipboard(job?.log || "") showToast(t("agents.detail.toast.logCopied"), "success") } catch { showToast(t("agents.detail.toast.logCopyFailed"), "error") diff --git a/packages/launcher/src/renderer/pages/logs/index.tsx b/packages/launcher/src/renderer/pages/logs/index.tsx index a914e804b..1321f35e9 100644 --- a/packages/launcher/src/renderer/pages/logs/index.tsx +++ b/packages/launcher/src/renderer/pages/logs/index.tsx @@ -21,6 +21,7 @@ import { } from "@renderer/components/ui/select" import { Tabs, TabsList, TabsTrigger } from "@renderer/components/ui/tabs" import { cn } from "@renderer/lib/utils" +import { copyTextToClipboard } from "@renderer/lib/clipboard" import { useAgentsStore } from "@renderer/store/agents" import { formatDateTime } from "@renderer/services/logs/log-metrics" import type { ParsedLog } from "@renderer/services/logs/log-parser" @@ -76,8 +77,7 @@ export default function Logs({ showToast }: LogsProps): React.JSX.Element { } const copy = (text: string): void => { - navigator.clipboard - .writeText(text) + copyTextToClipboard(text) .then(() => showToast(t("logs.toast.copied"), "success")) .catch(() => showToast(t("logs.toast.copyFailed"), "error")) } diff --git a/packages/launcher/src/renderer/pages/settings/sections/runtime-section.tsx b/packages/launcher/src/renderer/pages/settings/sections/runtime-section.tsx index 89551abce..323cfeb25 100644 --- a/packages/launcher/src/renderer/pages/settings/sections/runtime-section.tsx +++ b/packages/launcher/src/renderer/pages/settings/sections/runtime-section.tsx @@ -1,6 +1,7 @@ import React from "react" import { useTranslation } from "react-i18next" import { ClipboardCopy, FolderOpen, RefreshCw } from "lucide-react" +import { copyTextToClipboard } from "@renderer/lib/clipboard" import { Badge } from "@renderer/components/ui/badge" import { Button } from "@renderer/components/ui/button" @@ -61,7 +62,7 @@ export function RuntimeSection({ `Logs: ${paths.logs ?? "n/a"}`, ].filter(Boolean) try { - await navigator.clipboard.writeText(lines.join("\n")) + await copyTextToClipboard(lines.join("\n")) showToast(t("settings.runtime.diagnosticsCopied"), "success") } catch { showToast(t("settings.runtime.diagnosticsCopyFailed"), "error") diff --git a/packages/launcher/src/renderer/pages/workspaces/index.tsx b/packages/launcher/src/renderer/pages/workspaces/index.tsx index 43f49fd54..2c59fdc79 100644 --- a/packages/launcher/src/renderer/pages/workspaces/index.tsx +++ b/packages/launcher/src/renderer/pages/workspaces/index.tsx @@ -20,6 +20,7 @@ import { useConnectionsStore } from "@renderer/store/connections" import { useUiStore } from "@renderer/store/ui" import { useWorkspacePrefs } from "@renderer/store/workspace-prefs" import { workspaceUrl } from "@renderer/lib/workspace-urls" +import { copyTextToClipboard } from "@renderer/lib/clipboard" import type { Workspace } from "@renderer/types" import type { ToastType } from "@renderer/hooks/useToast" import { @@ -79,7 +80,7 @@ export default function Workspaces({ showToast }: Props): React.JSX.Element { const copyUrl = async (ws: Workspace): Promise => { markUsed(ws.id) try { - await navigator.clipboard.writeText(workspaceUrl(ws)) + await copyTextToClipboard(workspaceUrl(ws)) showToast(t("workspaces.toast.urlCopied"), "success") } catch { showToast(t("workspaces.toast.copyFailed"), "error") diff --git a/sdk/studio/src/components/chat/CodeBlock.tsx b/sdk/studio/src/components/chat/CodeBlock.tsx index 1b5cf0f52..57c9273fa 100644 --- a/sdk/studio/src/components/chat/CodeBlock.tsx +++ b/sdk/studio/src/components/chat/CodeBlock.tsx @@ -2,6 +2,7 @@ import React, { useState } from 'react'; import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; import { oneDark, oneLight } from 'react-syntax-highlighter/dist/esm/styles/prism'; import { useThemeStore } from '../../stores/themeStore'; +import { copyTextToClipboard } from '../../utils/clipboard'; interface CodeBlockProps { code: string; @@ -12,10 +13,12 @@ const CodeBlock: React.FC = ({ code, language }) => { const { theme } = useThemeStore(); const [copied, setCopied] = useState(false); - const copyToClipboard = () => { - navigator.clipboard.writeText(code); - setCopied(true); - setTimeout(() => setCopied(false), 2000); + const handleCopy = async () => { + try { + await copyTextToClipboard(code); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch {} }; // Map common language aliases to proper ones @@ -53,7 +56,7 @@ const CodeBlock: React.FC = ({ code, language }) => {
{normalizedLanguage}