fix: support clipboard copy on private HTTP origins

This commit is contained in:
Pi Web Agent
2026-07-08 21:02:21 +00:00
parent 829b3508a4
commit d6cfffd70d
6 changed files with 164 additions and 25 deletions
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Allow chat copy buttons to work from HTTP private-network addresses by falling back when the browser Clipboard API is unavailable.
+46
View File
@@ -0,0 +1,46 @@
import { describe, expect, it, vi } from "vitest";
import { writeClipboardText } from "./clipboard";
describe("writeClipboardText", () => {
it("uses the synchronous fallback directly in insecure contexts", async () => {
const writeText = vi.fn(() => Promise.resolve());
const fallbackWriteText = vi.fn(() => true);
const copied = await writeClipboardText("hello", { isSecureContext: false, writeText, fallbackWriteText });
expect(copied).toBe(true);
expect(writeText).not.toHaveBeenCalled();
expect(fallbackWriteText).toHaveBeenCalledWith("hello");
});
it("uses the async Clipboard API in secure contexts", async () => {
const writeText = vi.fn(() => Promise.resolve());
const fallbackWriteText = vi.fn(() => true);
const copied = await writeClipboardText("hello", { isSecureContext: true, writeText, fallbackWriteText });
expect(copied).toBe(true);
expect(writeText).toHaveBeenCalledWith("hello");
expect(fallbackWriteText).not.toHaveBeenCalled();
});
it("falls back when the async Clipboard API is unavailable", async () => {
const fallbackWriteText = vi.fn(() => true);
const copied = await writeClipboardText("hello", { isSecureContext: true, fallbackWriteText });
expect(copied).toBe(true);
expect(fallbackWriteText).toHaveBeenCalledWith("hello");
});
it("falls back when the async Clipboard API rejects", async () => {
const writeText = vi.fn(() => Promise.reject(new Error("denied")));
const fallbackWriteText = vi.fn(() => true);
const copied = await writeClipboardText("hello", { isSecureContext: true, writeText, fallbackWriteText });
expect(copied).toBe(true);
expect(writeText).toHaveBeenCalledWith("hello");
expect(fallbackWriteText).toHaveBeenCalledWith("hello");
});
});
+101
View File
@@ -0,0 +1,101 @@
export interface ClipboardTextWriteHost {
readonly isSecureContext: boolean;
readonly writeText?: (text: string) => Promise<void>;
readonly fallbackWriteText: (text: string) => boolean;
}
export async function writeClipboardText(text: string, host: ClipboardTextWriteHost = browserClipboardTextWriteHost()): Promise<boolean> {
if (!host.isSecureContext) return host.fallbackWriteText(text);
if (host.writeText !== undefined) {
try {
await host.writeText(text);
return true;
} catch {
return host.fallbackWriteText(text);
}
}
return host.fallbackWriteText(text);
}
function browserClipboardTextWriteHost(): ClipboardTextWriteHost {
const fallbackWriteText = (text: string) => writeClipboardTextWithSelectionFallback(text);
const writeText = browserClipboardWriteText();
return writeText === undefined
? { isSecureContext: browserIsSecureContext(), fallbackWriteText }
: { isSecureContext: browserIsSecureContext(), writeText, fallbackWriteText };
}
function browserIsSecureContext(): boolean {
return typeof window !== "undefined" && window.isSecureContext;
}
function browserClipboardWriteText(): ((text: string) => Promise<void>) | undefined {
if (typeof navigator === "undefined" || !("clipboard" in navigator)) return undefined;
return navigator.clipboard.writeText.bind(navigator.clipboard);
}
function writeClipboardTextWithSelectionFallback(text: string): boolean {
if (typeof document === "undefined") return false;
const activeElement = document.activeElement;
const selection = document.getSelection();
const selectedRanges = selection === null ? [] : selectionRanges(selection);
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.readOnly = true;
textarea.setAttribute("aria-hidden", "true");
textarea.style.position = "fixed";
textarea.style.top = "0";
textarea.style.left = "-9999px";
textarea.style.width = "1px";
textarea.style.height = "1px";
textarea.style.padding = "0";
textarea.style.border = "0";
textarea.style.opacity = "0";
textarea.style.pointerEvents = "none";
document.body.append(textarea);
textarea.focus();
textarea.select();
textarea.setSelectionRange(0, textarea.value.length);
try {
// eslint-disable-next-line @typescript-eslint/no-deprecated -- Required for HTTP/private-network pages where navigator.clipboard is unavailable.
return document.execCommand("copy");
} catch {
return false;
} finally {
textarea.remove();
restoreSelection(selection, selectedRanges);
restoreFocus(activeElement);
}
}
function selectionRanges(selection: Selection): Range[] {
const ranges: Range[] = [];
for (let index = 0; index < selection.rangeCount; index += 1) {
ranges.push(selection.getRangeAt(index));
}
return ranges;
}
function restoreSelection(selection: Selection | null, ranges: readonly Range[]): void {
if (selection === null) return;
try {
selection.removeAllRanges();
for (const range of ranges) selection.addRange(range);
} catch {
// Restoring the prior selection is best-effort; the copy result should remain authoritative.
}
}
function restoreFocus(element: Element | null): void {
if (typeof HTMLElement === "undefined" || !(element instanceof HTMLElement)) return;
try {
element.focus({ preventScroll: true });
} catch {
element.focus();
}
}
+3 -10
View File
@@ -3,6 +3,7 @@ import { customElement, property, query, state } from "lit/decorators.js";
import { repeat } from "lit/directives/repeat.js";
import { ChatDisclosureController } from "../chatDisclosure";
import { groupChatMessages, summarizeChatGroup, type ChatGroup } from "../chatGroups";
import { writeClipboardText } from "../clipboard";
import { capturePrependScrollAnchor, PREPEND_RESTORE_SETTLE_FRAMES, restorePrependScrollAnchor, type PrependScrollAnchor } from "../chatScrollAnchoring";
import { shouldRequestEarlierMessages } from "../chatHistoryLoading";
import { ChatScrollController, distanceFromScrollBottom, findFirstVisibleArticle, isNearScrollBottom, type ChatAnchorScrollPosition, type ChatScrollRestoreResult } from "../chatScrollPosition";
@@ -438,22 +439,14 @@ export class ChatView extends LitElement {
private async copyMessage(message: ChatLine, key: string, event: MouseEvent): Promise<void> {
event.stopPropagation();
const ok = await this.writeClipboard(this.messageCopyText(message));
if (!ok) return;
const copied = await writeClipboardText(this.messageCopyText(message));
if (!copied) return;
this.copiedMessageKey = key;
window.setTimeout(() => {
if (this.copiedMessageKey === key) this.copiedMessageKey = undefined;
}, 1200);
}
private async writeClipboard(text: string): Promise<boolean> {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
return false;
}
}
private messageMetaLabel(message: ChatLine): { short: string; full: string } {
const cached = this.messageMetaCache.get(message);
+3 -10
View File
@@ -1,6 +1,7 @@
import { LitElement, html } from "lit";
import { customElement, property } from "lit/decorators.js";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import { writeClipboardText } from "../clipboard";
import { toSafeMarkdownHtml } from "../formatting/markdown";
import { formattedTextStyles } from "./shared";
@@ -49,8 +50,8 @@ export class FormattedText extends LitElement {
};
private async copyCode(text: string, button: HTMLButtonElement): Promise<void> {
const ok = await writeClipboard(text);
this.setCopyButtonState(button, ok ? "copied" : "failed");
const copied = await writeClipboardText(text);
this.setCopyButtonState(button, copied ? "copied" : "failed");
window.setTimeout(() => {
this.setCopyButtonState(button, "idle");
}, 1200);
@@ -67,11 +68,3 @@ export class FormattedText extends LitElement {
static override styles = formattedTextStyles;
}
async function writeClipboard(text: string): Promise<boolean> {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
return false;
}
}
@@ -1,5 +1,6 @@
import { LitElement, css, html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { writeClipboardText } from "../clipboard";
import type { ToolExecutionPart } from "./shared";
const MAX_COLLAPSED_DIFF_LINES = 180;
@@ -115,13 +116,13 @@ export class ToolExecutionView extends LitElement {
}
private async copyDiff(diff: string): Promise<void> {
try {
await navigator.clipboard.writeText(diff);
this.copied = true;
window.setTimeout(() => { this.copied = false; }, 1200);
} catch {
const copied = await writeClipboardText(diff);
if (!copied) {
this.copied = false;
return;
}
this.copied = true;
window.setTimeout(() => { this.copied = false; }, 1200);
}
static override styles = css`