authorgravatar for 107891771+Michel-Liao@users.noreply.github.comMichel Liao <107891771+Michel-Liao@users.noreply.github.com> 2026-09-04 22:22:53-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-04 19:22:53-07:00
log03c6cd8badb7af4dfd05d370f3180d16f6927591
tree7569dee2ebbcf84fe313d5cb514c8cdc013ad692
parent4ade365180231983008bcd46ca7312f8155e1224
signature Signed by PGP key B5690EEEBB952194

fix(web): copy text over plain HTTP (#8023)

Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com>

2 files changed, 120 insertions(+), 12 deletions(-)

apps/web/src/hooks/useCopyToClipboard.test.ts+80-7
...@@ -14,6 +14,7 @@ describe("writeTextToClipboard", () => {...@@ -14,6 +14,7 @@ describe("writeTextToClipboard", () => {
14 it("reports unavailable clipboard support with structural context", async () => {14 it("reports unavailable clipboard support with structural context", async () => {
15 vi.stubGlobal("window", {});15 vi.stubGlobal("window", {});
16 vi.stubGlobal("navigator", {});16 vi.stubGlobal("navigator", {});
17 vi.stubGlobal("document", undefined);
1718
18 const error = await writeTextToClipboard("plan contents", "plan").then(19 const error = await writeTextToClipboard("plan contents", "plan").then(
19 () => undefined,20 () => undefined,
...@@ -27,6 +28,72 @@ describe("writeTextToClipboard", () => {...@@ -27,6 +28,72 @@ describe("writeTextToClipboard", () => {
27 expect((error as Error).message).not.toContain("plan contents");28 expect((error as Error).message).not.toContain("plan contents");
28 });29 });
2930
31 it.each(["success", "denied", "throws"] as const)(
32 "cleans up the Clipboard API fallback when copying %s",
33 async (result) => {
34 const focus = vi.fn();
35 const restoreFocus = vi.fn();
36 const appendChild = vi.fn();
37 const execCommand = vi.fn(() => {
38 if (result === "throws") throw new Error("copy command failed");
39 return result === "success";
40 });
41 const remove = vi.fn();
42 const select = vi.fn();
43 const setAttribute = vi.fn();
44 const setSelectionRange = vi.fn();
45 const textarea = {
46 focus,
47 remove,
48 select,
49 setAttribute,
50 setSelectionRange,
51 style: {},
52 value: "",
53 };
54
55 vi.stubGlobal("window", {});
56 vi.stubGlobal("navigator", {});
57 vi.stubGlobal("document", {
58 activeElement: { focus: restoreFocus },
59 body: { appendChild },
60 createElement: vi.fn(() => textarea),
61 execCommand,
62 });
63
64 const pendingCopy = writeTextToClipboard("remote command", "command");
65 // The fallback must run during the original user gesture, before any await.
66 expect(execCommand).toHaveBeenCalledWith("copy");
67 if (result === "success") {
68 await expect(pendingCopy).resolves.toBe(true);
69 } else {
70 await expect(pendingCopy).rejects.toBeInstanceOf(ClipboardApiUnavailableError);
71 }
72
73 expect(textarea.value).toBe("remote command");
74 expect(textarea.style).toMatchObject({ fontSize: "16px" });
75 expect(appendChild).toHaveBeenCalledWith(textarea);
76 expect(focus).toHaveBeenCalledWith({ preventScroll: true });
77 expect(select).toHaveBeenCalledOnce();
78 expect(setSelectionRange).toHaveBeenCalledWith(0, "remote command".length);
79 expect(remove).toHaveBeenCalledOnce();
80 expect(restoreFocus).toHaveBeenCalledOnce();
81 },
82 );
83
84 it("uses the Clipboard API without touching the fallback when it is available", async () => {
85 const writeText = vi.fn().mockResolvedValue(undefined);
86 const execCommand = vi.fn();
87 vi.stubGlobal("window", {});
88 vi.stubGlobal("navigator", { clipboard: { writeText } });
89 vi.stubGlobal("document", { execCommand });
90
91 await expect(writeTextToClipboard("remote command", "command")).resolves.toBe(true);
92
93 expect(writeText).toHaveBeenCalledWith("remote command");
94 expect(execCommand).not.toHaveBeenCalled();
95 });
96
30 it("preserves the exact clipboard failure without exposing copied contents", async () => {97 it("preserves the exact clipboard failure without exposing copied contents", async () => {
31 const cause = new Error("browser clipboard failure");98 const cause = new Error("browser clipboard failure");
32 const writeText = vi.fn().mockRejectedValue(cause);99 const writeText = vi.fn().mockRejectedValue(cause);
...@@ -47,12 +114,18 @@ describe("writeTextToClipboard", () => {...@@ -47,12 +114,18 @@ describe("writeTextToClipboard", () => {
47 expect((error as Error).message).not.toContain("secret clipboard contents");114 expect((error as Error).message).not.toContain("secret clipboard contents");
48 });115 });
49116
50 it("keeps empty values as a no-op when clipboard support is available", async () => {117 it.each([true, false])(
51 const writeText = vi.fn();118 "keeps empty values as a no-op with Clipboard API support: %s",
52 vi.stubGlobal("window", {});119 async (available) => {
53 vi.stubGlobal("navigator", { clipboard: { writeText } });120 const writeText = vi.fn();
121 vi.stubGlobal("window", {});
122 const execCommand = vi.fn();
123 vi.stubGlobal("navigator", available ? { clipboard: { writeText } } : {});
124 vi.stubGlobal("document", { execCommand });
54125
55 await expect(writeTextToClipboard("", "plan")).resolves.toBe(false);126 await expect(writeTextToClipboard("", "plan")).resolves.toBe(false);
56 expect(writeText).not.toHaveBeenCalled();127 expect(writeText).not.toHaveBeenCalled();
57 });128 expect(execCommand).not.toHaveBeenCalled();
129 },
130 );
58});131});
apps/web/src/hooks/useCopyToClipboard.ts+40-5
...@@ -47,12 +47,40 @@ export class ClipboardReadError extends Schema.TaggedErrorClass<ClipboardReadErr...@@ -47,12 +47,40 @@ export class ClipboardReadError extends Schema.TaggedErrorClass<ClipboardReadErr
47 }47 }
48}48}
4949
50/** Copy fallback for remote web pages served over plain HTTP. */
51function writeTextWithExecCommand(value: string): boolean {
52 if (typeof document === "undefined" || typeof document.execCommand !== "function") return false;
53
54 const textarea = document.createElement("textarea");
55 textarea.value = value;
56 textarea.setAttribute("readonly", "");
57 textarea.setAttribute("aria-hidden", "true");
58 textarea.style.position = "fixed";
59 textarea.style.top = "0";
60 textarea.style.left = "0";
61 textarea.style.opacity = "0";
62 textarea.style.fontSize = "16px";
63
64 const previouslyFocused = document.activeElement;
65 document.body.appendChild(textarea);
66 try {
67 textarea.focus({ preventScroll: true });
68 textarea.select();
69 textarea.setSelectionRange(0, value.length);
70 return document.execCommand("copy");
71 } catch {
72 return false;
73 } finally {
74 textarea.remove();
75 const restoreFocus = (previouslyFocused as { focus?: unknown } | null)?.focus;
76 if (typeof restoreFocus === "function") {
77 restoreFocus.call(previouslyFocused);
78 }
79 }
80}
81
50export async function writeTextToClipboard(value: string, target = "text") {82export async function writeTextToClipboard(value: string, target = "text") {
51 if (83 if (typeof window === "undefined") {
52 typeof window === "undefined" ||
53 typeof navigator === "undefined" ||
54 !navigator.clipboard?.writeText
55 ) {
56 throw new ClipboardApiUnavailableError({84 throw new ClipboardApiUnavailableError({
57 target,85 target,
58 });86 });
...@@ -60,6 +88,13 @@ export async function writeTextToClipboard(value: string, target = "text") {...@@ -60,6 +88,13 @@ export async function writeTextToClipboard(value: string, target = "text") {
6088
61 if (!value) return false;89 if (!value) return false;
6290
91 if (typeof navigator === "undefined" || !navigator.clipboard?.writeText) {
92 if (writeTextWithExecCommand(value)) return true;
93 throw new ClipboardApiUnavailableError({
94 target,
95 });
96 }
97
63 try {98 try {
64 await navigator.clipboard.writeText(value);99 await navigator.clipboard.writeText(value);
65 return true;100 return true;