import { detectHomograph, extractDomain } from "../shared/domain"; import { ACCENT_COLORS } from "../shared/types"; import type { VaultEntry } from "../shared/types"; let accentHex = "#E8956D"; chrome.runtime.sendMessage({ type: "GET_SETTINGS" }, (resp: { ok: boolean; data?: { accentColor?: string } } | undefined) => { const name = resp?.data?.accentColor ?? "peach"; accentHex = ACCENT_COLORS.find(c => c.name === name)?.hex ?? "#E8956D"; }); let currentBanner: (() => void) | null = null; interface DetectedCredential { username: string; password: string; url: string; domain: string; } async function getMatchingEntry(domain: string): Promise { const response = await chrome.runtime.sendMessage({ type: "AUTOFILL_QUERY", payload: { domain }, }); const summary = (response as { ok: boolean; data?: Array<{ id: string }>; }).data?.[0]; if (!summary) return null; const entryResponse = await chrome.runtime.sendMessage({ type: "GET_ENTRY_FOR_FILL", payload: { id: summary.id }, }); return ( (entryResponse as { ok: boolean; data?: VaultEntry }).data ?? null ); } async function saveEntry( username: string, password: string, url: string ): Promise { await chrome.runtime.sendMessage({ type: "ADD_ENTRY", payload: { title: new URL(url).hostname, username, password, url }, }); } async function updateEntryPassword( id: string, password: string ): Promise { await chrome.runtime.sendMessage({ type: "UPDATE_ENTRY", payload: { id, patch: { password } }, }); } function showBanner( credential: DetectedCredential, existingEntry: VaultEntry | null, onPrimaryAction: () => Promise, onDismiss: () => void ): () => void { const host = document.createElement("div"); host.style.cssText = ` position: fixed; top: 16px; right: 16px; z-index: 2147483647; font-family: Inter, sans-serif; `; host.style.setProperty("--peach-accent", accentHex); const shadow = host.attachShadow({ mode: "closed" }); const isUpdate = existingEntry !== null; const title = isUpdate ? "Update password?" : "Save password?"; const subtitle = isUpdate ? `Update password for ${credential.domain}?` : `Save password for ${credential.domain}?`; const style = shadow.appendChild(document.createElement("style")); style.textContent = ` :host { all: initial; } .banner { background: radial-gradient(120% 80% at 0% 0%, color-mix(in srgb, var(--peach-accent) 10%, transparent) 0%, transparent 65%), radial-gradient(85% 100% at 100% 100%, color-mix(in srgb, var(--peach-accent) 6%, transparent) 0%, transparent 70%), #0C0A06; border: 1px solid rgba(240,232,216,0.10); border-radius: 14px; padding: 14px 16px; box-shadow: 0 18px 48px rgba(0,0,0,0.45); width: 296px; font-family: Inter, sans-serif; } .banner-title { font-family: "Cormorant Garamond", Georgia, serif; font-size: 22px; font-style: italic; line-height: 0.95; color: #F0E8D8; margin: 0 0 6px; } .banner-sub { font-family: "IBM Plex Mono", "SFMono-Regular", monospace; font-size: 10px; letter-spacing: 0.08em; color: rgba(240,232,216,0.42); margin: 0 0 12px; } .banner-warn { font-family: "IBM Plex Mono", "SFMono-Regular", monospace; font-size: 10px; line-height: 1.4; color: #C8A840; margin: 0 0 12px; } .banner-actions { display: flex; gap: 8px; } .btn-save { flex: 1; background: var(--peach-accent); color: rgba(0,0,0,0.75); border: none; border-radius: 10px; padding: 8px 12px; font-size: 10px; letter-spacing: 0.08em; text-transform: uppercase; font-weight: 500; cursor: pointer; font-family: Inter, sans-serif; } .btn-save:hover { opacity: 0.88; } .btn-dismiss { flex: 1; background: rgba(240,232,216,0.06); color: rgba(240,232,216,0.6); border: 1px solid rgba(240,232,216,0.1); border-radius: 10px; padding: 8px 12px; font-size: 10px; letter-spacing: 0.08em; text-transform: uppercase; cursor: pointer; font-family: Inter, sans-serif; } .btn-dismiss:hover { background: rgba(240,232,216,0.1); } `; const banner = shadow.appendChild(document.createElement("div")); banner.className = "banner"; const titleEl = banner.appendChild(document.createElement("p")); titleEl.className = "banner-title"; titleEl.textContent = title; const subEl = banner.appendChild(document.createElement("p")); subEl.className = "banner-sub"; subEl.textContent = subtitle; // UI-8 / LV-2: non-blocking homograph warning when the page's own address // is non-ASCII or punycode — never blocks saving. if (detectHomograph(location.hostname)) { const warnEl = banner.appendChild(document.createElement("p")); warnEl.className = "banner-warn"; warnEl.textContent = "⚠ This site's address contains non-ASCII or punycode characters. Verify it's the site you intend."; } const actionsEl = banner.appendChild(document.createElement("div")); actionsEl.className = "banner-actions"; const saveBtn = actionsEl.appendChild(document.createElement("button")); saveBtn.className = "btn-save"; saveBtn.textContent = isUpdate ? "Update" : "Save"; const dismissBtn = actionsEl.appendChild(document.createElement("button")); dismissBtn.className = "btn-dismiss"; dismissBtn.textContent = "Dismiss"; saveBtn.addEventListener("click", async () => { saveBtn.disabled = true; saveBtn.textContent = isUpdate ? "Updating..." : "Saving..."; try { await onPrimaryAction(); host.remove(); } finally { saveBtn.disabled = false; saveBtn.textContent = isUpdate ? "Update" : "Save"; } }); dismissBtn.addEventListener("click", () => { onDismiss(); host.remove(); }); document.body.appendChild(host); return () => { host.remove(); }; } async function checkForNewCredentials( credential: DetectedCredential ): Promise { if (currentBanner) { currentBanner(); currentBanner = null; } const existing = await getMatchingEntry(credential.domain); const isUpdate = existing !== null && existing.password !== credential.password; const isNew = existing === null; if (!isUpdate && !isNew) { if (existing && existing.canary && existing.password === credential.password) { chrome.runtime.sendMessage({ type: "FIRE_CANARY_PING", payload: { webhookUrl: existing.canary.webhookUrl, entryTitle: existing.title, trigger: "submitted", }, }).catch(() => {}); chrome.runtime.sendMessage({ type: "UPDATE_ENTRY", payload: { id: existing.id, patch: { canary: { ...existing.canary, lastPinged: Date.now(), pingCount: (existing.canary.pingCount ?? 0) + 1, }, }, }, }).catch(() => {}); } return; } currentBanner = showBanner( credential, isUpdate ? existing : null, async () => { if (isUpdate && existing) { await updateEntryPassword(existing.id, credential.password); } else { await saveEntry(credential.username, credential.password, credential.url); } currentBanner = null; }, () => { currentBanner = null; } ); } export function setupSaveDetection(): void { // PCH-01-026 #1: removed a dead no-op URL-tracking MutationObserver that ran // on every DOM mutation only to update a `lastUrl` variable nothing consumed. document.addEventListener("submit", async (e) => { const form = e.target as HTMLFormElement; if (!form) return; const inputs = Array.from(form.querySelectorAll("input")); let username = ""; let password = ""; for (const input of inputs) { const type = input.type.toLowerCase(); const name = (input.name + input.id + input.autocomplete).toLowerCase(); if (type === "password" && input.value) { password = input.value; } else if ( !username && (type === "text" || type === "email" || type === "tel") && (name.includes("user") || name.includes("email") || name.includes("login") || name.includes("name") || input.autocomplete === "username" || input.autocomplete === "email") ) { username = input.value; } } if (!password) return; const domain = extractDomain(location.href) ?? location.hostname; const credential: DetectedCredential = { username, password, url: location.href, domain, }; await checkForNewCredentials(credential); }); }