From a129322ceda23d8445d7ef80823c1c1d841922a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B6=85=E8=B6=85=E8=B6=85=E8=B6=85=E8=B6=85=E7=BA=A7?= =?UTF-8?q?=E5=96=9C=E6=AC=A2=E4=BD=A0=E7=9A=84=E8=BE=BE=E5=A6=AE=E5=A8=85?= <176143450+My-Denia@users.noreply.github.com> Date: Fri, 25 Sep 2026 22:14:58 +0800 Subject: [PATCH] fix(ai-edition): copy chat messages via main-process clipboard on Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Electron denies the renderer's navigator.clipboard.writeText (NotAllowedError: Write permission denied — the renderer never holds clipboard-sanitized-write), so the chat panel's Copy message button always fell to its error toast and the clipboard kept its old content, in dev and packaged builds alike (issue #738). Route the write through a narrow preload bridge into main's clipboard module, which has no such permission gate: ipcMain.handle receives the text (string-checked), the preload exposes copyToClipboard, and the panel prefers the bridge while keeping the navigator path for shim/web contexts without one. Toasts and message content are unchanged. The regression test pins the distinguishing case: a navigator clipboard that rejects exactly the way Electron's does, with the bridge available — the old handler fails it, the new one copies the full message and reports success. --- electron/electron-env.d.ts | 4 + electron/ipc/handlers.ts | 14 + electron/preload.ts | 7 + .../ai-edition/LeftPanel.copyMessage.test.tsx | 245 ++++++++++++++++++ src/components/ai-edition/LeftPanel.tsx | 10 +- 5 files changed, 279 insertions(+), 1 deletion(-) create mode 100644 src/components/ai-edition/LeftPanel.copyMessage.test.tsx diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index be65ea224..bbb9944b8 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -29,6 +29,10 @@ interface Window { ) => Promise>; /** Export bench only (--bench=): tells main the run is over so it can quit. */ benchFinished?: () => Promise; + /** Clipboard write via main (issue #738): Electron denies the renderer's + * navigator.clipboard.writeText, so Copy message crosses to main's + * clipboard module. Optional: shim/web contexts have no bridge. */ + copyToClipboard?: (text: string) => Promise; /** Native (D3D) export progress — frames encoded so far, pushed at ~10 Hz max while * `compositor.export`/`compositor.exportMulti` runs. Distinct from `exportOnFrameAck`, * the OLD web/CPU pipeline's per-frame ack, not a progress signal. */ diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index fe60a1534..925efbdfc 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -9,6 +9,7 @@ import type { DesktopCapturerSource, Rectangle } from "electron"; import { app, BrowserWindow, + clipboard, desktopCapturer, dialog, ipcMain, @@ -1916,6 +1917,19 @@ export function registerIpcHandlers( const sameSelectedSource = (left: SelectedSource | null, right: SelectedSource | null) => left?.id === right?.id && left?.name === right?.name && left?.display_id === right?.display_id; + // Issue #738: the renderer's `navigator.clipboard.writeText` is always denied + // here (NotAllowedError: Write permission denied — Electron withholds the + // clipboard-sanitized-write permission from the renderer), so chat's + // "Copy message" fell to its error toast and the clipboard kept its old + // content. Main's `clipboard` module has no such permission gate; the + // preload exposes this as `copyToClipboard`. + ipcMain.handle("clipboard:write-text", (_event, text: unknown) => { + if (typeof text !== "string") { + throw new TypeError("clipboard:write-text expects a string"); + } + clipboard.writeText(text); + }); + ipcMain.handle("get-sources", async (_, opts) => { // desktopCapturer.getSources can never settle where the GL stack cannot be // reached -- a container, a CI runner, a host whose ANGLE fails to diff --git a/electron/preload.ts b/electron/preload.ts index cba14229c..dad791686 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -27,6 +27,13 @@ const PLATFORM = process.platform; contextBridge.exposeInMainWorld("electronAPI", { assetBaseUrl, + // The renderer's navigator.clipboard.writeText is denied by Electron's + // permission gate (clipboard-sanitized-write), which left chat's Copy + // message permanently on its error toast (issue #738). Main's clipboard + // module has no such gate, so the write crosses here. + copyToClipboard: (text: string) => + ipcRenderer.invoke("clipboard:write-text", text) as Promise, + // --- Native export encoder ------------------------------------------------- // The renderer composites and extracts frames but cannot spawn ffmpeg (it is // sandboxed, deliberately), so frames cross to main and it feeds ffmpeg's diff --git a/src/components/ai-edition/LeftPanel.copyMessage.test.tsx b/src/components/ai-edition/LeftPanel.copyMessage.test.tsx new file mode 100644 index 000000000..be2ab4e01 --- /dev/null +++ b/src/components/ai-edition/LeftPanel.copyMessage.test.tsx @@ -0,0 +1,245 @@ +// @vitest-environment jsdom +// Issue #738: the chat panel's Copy message button resolved the copy through +// `navigator.clipboard.writeText`, which Electron denies in the renderer +// (NotAllowedError: Write permission denied — clipboard-sanitized-write), so +// every click landed on the "Couldn't copy" toast and the clipboard kept its +// old content. The button must route through the preload bridge into main's +// `clipboard.writeText` when the bridge offers it, and fall back to the +// navigator path only where the bridge is absent (shim/web contexts). +// +// The distinguishing case is the first one: a navigator clipboard that +// rejects exactly the way Electron's does, with the bridge available. The +// pre-fix handler failed it (it called the navigator and showed the error +// toast); the post-fix handler passes it (bridge write with the exact +// message content, success toast). + +import "@testing-library/jest-dom"; +import { act, cleanup, fireEvent, render, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const ASSISTANT_CONTENT = + "Stub assistant reply for issue 738.\nSecond line with unicode: ✓ 🎬 — em dash, accént, 中文."; + +const llmGetSnapshot = vi.fn(() => + Promise.resolve({ + config: null, + connectedProviders: [], + availableProviders: [], + credentialSummary: [], + }), +); +const chatListSessions = vi.fn(() => + Promise.resolve([ + { + id: "session-1", + title: "Conversation 1", + messageCount: 2, + createdAt: "2026-09-23T00:00:00Z", + }, + ]), +); +const chatSelectSession = vi.fn(() => + Promise.resolve({ + id: "session-1", + projectId: "project-1", + title: "Conversation 1", + createdAt: "2026-09-23T00:00:00Z", + updatedAt: "2026-09-23T00:00:00Z", + messages: [ + { + id: "m1", + role: "user", + content: "Reply with exactly: stub reply ok", + createdAt: "2026-09-23T00:00:01Z", + }, + { + id: "m2", + role: "assistant", + content: ASSISTANT_CONTENT, + createdAt: "2026-09-23T00:00:02Z", + }, + { + id: "m3", + role: "assistant", + content: "", + createdAt: "2026-09-23T00:00:03Z", + }, + ], + }), +); + +vi.mock("@/native/client", () => ({ + nativeBridgeClient: { + aiEdition: { + llmGetSnapshot: () => llmGetSnapshot(), + chatListSessions: () => chatListSessions(), + chatSelectSession: () => chatSelectSession(), + chatBudget: () => Promise.resolve(null), + }, + }, +})); + +const toastSuccess = vi.fn(); +const toastError = vi.fn(); +vi.mock("sonner", () => ({ + toast: { + success: (...args: unknown[]) => toastSuccess(...args), + error: (...args: unknown[]) => toastError(...args), + }, +})); + +// The panel's copy is not what is under test, and an echoing translator keeps this file off +// the critical path of a copy edit. +vi.mock("@/contexts/I18nContext", () => ({ + useI18n: () => ({ + locale: "en", + setLocale: () => { + /* fixed locale */ + }, + }), + useScopedT: () => (key: string) => key, +})); + +// Electron's actual renderer-side rejection on Windows (captured from a dev +// run against the failing build): the async clipboard write is denied even +// with the window focused, because the renderer never holds +// clipboard-sanitized-write. +const electronRejection = () => + Promise.reject( + new DOMException( + "Failed to execute 'writeText' on 'Clipboard': Write permission denied.", + "NotAllowedError", + ), + ); + +const navigatorWriteText = vi.fn(); +const copyToClipboard = vi.fn(); + +function stubClipboard(bridgeCopy: ((text: string) => Promise) | undefined) { + navigatorWriteText.mockReset().mockImplementation(electronRejection); + copyToClipboard.mockReset().mockImplementation((text: string) => Promise.resolve(text)); + Object.defineProperty(navigator, "clipboard", { + value: { writeText: navigatorWriteText }, + configurable: true, + }); + (window as unknown as { electronAPI?: unknown }).electronAPI = { + onAiEditionChatEvent: () => () => { + /* unsubscribe */ + }, + ...(bridgeCopy ? { copyToClipboard: bridgeCopy } : {}), + }; +} + +beforeEach(() => { + llmGetSnapshot.mockClear(); + chatListSessions.mockClear(); + chatSelectSession.mockClear(); + toastSuccess.mockClear(); + toastError.mockClear(); + // jsdom implements no scrolling at all, and the transcript pins itself to the bottom on + // every render. + Element.prototype.scrollTo = () => { + /* no scrolling in jsdom */ + }; +}); + +afterEach(() => { + cleanup(); + (window as unknown as { electronAPI?: unknown }).electronAPI = undefined; + Object.defineProperty(navigator, "clipboard", { value: undefined, configurable: true }); +}); + +async function renderPanelWithAssistantMessage() { + const { useProjectStore } = await import("@/lib/ai-edition/store/projectStore"); + useProjectStore.setState({ projectId: "project-1" }); + const { EditorDialogsProvider } = await import("@/contexts/EditorDialogsContext"); + const { ChatStripPanel } = await import("./LeftPanel"); + const view = render( + + + , + ); + // Flush the session load so the assistant bubble (and its copy button) is on screen. + await waitFor(() => { + expect(view.getAllByTitle("chat.copyMessage").length).toBeGreaterThan(0); + }); + return view; +} + +describe("ChatStripPanel Copy message (issue #738)", () => { + it("copies the full message through the preload bridge and reports success, never touching the denied navigator clipboard", async () => { + stubClipboard(copyToClipboard); + const view = await renderPanelWithAssistantMessage(); + + // Two copy buttons (user + assistant); the assistant message is the last one rendered. + const copyButtons = view.getAllByTitle("chat.copyMessage"); + // user + assistant + the empty trailing assistant turn. + expect(copyButtons).toHaveLength(3); + await act(async () => { + fireEvent.click(copyButtons[1]); + await Promise.resolve(); + }); + + await waitFor(() => { + expect(toastSuccess).toHaveBeenCalledWith("chat.copiedToClipboard"); + }); + expect(copyToClipboard).toHaveBeenCalledTimes(1); + expect(copyToClipboard).toHaveBeenCalledWith(ASSISTANT_CONTENT); + expect(navigatorWriteText).not.toHaveBeenCalled(); + expect(toastError).not.toHaveBeenCalled(); + }); + + it("falls back to navigator.clipboard when the bridge offers no copy (shim/web), surfacing its failure", async () => { + stubClipboard(undefined); + const view = await renderPanelWithAssistantMessage(); + + const copyButtons = view.getAllByTitle("chat.copyMessage"); + // The fallback inherits Electron's denial in a bare context: the click + // lands on the navigator path and reports the failure toast. + await act(async () => { + fireEvent.click(copyButtons[1]); + await Promise.resolve(); + }); + await waitFor(() => { + expect(toastError).toHaveBeenCalledWith("chat.copyFailed"); + }); + expect(navigatorWriteText).toHaveBeenCalledTimes(1); + expect(navigatorWriteText).toHaveBeenCalledWith(ASSISTANT_CONTENT); + expect(toastSuccess).not.toHaveBeenCalled(); + const failuresAfterDeniedClick = toastError.mock.calls.length; + + // When the navigator allows the write, the same fallback reports success. + navigatorWriteText.mockImplementation((text: string) => Promise.resolve(text)); + await act(async () => { + fireEvent.click(copyButtons[1]); + await Promise.resolve(); + }); + await waitFor(() => { + expect(toastSuccess).toHaveBeenCalledWith("chat.copiedToClipboard"); + }); + expect(navigatorWriteText).toHaveBeenCalledTimes(2); + expect(toastError.mock.calls.length).toBe(failuresAfterDeniedClick); + }); + + it("passes empty message content through to the bridge untouched, per the product's existing semantics", async () => { + stubClipboard(copyToClipboard); + const view = await renderPanelWithAssistantMessage(); + + // The empty trailing assistant turn renders a bubble with no text; its + // copy button sits last. The handler must not branch on content — an + // empty string crosses to the bridge exactly like any other content and + // still reports success. + const copyButtons = view.getAllByTitle("chat.copyMessage"); + await act(async () => { + fireEvent.click(copyButtons[copyButtons.length - 1]); + await Promise.resolve(); + }); + + await waitFor(() => { + expect(toastSuccess).toHaveBeenCalledWith("chat.copiedToClipboard"); + }); + expect(copyToClipboard).toHaveBeenCalledWith(""); + expect(navigatorWriteText).not.toHaveBeenCalled(); + expect(toastError).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/ai-edition/LeftPanel.tsx b/src/components/ai-edition/LeftPanel.tsx index 0be40aa18..81c62c306 100644 --- a/src/components/ai-edition/LeftPanel.tsx +++ b/src/components/ai-edition/LeftPanel.tsx @@ -1331,7 +1331,15 @@ export function ChatStripPanel() { title={t("chat.copyMessage")} aria-label={t("chat.copyMessage")} onClick={() => { - void navigator.clipboard.writeText(m.content).then( + // Electron denies the renderer's navigator clipboard write + // (issue #738), so the write crosses to main when the + // bridge offers it; the navigator path remains for + // shim/web contexts without the bridge. + const bridge = window.electronAPI?.copyToClipboard; + const write = bridge + ? bridge(m.content) + : navigator.clipboard.writeText(m.content); + void write.then( () => toast.success(t("chat.copiedToClipboard")), () => toast.error(t("chat.copyFailed")), );