diff --git a/.changeset/chat-copy-insecure-contexts.md b/.changeset/chat-copy-insecure-contexts.md new file mode 100644 index 0000000..67af395 --- /dev/null +++ b/.changeset/chat-copy-insecure-contexts.md @@ -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. diff --git a/src/client/src/clipboard.test.ts b/src/client/src/clipboard.test.ts new file mode 100644 index 0000000..8d7d543 --- /dev/null +++ b/src/client/src/clipboard.test.ts @@ -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"); + }); +}); diff --git a/src/client/src/clipboard.ts b/src/client/src/clipboard.ts new file mode 100644 index 0000000..1feccb2 --- /dev/null +++ b/src/client/src/clipboard.ts @@ -0,0 +1,101 @@ +export interface ClipboardTextWriteHost { + readonly isSecureContext: boolean; + readonly writeText?: (text: string) => Promise; + readonly fallbackWriteText: (text: string) => boolean; +} + +export async function writeClipboardText(text: string, host: ClipboardTextWriteHost = browserClipboardTextWriteHost()): Promise { + 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) | 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(); + } +} diff --git a/src/client/src/components/ChatView.ts b/src/client/src/components/ChatView.ts index d998c81..da47d3f 100644 --- a/src/client/src/components/ChatView.ts +++ b/src/client/src/components/ChatView.ts @@ -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 { 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 { - 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); diff --git a/src/client/src/components/FormattedText.ts b/src/client/src/components/FormattedText.ts index dccf74e..186341e 100644 --- a/src/client/src/components/FormattedText.ts +++ b/src/client/src/components/FormattedText.ts @@ -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 { - 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 { - try { - await navigator.clipboard.writeText(text); - return true; - } catch { - return false; - } -} diff --git a/src/client/src/components/ToolExecutionView.ts b/src/client/src/components/ToolExecutionView.ts index 4fd09a2..c031230 100644 --- a/src/client/src/components/ToolExecutionView.ts +++ b/src/client/src/components/ToolExecutionView.ts @@ -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 { - 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`