From 2a74c1e3e64d142ddc61b25d96bd607cc2eee37d Mon Sep 17 00:00:00 2001 From: Benjamin Toby Date: Wed, 21 Oct 2026 15:22:16 +0100 Subject: [PATCH] Fix copy buttons on non-secure (plain http) deployments `navigator.clipboard` only exists in secure contexts, so every copy button threw on plain-http LAN/IP deployments and falsely reported failure. Add copyTextToClipboard(), which uses the Clipboard API on secure origins and falls back to a hidden textarea + `execCommand("copy")` otherwise (restoring focus/selection and always cleaning the element up). CopyButton now reports success only when the text actually reached the clipboard. installClipboardFallback() polyfills a working `navigator.clipboard` on insecure pages so twui's CodeBlock/CopySlug/Input copy icons work too, without editing twui sources. It is installed once from AppProviders. --- src/components/general/app-providers.tsx | 4 + src/components/general/copy-button.tsx | 15 +- src/hooks/use-clipboard-fallback.ts | 13 ++ src/utils/copy-text-to-clipboard.test.ts | 259 +++++++++++++++++++++++ src/utils/copy-text-to-clipboard.ts | 166 +++++++++++++++ 5 files changed, 451 insertions(+), 6 deletions(-) create mode 100644 src/hooks/use-clipboard-fallback.ts create mode 100644 src/utils/copy-text-to-clipboard.test.ts create mode 100644 src/utils/copy-text-to-clipboard.ts diff --git a/src/components/general/app-providers.tsx b/src/components/general/app-providers.tsx index 7c9bba9..adc3c96 100644 --- a/src/components/general/app-providers.tsx +++ b/src/components/general/app-providers.tsx @@ -1,8 +1,12 @@ import type { PropsWithChildren } from "react"; import ToastProvider from "./toast/toast-provider"; import ConfirmProvider from "./confirm/confirm-provider"; +import useClipboardFallback from "@/src/hooks/use-clipboard-fallback"; export default function AppProviders({ children }: PropsWithChildren) { + // Plain-http (non-localhost) deployments have no `navigator.clipboard`. + useClipboardFallback(); + return ( {children} diff --git a/src/components/general/copy-button.tsx b/src/components/general/copy-button.tsx index a8d397c..59b85ba 100644 --- a/src/components/general/copy-button.tsx +++ b/src/components/general/copy-button.tsx @@ -1,5 +1,6 @@ import Button from "@/src/components/twui/layout/Button"; import useToast from "@/src/hooks/use-toast"; +import { copyTextToClipboard } from "@/src/utils/copy-text-to-clipboard"; import { Check, Copy } from "lucide-react"; import { useState } from "react"; import { twMerge } from "tailwind-merge"; @@ -18,14 +19,16 @@ export default function CopyButton({ value, label, show_text, className }: Props const [copied, setCopied] = useState(false); async function copy() { - try { - await navigator.clipboard.writeText(value); - setCopied(true); - toast({ msg: `${label} copied`, color: "success" }); - setTimeout(() => setCopied(false), 1500); - } catch { + const copied_ok = await copyTextToClipboard({ text: value }); + + if (!copied_ok) { toast({ msg: `Couldn't copy ${label.toLowerCase()}`, color: "error" }); + return; } + + setCopied(true); + toast({ msg: `${label} copied`, color: "success" }); + setTimeout(() => setCopied(false), 1500); } const Icon = copied ? Check : Copy; diff --git a/src/hooks/use-clipboard-fallback.ts b/src/hooks/use-clipboard-fallback.ts new file mode 100644 index 0000000..0c2b870 --- /dev/null +++ b/src/hooks/use-clipboard-fallback.ts @@ -0,0 +1,13 @@ +import { useEffect } from "react"; +import { installClipboardFallback } from "../utils/copy-text-to-clipboard"; + +/** + * Installs the insecure-context clipboard fallback once the app mounts, so + * twui components calling `navigator.clipboard` directly also work over plain + * http on a LAN IP. + */ +export default function useClipboardFallback() { + useEffect(() => { + installClipboardFallback(); + }, []); +} diff --git a/src/utils/copy-text-to-clipboard.test.ts b/src/utils/copy-text-to-clipboard.test.ts new file mode 100644 index 0000000..f0d73c1 --- /dev/null +++ b/src/utils/copy-text-to-clipboard.test.ts @@ -0,0 +1,259 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { copyTextToClipboard, installClipboardFallback } from "./copy-text-to-clipboard"; + +type FakeElement = { + tag: string; + value: string; + style: Record; + attrs: Record; + focused: boolean; + setAttribute: (key: string, value: string) => void; + focus: () => void; + select: () => void; + setSelectionRange: (start: number, end: number) => void; + remove: () => void; +}; + +type FakeDom = { + document: unknown; + /** Text the fake `execCommand("copy")` "wrote" to the clipboard. */ + copied: string[]; + /** Textareas currently attached to `body`. */ + attached: FakeElement[]; +}; + +const Originals = { + document: Object.getOwnPropertyDescriptor(globalThis, "document"), + navigator: Object.getOwnPropertyDescriptor(globalThis, "navigator"), +}; + +function createFakeElement({ tag, on_remove }: { tag: string; on_remove: () => void }): FakeElement { + const element: FakeElement = { + tag, + value: "", + style: {}, + attrs: {}, + focused: false, + setAttribute: (key, value) => { + element.attrs[key] = value; + }, + focus: () => { + element.focused = true; + }, + select: () => {}, + setSelectionRange: () => {}, + remove: on_remove, + }; + + return element; +} + +/** jsdom-free stand-in for the handful of DOM APIs the fallback touches. */ +function installFakeDom({ exec_command_result = true } = {}): FakeDom { + const dom: FakeDom = { document: null, copied: [], attached: [] }; + + const fake_document = { + body: { + appendChild: (element: FakeElement) => { + dom.attached.push(element); + return element; + }, + }, + activeElement: null, + getSelection: () => null, + createElement: (tag: string) => { + const element: FakeElement = createFakeElement({ + tag, + on_remove: () => { + dom.attached = dom.attached.filter((item) => item !== element); + }, + }); + + return element; + }, + execCommand: (command: string) => { + if (command != "copy") { + return false; + } + + const selected = dom.attached.at(-1); + + if (exec_command_result && selected) { + dom.copied.push(selected.value); + } + + return exec_command_result; + }, + }; + + dom.document = fake_document; + + Object.defineProperty(globalThis, "document", { + value: fake_document, + configurable: true, + writable: true, + }); + + return dom; +} + +function setNavigator(value: unknown) { + Object.defineProperty(globalThis, "navigator", { + value, + configurable: true, + writable: true, + }); +} + +type FakeClipboard = { + writeText: (text: string) => Promise; +}; + +function grabFakeClipboard() { + return (globalThis.navigator as unknown as { clipboard: FakeClipboard }).clipboard; +} + +function setWindow(value: unknown) { + Object.defineProperty(globalThis, "window", { + value, + configurable: true, + writable: true, + }); +} + +afterEach(() => { + for (const [key, descriptor] of Object.entries(Originals)) { + if (descriptor) { + Object.defineProperty(globalThis, key, descriptor); + continue; + } + + // Not present originally (e.g. `document` under bun) → remove it again. + delete (globalThis as Record)[key]; + } + + delete (globalThis as Record)["window"]; +}); + +describe("copyTextToClipboard", () => { + test("uses the native Clipboard API when it is available", async () => { + const written: string[] = []; + const dom = installFakeDom(); + + setNavigator({ + clipboard: { + writeText: async (text: string) => { + written.push(text); + }, + }, + }); + + expect(await copyTextToClipboard({ text: "wg-pubkey" })).toBe(true); + expect(written).toEqual(["wg-pubkey"]); + expect(dom.copied).toEqual([]); + }); + + test("falls back to execCommand when navigator.clipboard is missing (plain http)", async () => { + const dom = installFakeDom(); + + // Insecure contexts expose `navigator` but no `clipboard` property. + setNavigator({}); + + expect(await copyTextToClipboard({ text: "10.0.0.2" })).toBe(true); + expect(dom.copied).toEqual(["10.0.0.2"]); + // The helper textarea must be cleaned up afterwards. + expect(dom.attached).toEqual([]); + }); + + test("falls back to execCommand when the native API rejects", async () => { + const dom = installFakeDom(); + + setNavigator({ + clipboard: { + writeText: async () => { + throw new Error("NotAllowedError"); + }, + }, + }); + + expect(await copyTextToClipboard({ text: "key" })).toBe(true); + expect(dom.copied).toEqual(["key"]); + }); + + test("skips the Clipboard API on an insecure origin", async () => { + const dom = installFakeDom(); + let native_calls = 0; + + setNavigator({ + clipboard: { + writeText: async () => { + native_calls += 1; + }, + }, + }); + setWindow({ isSecureContext: false }); + + expect(await copyTextToClipboard({ text: "plain-http" })).toBe(true); + expect(native_calls).toEqual(0); + expect(dom.copied).toEqual(["plain-http"]); + }); + + test("reports failure instead of pretending the copy worked", async () => { + installFakeDom({ exec_command_result: false }); + + setNavigator({}); + + expect(await copyTextToClipboard({ text: "key" })).toBe(false); + }); +}); + +describe("installClipboardFallback", () => { + test("polyfills navigator.clipboard on an insecure origin", async () => { + const dom = installFakeDom(); + + setNavigator({}); + setWindow({ isSecureContext: false }); + + installClipboardFallback(); + + const clipboard = grabFakeClipboard(); + + expect(typeof clipboard.writeText).toEqual("function"); + + await clipboard.writeText("copied-via-polyfill"); + + expect(dom.copied).toEqual(["copied-via-polyfill"]); + }); + + test("leaves the native clipboard untouched on a secure origin", () => { + const native_write_text = async () => {}; + + setNavigator({ clipboard: { writeText: native_write_text } }); + setWindow({ isSecureContext: true }); + + installClipboardFallback(); + + expect(grabFakeClipboard().writeText).toBe(native_write_text); + }); + + test("is idempotent", async () => { + const dom = installFakeDom(); + + setNavigator({}); + setWindow({ isSecureContext: false }); + + installClipboardFallback(); + + const first = grabFakeClipboard(); + + installClipboardFallback(); + + const second = grabFakeClipboard(); + + expect(second.writeText).toBe(first.writeText); + + await second.writeText("once"); + + expect(dom.copied).toEqual(["once"]); + }); +}); diff --git a/src/utils/copy-text-to-clipboard.ts b/src/utils/copy-text-to-clipboard.ts new file mode 100644 index 0000000..6a06ea7 --- /dev/null +++ b/src/utils/copy-text-to-clipboard.ts @@ -0,0 +1,166 @@ +/** + * `navigator.clipboard` only exists in secure contexts (https, localhost, + * 127.0.0.1). Deployments served over plain http on a LAN IP have no + * clipboard API at all, so every copy button needs a fallback. + * + * `document.execCommand("copy")` is deprecated but still the only option + * there, and it works from any origin as long as it runs inside a user + * gesture. + */ + +/** Marker so the fallback never re-enters itself through `navigator.clipboard`. */ +const FallbackMark = "__wgui_clipboard_fallback"; + +type CopyParams = { + text: string; +}; + +function copyViaExecCommand({ text }: CopyParams): boolean { + if (typeof document === "undefined" || !document.body) { + return false; + } + + const holder = document.createElement("textarea"); + holder.value = text; + holder.setAttribute("readonly", ""); + holder.setAttribute("aria-hidden", "true"); + // Must stay rendered (not `display: none`) for selection to work. + holder.style.position = "fixed"; + holder.style.top = "0"; + holder.style.left = "0"; + holder.style.width = "1px"; + holder.style.height = "1px"; + holder.style.padding = "0"; + holder.style.border = "none"; + holder.style.opacity = "0"; + holder.style.pointerEvents = "none"; + + const previous_focus = document.activeElement as HTMLElement | null; + const selection = document.getSelection(); + const previous_range = + selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null; + + document.body.appendChild(holder); + + let copied = false; + + try { + holder.focus(); + holder.select(); + // iOS ignores `select()` on a readonly textarea without a range. + holder.setSelectionRange(0, holder.value.length); + copied = document.execCommand("copy"); + } catch { + copied = false; + } finally { + holder.remove(); + + try { + previous_focus?.focus?.(); + + if (selection && previous_range) { + selection.removeAllRanges(); + selection.addRange(previous_range); + } + } catch { + // Restoring selection/focus is cosmetic — never fail the copy on it. + } + } + + return copied; +} + +function isFallback(write_text: unknown) { + return ( + typeof write_text === "function" && + (write_text as unknown as Record)[FallbackMark] === true + ); +} + +function createFallbackWriteText() { + // Mirrors the native signature so third-party call sites need no change. + const write_text = async (text: string) => { + if (!copyViaExecCommand({ text: String(text ?? "") })) { + throw new Error("Clipboard is unavailable in this context"); + } + }; + + return Object.assign(write_text, { [FallbackMark]: true }); +} + +/** + * Copies `text`, using the async Clipboard API when the page is a secure + * context and falling back to `execCommand` everywhere else. + * + * Resolves `true` when the text reached the clipboard, `false` otherwise — + * callers should never assume success from a resolved promise alone. + */ +export async function copyTextToClipboard({ text }: CopyParams): Promise { + // On a plain-http origin the Clipboard API is absent, and awaiting + // anything first would also break the user-gesture requirement — so go + // straight to the synchronous fallback. + const insecure = typeof window !== "undefined" && window.isSecureContext === false; + const clipboard = typeof navigator === "undefined" ? undefined : navigator.clipboard; + + if ( + !insecure && + clipboard && + typeof clipboard.writeText === "function" && + !isFallback(clipboard.writeText) + ) { + try { + await clipboard.writeText(text); + return true; + } catch { + // Permission denied or a partially-supported stub → fall back. + } + } + + return copyViaExecCommand({ text }); +} + +/** + * Gives insecure-context pages a working `navigator.clipboard`, so third-party + * components (twui `CodeBlock`, `CopySlug`, `Input`) that call it directly keep + * working without patching their source. Safe to call more than once. + */ +export function installClipboardFallback() { + if (typeof window === "undefined" || typeof navigator === "undefined") { + return; + } + + // Keep the native implementation unless the page is definitely insecure + // (`isSecureContext` is `undefined` on very old browsers). + if (window.isSecureContext !== false && navigator.clipboard) { + return; + } + + const fallback_write_text = createFallbackWriteText(); + const clipboard = navigator.clipboard ?? ({} as Clipboard); + + try { + Object.defineProperty(clipboard, "writeText", { + value: fallback_write_text, + configurable: true, + writable: true, + }); + } catch { + // Frozen/exotic implementations: leave them alone. + return; + } + + // The property already existed → it was patched in place. + if (clipboard === navigator.clipboard) { + return; + } + + try { + Object.defineProperty(navigator, "clipboard", { + value: clipboard, + configurable: true, + writable: true, + }); + } catch { + // Nothing else we can do; `copyTextToClipboard` still works directly. + } +}