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 type { PropsWithChildren } from "react";
|
||||||
import ToastProvider from "./toast/toast-provider";
|
import ToastProvider from "./toast/toast-provider";
|
||||||
import ConfirmProvider from "./confirm/confirm-provider";
|
import ConfirmProvider from "./confirm/confirm-provider";
|
||||||
|
import useClipboardFallback from "@/src/hooks/use-clipboard-fallback";
|
||||||
|
|
||||||
export default function AppProviders({ children }: PropsWithChildren) {
|
export default function AppProviders({ children }: PropsWithChildren) {
|
||||||
|
// Plain-http (non-localhost) deployments have no `navigator.clipboard`.
|
||||||
|
useClipboardFallback();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<ConfirmProvider>{children}</ConfirmProvider>
|
<ConfirmProvider>{children}</ConfirmProvider>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Button from "@/src/components/twui/layout/Button";
|
import Button from "@/src/components/twui/layout/Button";
|
||||||
import useToast from "@/src/hooks/use-toast";
|
import useToast from "@/src/hooks/use-toast";
|
||||||
|
import { copyTextToClipboard } from "@/src/utils/copy-text-to-clipboard";
|
||||||
import { Check, Copy } from "lucide-react";
|
import { Check, Copy } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
@@ -18,14 +19,16 @@ export default function CopyButton({ value, label, show_text, className }: Props
|
|||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
async function copy() {
|
async function copy() {
|
||||||
try {
|
const copied_ok = await copyTextToClipboard({ text: value });
|
||||||
await navigator.clipboard.writeText(value);
|
|
||||||
|
if (!copied_ok) {
|
||||||
|
toast({ msg: `Couldn't copy ${label.toLowerCase()}`, color: "error" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setCopied(true);
|
setCopied(true);
|
||||||
toast({ msg: `${label} copied`, color: "success" });
|
toast({ msg: `${label} copied`, color: "success" });
|
||||||
setTimeout(() => setCopied(false), 1500);
|
setTimeout(() => setCopied(false), 1500);
|
||||||
} catch {
|
|
||||||
toast({ msg: `Couldn't copy ${label.toLowerCase()}`, color: "error" });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const Icon = copied ? Check : Copy;
|
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