From faa2c040489b4e6d77d0410bd4e3617142528b86 Mon Sep 17 00:00:00 2001 From: Aarnav Tale Date: Tue, 29 Sep 2026 00:21:32 -0700 Subject: [PATCH] fix: fall back to copying via execCommand in http --- CHANGELOG.md | 1 + app/components/attribute.tsx | 8 +++- app/components/code-block.tsx | 5 ++- .../machines/components/machine-row.tsx | 9 ++++- app/utils/copy.ts | 37 +++++++++++++++++++ 5 files changed, 55 insertions(+), 5 deletions(-) create mode 100644 app/utils/copy.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 83aaa866..caba6c77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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)). diff --git a/app/components/attribute.tsx b/app/components/attribute.tsx index dcef9183..68fa4277 100644 --- a/app/components/attribute.tsx +++ b/app/components/attribute.tsx @@ -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(() => { diff --git a/app/components/code-block.tsx b/app/components/code-block.tsx index 80c45418..4ee2fa8d 100644 --- a/app/components/code-block.tsx +++ b/app/components/code-block.tsx @@ -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."); }} > {text} diff --git a/app/routes/machines/components/machine-row.tsx b/app/routes/machines/components/machine-row.tsx index de976ae7..4b68303e 100644 --- a/app/routes/machines/components/machine-row.tsx +++ b/app/routes/machines/components/machine-row.tsx @@ -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({ { - 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.", + ); }} >
{ + 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 }); + } + } +}