fix: fall back to copying via execCommand in http

This commit is contained in:
Aarnav Tale
2026-09-29 00:21:32 -07:00
parent dbaf5e701a
commit faa2c04048
5 changed files with 55 additions and 5 deletions
+1
View File
@@ -8,6 +8,7 @@ Building Headplane from source now requires Go 1.27.1 or newer.
## Fixes
- Fixed copying code, attributes, and machine addresses over plain HTTP, and added feedback when copying fails (closes [#597](https://github.com/tale/headplane/issues/597)).
- Updated NPM & Go dependencies to fix vulnerabilities.
- Fixed the Native installation documentation as it was outdated and missing steps (via [#633](https://github.com/tale/headplane/pull/633)).
+7 -1
View File
@@ -1,6 +1,7 @@
import { Check, Copy, Info } from "lucide-react";
import cn from "~/utils/cn";
import { copyToClipboard } from "~/utils/copy";
import toast from "~/utils/toast";
import Tooltip from "./tooltip";
@@ -47,11 +48,16 @@ export default function Attribute({ name, value, tooltip, isCopyable }: Attribut
className="relative flex w-full min-w-0 items-center gap-1.5"
onClick={async (event) => {
const svgs = event.currentTarget.querySelectorAll("svg");
const isCopied = await copyToClipboard(value);
if (!isCopied) {
toast("Copy failed. Please copy the text manually.");
return;
}
for (const svg of svgs) {
svg.toggleAttribute("data-copied", true);
}
await navigator.clipboard.writeText(value);
toast(`Copied ${name} to clipboard`);
setTimeout(() => {
+3 -2
View File
@@ -1,6 +1,7 @@
import { Copy } from "lucide-react";
import cn from "~/utils/cn";
import { copyToClipboard } from "~/utils/copy";
import toast from "~/utils/toast";
export interface CodeBlockProps {
@@ -20,8 +21,8 @@ export default function CodeBlock({ children, className }: CodeBlockProps) {
className,
)}
onClick={async () => {
await navigator.clipboard.writeText(text);
toast("Copied to clipboard");
const isCopied = await copyToClipboard(text);
toast(isCopied ? "Copied to clipboard" : "Copy failed. Please copy the text manually.");
}}
>
<code className="block px-3 pt-2 pb-1 text-sm break-all">{text}</code>
@@ -12,6 +12,7 @@ import { SubnetTag } from "~/components/tags/Subnet";
import { TailscaleSSHTag } from "~/components/tags/TailscaleSSH";
import type { User } from "~/types";
import cn from "~/utils/cn";
import { copyToClipboard } from "~/utils/copy";
import * as hinfo from "~/utils/host-info";
import { isNoExpiry, type PopulatedNode } from "~/utils/node-info";
import { formatTimeDelta } from "~/utils/time";
@@ -89,8 +90,12 @@ export default function MachineRow({
<MenuItem
key={ip}
onClick={async () => {
await navigator.clipboard.writeText(ip);
toast("Copied IP address to clipboard");
const isCopied = await copyToClipboard(ip);
toast(
isCopied
? "Copied IP address to clipboard"
: "Copy failed. Please copy the address manually.",
);
}}
>
<div
+37
View File
@@ -0,0 +1,37 @@
export async function copyToClipboard(text: string): Promise<boolean> {
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return true;
}
} catch {
// Clipboard permissions can fail even when the API is available.
}
if (typeof document === "undefined" || typeof document.execCommand !== "function") {
return false;
}
const activeElement = document.activeElement;
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.readOnly = true;
textarea.style.cssText = "position: fixed; top: 0; left: -9999px;";
// Keep the temporary field inside any active modal's focus trap.
const container = activeElement?.closest('[role="dialog"], [role="alertdialog"], dialog');
try {
(container ?? document.body).appendChild(textarea);
textarea.focus({ preventScroll: true });
textarea.select();
return document.execCommand("copy");
} catch {
return false;
} finally {
textarea.remove();
if (activeElement instanceof HTMLElement) {
activeElement.focus({ preventScroll: true });
}
}
}