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
↓ peach-browser-b0f705a6.tar.gzatlas at this save
files changed5 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/popup/views/Vault.tsx+40 −22src/shared/favicon.ts+43 −0manifest.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": {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",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": {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 }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+}