1import { extractDomain } from "../shared/domain";2import type { VaultEntry } from "../shared/types";34let currentBanner: (() => void) | null = null;56interface DetectedCredential {7 username: string;8 password: string;9 url: string;10 domain: string;11}1213async function getMatchingEntry(domain: string): Promise<VaultEntry | null> {14 const response = await chrome.runtime.sendMessage({15 type: "AUTOFILL_QUERY",16 payload: { domain },17 });18 const summary = (response as {19 ok: boolean;20 data?: Array<{ id: string }>;21 }).data?.[0];2223 if (!summary) return null;2425 const entryResponse = await chrome.runtime.sendMessage({26 type: "GET_ENTRY_FOR_FILL",27 payload: { id: summary.id },28 });2930 return (31 (entryResponse as { ok: boolean; data?: VaultEntry }).data ?? null32 );33}3435async function saveEntry(36 username: string,37 password: string,38 url: string39): Promise<void> {40 await chrome.runtime.sendMessage({41 type: "ADD_ENTRY",42 payload: { title: new URL(url).hostname, username, password, url },43 });44}4546async function updateEntryPassword(47 id: string,48 password: string49): Promise<void> {50 await chrome.runtime.sendMessage({51 type: "UPDATE_ENTRY",52 payload: { id, patch: { password } },53 });54}5556function showBanner(57 credential: DetectedCredential,58 existingEntry: VaultEntry | null,59 onPrimaryAction: () => Promise<void>,60 onDismiss: () => void61): () => void {62 const host = document.createElement("div");63 host.style.cssText = `64 position: fixed; top: 16px; right: 16px; z-index: 2147483647;65 font-family: Inter, sans-serif;66 `;67 const shadow = host.attachShadow({ mode: "closed" });6869 const isUpdate = existingEntry !== null;70 const title = isUpdate ? "Update password?" : "Save password?";71 const subtitle = isUpdate72 ? `Update password for ${credential.domain}?`73 : `Save password for ${credential.domain}?`;7475 const style = shadow.appendChild(document.createElement("style"));76 style.textContent = `77 :host { all: initial; }78 .banner {79 background:80 radial-gradient(120% 80% at 0% 0%, rgba(217,104,48,0.10) 0%, transparent 65%),81 radial-gradient(85% 100% at 100% 100%, rgba(217,104,48,0.06) 0%, transparent 70%),82 #0C0A06;83 border: 1px solid rgba(240,232,216,0.10);84 border-radius: 14px;85 padding: 14px 16px;86 box-shadow: 0 18px 48px rgba(0,0,0,0.45);87 width: 296px;88 font-family: Inter, sans-serif;89 }90 .banner-title {91 font-family: "Cormorant Garamond", Georgia, serif;92 font-size: 22px;93 font-style: italic;94 line-height: 0.95;95 color: #F0E8D8;96 margin: 0 0 6px;97 }98 .banner-sub {99 font-family: "IBM Plex Mono", "SFMono-Regular", monospace;100 font-size: 10px;101 letter-spacing: 0.08em;102 color: rgba(240,232,216,0.42);103 margin: 0 0 12px;104 }105 .banner-actions {106 display: flex;107 gap: 8px;108 }109 .btn-save {110 flex: 1;111 background: #D96830;112 color: rgba(0,0,0,0.75);113 border: none;114 border-radius: 10px;115 padding: 8px 12px;116 font-size: 10px;117 letter-spacing: 0.08em;118 text-transform: uppercase;119 font-weight: 500;120 cursor: pointer;121 font-family: Inter, sans-serif;122 }123 .btn-save:hover { opacity: 0.88; }124 .btn-dismiss {125 flex: 1;126 background: rgba(240,232,216,0.06);127 color: rgba(240,232,216,0.6);128 border: 1px solid rgba(240,232,216,0.1);129 border-radius: 10px;130 padding: 8px 12px;131 font-size: 10px;132 letter-spacing: 0.08em;133 text-transform: uppercase;134 cursor: pointer;135 font-family: Inter, sans-serif;136 }137 .btn-dismiss:hover { background: rgba(240,232,216,0.1); }138 `;139140 const banner = shadow.appendChild(document.createElement("div"));141 banner.className = "banner";142143 const titleEl = banner.appendChild(document.createElement("p"));144 titleEl.className = "banner-title";145 titleEl.textContent = title;146147 const subEl = banner.appendChild(document.createElement("p"));148 subEl.className = "banner-sub";149 subEl.textContent = subtitle;150151 const actionsEl = banner.appendChild(document.createElement("div"));152 actionsEl.className = "banner-actions";153154 const saveBtn = actionsEl.appendChild(document.createElement("button"));155 saveBtn.className = "btn-save";156 saveBtn.textContent = isUpdate ? "Update" : "Save";157158 const dismissBtn = actionsEl.appendChild(document.createElement("button"));159 dismissBtn.className = "btn-dismiss";160 dismissBtn.textContent = "Dismiss";161162 saveBtn.addEventListener("click", async () => {163 saveBtn.disabled = true;164 saveBtn.textContent = isUpdate ? "Updating..." : "Saving...";165 try {166 await onPrimaryAction();167 host.remove();168 } finally {169 saveBtn.disabled = false;170 saveBtn.textContent = isUpdate ? "Update" : "Save";171 }172 });173174 dismissBtn.addEventListener("click", () => {175 onDismiss();176 host.remove();177 });178179 document.body.appendChild(host);180181 return () => {182 host.remove();183 };184}185186async function checkForNewCredentials(187 credential: DetectedCredential188): Promise<void> {189 if (currentBanner) {190 currentBanner();191 currentBanner = null;192 }193194 const existing = await getMatchingEntry(credential.domain);195 const isUpdate = existing !== null && existing.password !== credential.password;196 const isNew = existing === null;197198 if (!isUpdate && !isNew) {199 if (existing && existing.canary && existing.password === credential.password) {200 chrome.runtime.sendMessage({201 type: "FIRE_CANARY_PING",202 payload: {203 webhookUrl: existing.canary.webhookUrl,204 entryTitle: existing.title,205 trigger: "submitted",206 },207 }).catch(() => {});208 chrome.runtime.sendMessage({209 type: "UPDATE_ENTRY",210 payload: {211 id: existing.id,212 patch: {213 canary: {214 ...existing.canary,215 lastPinged: Date.now(),216 pingCount: (existing.canary.pingCount ?? 0) + 1,217 },218 },219 },220 }).catch(() => {});221 }222 return;223 }224225 currentBanner = showBanner(226 credential,227 isUpdate ? existing : null,228 async () => {229 if (isUpdate && existing) {230 await updateEntryPassword(existing.id, credential.password);231 } else {232 await saveEntry(credential.username, credential.password, credential.url);233 }234 currentBanner = null;235 },236 () => {237 currentBanner = null;238 }239 );240}241242export function setupSaveDetection(): void {243 let lastUrl = location.href;244245 new MutationObserver(() => {246 if (location.href !== lastUrl) {247 lastUrl = location.href;248 }249 }).observe(document.body, { childList: true, subtree: true });250251 document.addEventListener("submit", async (e) => {252 const form = e.target as HTMLFormElement;253 if (!form) return;254255 const inputs = Array.from(form.querySelectorAll("input"));256 let username = "";257 let password = "";258259 for (const input of inputs) {260 const type = input.type.toLowerCase();261 const name = (input.name + input.id + input.autocomplete).toLowerCase();262263 if (type === "password" && input.value) {264 password = input.value;265 } else if (266 !username &&267 (type === "text" || type === "email" || type === "tel") &&268 (name.includes("user") ||269 name.includes("email") ||270 name.includes("login") ||271 name.includes("name") ||272 input.autocomplete === "username" ||273 input.autocomplete === "email")274 ) {275 username = input.value;276 }277 }278279 if (!password) return;280281 const domain = extractDomain(location.href) ?? location.hostname;282 const credential: DetectedCredential = {283 username,284 password,285 url: location.href,286 domain,287 };288289 await checkForNewCredentials(credential);290 });291}