b0f705a676ed

show InitialsChip instead of Chrome globe for entries without a real favicon — canvas-based globe detection, no flash (v3.6.145)

time
2026-05-31 01:40 UTC · 4 months ago
machine
koh2
agent
claude-sonnet-4-6
session
placeholder favicon initials
parent
d12779c5867c
5 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/popup/views/Vault.tsx+40 −22src/shared/favicon.ts+43 −0
manifest.firefox.json+1 −1
@@ -2,7 +2,7 @@22   "_comment_audio_capture": "Firefox does not honor the Chrome 'audioCapture' optional_permission — getUserMedia in WebExtensions is gated solely by the runtime permission prompt. Microphone access for chirp pairing works from the popup under the existing '<all_urls>' host permission; no manifest entry is required or accepted here.",33   "manifest_version": 3,44   "name": "Peach Password Manager",5-  "version": "3.6.144",5+  "version": "3.6.145",66   "description": "Zero-knowledge password manager",77   "browser_specific_settings": {88     "gecko": {

view file

manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "3.6.144",4+  "version": "3.6.145",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": "3.6.144",3+  "version": "3.6.145",44   "description": "Zero-knowledge password manager",55   "type": "module",66   "scripts": {

view file

src/popup/views/Vault.tsx+40 −22
@@ -1,7 +1,7 @@11 import { createEffect, createMemo, createSignal, For, Show } from "solid-js";22 import type { VaultEntry } from "../../shared/types";33 import { extractDomain, getDisplayDomain } from "../../shared/domain";4-import { faviconUrl } from "../../shared/favicon";4+import { faviconUrl, faviconImgSig, globeFaviconSig } from "../../shared/favicon";55 import { popupCopy } from "../copy";66 77 function vibrate(ms: number) {@@ -12,28 +12,46 @@1212   const src = props.domain && !props.isNote && !props.isPasskey1313     ? faviconUrl(props.domain)1414     : null;15-  const fallback = props.isPasskey16-    ? <PasskeyIcon />17-    : props.isNote18-      ? <NoteIcon />19-      : <InitialsChip title={props.title ?? props.domain ?? ""} />;20-  const [failed, setFailed] = createSignal(false);15+  const [isReal, setIsReal] = createSignal(false);16+17+  // Show InitialsChip (or passkey/note icon) first — no globe flash.18+  // Load the favicon invisibly in the background; switch to it only after19+  // confirming it differs from Chrome's default "no favicon" globe image.2120   return (22-    <Show when={src && !failed()} fallback={fallback}>23-      <img24-        src={src!}25-        width="16"26-        height="16"27-        alt=""28-        style="border-radius:3px;"29-        onError={() => setFailed(true)}30-        onLoad={(e) => {31-          // Chrome's _favicon/ returns the default globe at its native 16×1632-          // even when size=32 is requested. A genuinely cached favicon arrives33-          // at ≥32px. Treat ≤16 as "no favicon" so InitialsChip shows instead.34-          if ((e.currentTarget as HTMLImageElement).naturalWidth <= 16) setFailed(true);35-        }}36-      />21+    <Show22+      when={isReal()}23+      fallback={24+        <>25+          {props.isPasskey26+            ? <PasskeyIcon />27+            : props.isNote28+              ? <NoteIcon />29+              : <InitialsChip title={props.title ?? props.domain ?? ""} />}30+          {src && (31+            <img32+              src={src}33+              style="display:none"34+              onError={() => {}}35+              onLoad={async (e) => {36+                const img = e.currentTarget as HTMLImageElement;37+                // Old Chrome returns the globe at native 16×16 even when38+                // size=32 is requested — treat as no favicon.39+                if (img.naturalWidth <= 16) return;40+                // New Chrome returns the globe scaled to the requested size.41+                // Compare pixel data against the known "no favicon" reference.42+                const sig = faviconImgSig(img);43+                if (sig !== null) {44+                  const gSig = await globeFaviconSig();45+                  if (gSig !== null && sig === gSig) return;46+                }47+                setIsReal(true);48+              }}49+            />50+          )}51+        </>52+      }53+    >54+      <img src={src!} width="16" height="16" alt="" style="border-radius:3px;" />3755     </Show>3856   );3957 }

view file

src/shared/favicon.ts+43 −0
@@ -21,3 +21,46 @@2121     return null;2222   }2323 }24+25+// Renders an img element to an 8×8 canvas and returns its RGB pixels as a26+// compact string. Used to fingerprint what Chrome returns for "no favicon".27+function imgSig(img: HTMLImageElement): string {28+  const c = document.createElement("canvas");29+  c.width = c.height = 8;30+  const ctx = c.getContext("2d")!;31+  ctx.drawImage(img, 0, 0, 8, 8);32+  const d = ctx.getImageData(0, 0, 8, 8).data;33+  let s = "";34+  for (let i = 0; i < d.length; i += 4) s += `${d[i]},${d[i + 1]},${d[i + 2]};`;35+  return s;36+}37+38+// Cached promise so the globe reference image is only loaded once per page.39+let _globeSig: Promise<string | null> | undefined;40+41+/**42+ * Returns the pixel signature of what Chrome's _favicon/ endpoint returns43+ * when it has no stored favicon for a domain (the default globe icon).44+ * Cached after the first call. Returns null when the favicon API is45+ * unavailable (Firefox) or the canvas read is blocked.46+ */47+export function globeFaviconSig(): Promise<string | null> {48+  if (_globeSig) return _globeSig;49+  _globeSig = new Promise<string | null>((resolve) => {50+    const src = faviconUrl("zz-peach-no-favicon-sentinel.nonexistent");51+    if (!src) { resolve(null); return; }52+    const img = new Image();53+    img.onload = () => { try { resolve(imgSig(img)); } catch { resolve(null); } };54+    img.onerror = () => resolve(null);55+    img.src = src;56+  });57+  return _globeSig;58+}59+60+/**61+ * Returns the 8×8 canvas pixel signature for a loaded img element.62+ * Returns null if the canvas read is blocked (e.g. security error).63+ */64+export function faviconImgSig(img: HTMLImageElement): string | null {65+  try { return imgSig(img); } catch { return null; }66+}

view file