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
↓ peach-browser-6bf8fede.tar.gzatlas at this save
files changed11 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 −3manifest.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": {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",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": {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 =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": {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();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 }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);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+}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 =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