6bf8fedee043

fix copy buttons by writing clipboard directly from popup; SW only schedules auto-clear (v1.8.9)

time
2026-04-24 23:01 UTC · 6 months ago
machine
koh2
parent
7cb62f5f1ca2
11 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/clipboard.ts+12 −2src/background/messages.ts+6 −2src/content/index.ts+10 −3src/popup/App.tsx+3 −4src/popup/components/GeneratorDrawer.tsx+3 −5src/popup/copy.ts+31 −0src/popup/views/Entry.tsx+3 −2src/popup/views/contracts.test.tsx+3 −3
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach",4-  "version": "1.8.8",4+  "version": "1.8.9",55   "description": "Zero-knowledge password manager",66   "browser_specific_settings": {77     "gecko": {

view file

manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach",4-  "version": "1.8.8",4+  "version": "1.8.9",55   "description": "Zero-knowledge password manager",66   "action": {77     "default_popup": "src/popup/index.html",

view file

package.json+1 −1
@@ -1,6 +1,6 @@11 {22   "name": "peach",3-  "version": "1.8.8",3+  "version": "1.8.9",44   "description": "Zero-knowledge password manager",55   "type": "module",66   "scripts": {

view file

src/background/clipboard.ts+12 −2
@@ -24,12 +24,22 @@2424   await navigator.clipboard.writeText(text);2525 }2626 27-export async function copyToClipboard(text: string, _label: string): Promise<void> {27+export async function copyToClipboard(28+  text: string,29+  _label: string,30+  alreadyWritten = false,31+): Promise<void> {2832   if (clipboardClearTimer) {2933     clearTimeout(clipboardClearTimer);3034     clipboardClearTimer = null;3135   }32-  await writeToClipboard(text);36+  // The popup/content caller may have already written the clipboard from a37+  // focused document with a fresh user gesture — that path is more reliable38+  // than the SW's `executeScript` into the active tab. When that happens, we39+  // only need to schedule the auto-clear timer here.40+  if (!alreadyWritten) {41+    await writeToClipboard(text);42+  }3343 3444   const settingsSeconds = getSettings().clipboardClearSeconds;3545   const clearAfterMs =

view file

src/background/messages.ts+6 −2
@@ -210,8 +210,12 @@210210         return { ok: true, data: safeEntry };211211       }212212       case "COPY": {213-        const { text, label } = msg.payload as { text: string; label: string };214-        await copyToClipboard(text, label);213+        const { text, label, alreadyWritten } = msg.payload as {214+          text: string;215+          label: string;216+          alreadyWritten?: boolean;217+        };218+        await copyToClipboard(text, label, Boolean(alreadyWritten));215219         return { ok: true };216220       }217221       case "CHECK_BREACH": {

view file

src/content/index.ts+10 −3
@@ -164,7 +164,12 @@164164         const resp = await chrome.runtime.sendMessage({ type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } });165165         const r = resp as { ok: boolean; data?: { password?: string } };166166         if (r.ok && r.data?.password) {167-          await chrome.runtime.sendMessage({ type: "COPY", payload: { text: r.data.password, label: "Password" } });167+          let alreadyWritten = false;168+          try { await navigator.clipboard.writeText(r.data.password); alreadyWritten = true; } catch {}169+          await chrome.runtime.sendMessage({170+            type: "COPY",171+            payload: { text: r.data.password, label: "Password", alreadyWritten },172+          });168173           showToast("Password copied");169174         } else {170175           showToast("Could not copy password");@@ -263,11 +268,13 @@263268 264269   if (entry.totp) {265270     const { code } = generateTOTP(entry.totp);271+    let alreadyWritten = false;272+    try { await navigator.clipboard.writeText(code); alreadyWritten = true; } catch {}266273     const response = await chrome.runtime.sendMessage({267274       type: "COPY",268-      payload: { text: code, label: "TOTP" },275+      payload: { text: code, label: "TOTP", alreadyWritten },269276     });270-    if ((response as { ok?: boolean }).ok) {277+    if (alreadyWritten || (response as { ok?: boolean }).ok) {271278       showToast("TOTP copied · " + code);272279     }273280     const totpField = findTOTPField();

view file

src/popup/App.tsx+3 −4
@@ -9,6 +9,7 @@99 import { PasskeyApprovalView } from "./views/PasskeyApproval";1010 import { PasskeyEntryView } from "./views/PasskeyEntry";1111 import { extractDomain } from "../shared/domain";12+import { popupCopy } from "./copy";1213 1314 const ImportView    = lazy(() => import("./views/Import").then(m => ({ default: m.ImportView })));1415 const ExportView    = lazy(() => import("./views/Export").then(m => ({ default: m.ExportView })));@@ -355,8 +356,7 @@355356           e.preventDefault();356357           const entry = selectedEntry();357358           if (entry?.password) {358-            void sendMessage({ type: "COPY", payload: { text: entry.password, label: "Password" } })359-              .then(() => triggerRing());359+            void popupCopy(entry.password, "Password").then((ok) => { if (ok) triggerRing(); });360360           }361361           return;362362         }@@ -364,8 +364,7 @@364364           e.preventDefault();365365           const entry = selectedEntry();366366           if (entry?.username) {367-            void sendMessage({ type: "COPY", payload: { text: entry.username, label: "Username" } })368-              .then(() => triggerRing());367+            void popupCopy(entry.username, "Username").then((ok) => { if (ok) triggerRing(); });369368           }370369           return;371370         }

view file

src/popup/components/GeneratorDrawer.tsx+3 −5
@@ -2,6 +2,7 @@22 import { generatePassword } from "../../shared/generate";33 import type { PasswordPolicy } from "../../shared/types";44 import { sendMessage, triggerPipPulse, triggerRing } from "../App";5+import { popupCopy } from "../copy";56 67 interface GeneratorDrawerProps {78   open: boolean;@@ -68,11 +69,8 @@6869   }6970 7071   async function copyPassword(button?: HTMLButtonElement) {71-    const response = await sendMessage({72-      type: "COPY",73-      payload: { text: password(), label: "password" },74-    });75-    if ((response as { ok: boolean }).ok) {72+    const ok = await popupCopy(password(), "password");73+    if (ok) {7674       triggerRing();7775       triggerPipPulse();7876       setCopied(true);

view file

src/popup/copy.ts+31 −0
@@ -0,0 +1,31 @@1+/**2+ * Popup-side copy helper.3+ *4+ * MV3 service workers can't reliably write the clipboard from a non-focused5+ * tab — the SW path's `chrome.scripting.executeScript` into the active tab6+ * loses the popup's user gesture and the page document is not focused, so7+ * `navigator.clipboard.writeText` rejects with NotAllowed. The popup itself8+ * is a focused document with a fresh user gesture and `clipboardWrite`9+ * permission, so it can call the API directly. We then notify the SW with10+ * `alreadyWritten: true` so it skips its own write and only schedules the11+ * auto-clear timer.12+ */13+14+import { sendMessage } from "./App";15+16+export async function popupCopy(text: string, label: string): Promise<boolean> {17+  let alreadyWritten = false;18+  try {19+    await navigator.clipboard.writeText(text);20+    alreadyWritten = true;21+  } catch {22+    // Some popup contexts (jsdom in tests, or rare permission-denied edge23+    // cases) reject the direct write. Fall through to the SW path.24+  }25+26+  const response = await sendMessage({27+    type: "COPY",28+    payload: { text, label, alreadyWritten },29+  });30+  return alreadyWritten || Boolean((response as { ok?: boolean })?.ok);31+}

view file

src/popup/views/Entry.tsx+3 −2
@@ -4,6 +4,7 @@44 import { faviconUrl } from "../../shared/favicon";55 import { generateTOTP, parseOtpauthURI } from "../../crypto/totp";66 import { sendMessage, triggerPipPulse, triggerRing } from "../App";7+import { popupCopy } from "../copy";78 import { GeneratorDrawer } from "../components/GeneratorDrawer";89 import { NoteEditorInline, Toolbar } from "./NoteEditor";910 @@ -228,8 +229,8 @@228229   // ── Copy ─────────────────────────────────────────────────────────229230 230231   async function copyField(text: string, label: string, button?: HTMLButtonElement) {231-    const response = await sendMessage({ type: "COPY", payload: { text, label } });232-    if ((response as { ok: boolean }).ok) {232+    const ok = await popupCopy(text, label);233+    if (ok) {233234       const entry = props.entry;234235       if (entry?.canary?.webhookUrl) {235236         const trigger =

view file

src/popup/views/contracts.test.tsx+3 −3
@@ -430,7 +430,7 @@430430 431431     expect(sendMessageMock).toHaveBeenNthCalledWith(1, {432432       type: "COPY",433-      payload: { text: "secret123", label: "password" },433+      payload: { text: "secret123", label: "password", alreadyWritten: false },434434     });435435     expect(sendMessageMock).toHaveBeenNthCalledWith(2, {436436       type: "FIRE_CANARY_PING",@@ -448,7 +448,7 @@448448 449449     expect(sendMessageMock).toHaveBeenNthCalledWith(1, {450450       type: "COPY",451-      payload: { text: "june", label: "username" },451+      payload: { text: "june", label: "username", alreadyWritten: false },452452     });453453     expect(sendMessageMock).toHaveBeenNthCalledWith(2, {454454       type: "FIRE_CANARY_PING",@@ -494,7 +494,7 @@494494 495495     expect(sendMessageMock).toHaveBeenLastCalledWith({496496       type: "COPY",497-      payload: { text: "123456", label: "totp" },497+      payload: { text: "123456", label: "totp", alreadyWritten: false },498498     });499499   });500500 

view file