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.
This commit is contained in:
2026-10-21 15:22:16 +01:00
parent d9f5367270
commit 2a74c1e3e6
5 changed files with 451 additions and 6 deletions
+4
View File
@@ -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 (
<ToastProvider>
<ConfirmProvider>{children}</ConfirmProvider>
+9 -6
View File
@@ -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;
+13
View File
@@ -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();
}, []);
}
+259
View File
@@ -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<string, string>;
attrs: Record<string, string>;
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<void>;
};
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<string, unknown>)[key];
}
delete (globalThis as Record<string, unknown>)["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"]);
});
});
+166
View File
@@ -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<string, unknown>)[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<boolean> {
// 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.
}
}