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:
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
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);
|
||||
} catch {
|
||||
toast({ msg: `Couldn't copy ${label.toLowerCase()}`, color: "error" });
|
||||
}
|
||||
}
|
||||
|
||||
const Icon = copied ? Check : Copy;
|
||||
|
||||
@@ -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();
|
||||
}, []);
|
||||
}
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user