1import { extractDomain } from "../shared/domain";2import { ACCENT_COLORS } from "../shared/types";3import type { VaultEntry } from "../shared/types";45let accentHex = "#E8956D";6chrome.runtime.sendMessage({ type: "GET_SETTINGS" }, (resp: { ok: boolean; data?: { accentColor?: string } } | undefined) => {7 const name = resp?.data?.accentColor ?? "peach";8 accentHex = ACCENT_COLORS.find(c => c.name === name)?.hex ?? "#E8956D";9});1011let currentBanner: (() => void) | null = null;1213interface DetectedCredential {14 username: string;15 password: string;16 url: string;17 domain: string;18}1920async function getMatchingEntry(domain: string): Promise<VaultEntry | null> {21 const response = await chrome.runtime.sendMessage({22 type: "AUTOFILL_QUERY",23 payload: { domain },24 });25 const summary = (response as {26 ok: boolean;27 data?: Array<{ id: string }>;28 }).data?.[0];2930 if (!summary) return null;3132 const entryResponse = await chrome.runtime.sendMessage({33 type: "GET_ENTRY_FOR_FILL",34 payload: { id: summary.id },35 });3637 return (38 (entryResponse as { ok: boolean; data?: VaultEntry }).data ?? null39 );40}4142async function saveEntry(43 username: string,44 password: string,45 url: string46): Promise<void> {47 await chrome.runtime.sendMessage({48 type: "ADD_ENTRY",49 payload: { title: new URL(url).hostname, username, password, url },50 });51}5253async function updateEntryPassword(54 id: string,55 password: string56): Promise<void> {57 await chrome.runtime.sendMessage({58 type: "UPDATE_ENTRY",59 payload: { id, patch: { password } },60 });61}6263function showBanner(64 credential: DetectedCredential,65 existingEntry: VaultEntry | null,66 onPrimaryAction: () => Promise<void>,67 onDismiss: () => void68): () => void {69 const host = document.createElement("div");70 host.style.cssText = `71 position: fixed; top: 16px; right: 16px; z-index: 2147483647;72 font-family: Inter, sans-serif;73 `;74 host.style.setProperty("--peach-accent", accentHex);75 const shadow = host.attachShadow({ mode: "closed" });7677 const isUpdate = existingEntry !== null;78 const title = isUpdate ? "Update password?" : "Save password?";79 const subtitle = isUpdate80 ? `Update password for ${credential.domain}?`81 : `Save password for ${credential.domain}?`;8283 const style = shadow.appendChild(document.createElement("style"));84 style.textContent = `85 :host { all: initial; }86 .banner {87 background:88 radial-gradient(120% 80% at 0% 0%, color-mix(in srgb, var(--peach-accent) 10%, transparent) 0%, transparent 65%),89 radial-gradient(85% 100% at 100% 100%, color-mix(in srgb, var(--peach-accent) 6%, transparent) 0%, transparent 70%),90 #0C0A06;91 border: 1px solid rgba(240,232,216,0.10);92 border-radius: 14px;93 padding: 14px 16px;94 box-shadow: 0 18px 48px rgba(0,0,0,0.45);95 width: 296px;96 font-family: Inter, sans-serif;97 }98 .banner-title {99 font-family: "Cormorant Garamond", Georgia, serif;100 font-size: 22px;101 font-style: italic;102 line-height: 0.95;103 color: #F0E8D8;104 margin: 0 0 6px;105 }106 .banner-sub {107 font-family: "IBM Plex Mono", "SFMono-Regular", monospace;108 font-size: 10px;109 letter-spacing: 0.08em;110 color: rgba(240,232,216,0.42);111 margin: 0 0 12px;112 }113 .banner-actions {114 display: flex;115 gap: 8px;116 }117 .btn-save {118 flex: 1;119 background: var(--peach-accent);120 color: rgba(0,0,0,0.75);121 border: none;122 border-radius: 10px;123 padding: 8px 12px;124 font-size: 10px;125 letter-spacing: 0.08em;126 text-transform: uppercase;127 font-weight: 500;128 cursor: pointer;129 font-family: Inter, sans-serif;130 }131 .btn-save:hover { opacity: 0.88; }132 .btn-dismiss {133 flex: 1;134 background: rgba(240,232,216,0.06);135 color: rgba(240,232,216,0.6);136 border: 1px solid rgba(240,232,216,0.1);137 border-radius: 10px;138 padding: 8px 12px;139 font-size: 10px;140 letter-spacing: 0.08em;141 text-transform: uppercase;142 cursor: pointer;143 font-family: Inter, sans-serif;144 }145 .btn-dismiss:hover { background: rgba(240,232,216,0.1); }146 `;147148 const banner = shadow.appendChild(document.createElement("div"));149 banner.className = "banner";150151 const titleEl = banner.appendChild(document.createElement("p"));152 titleEl.className = "banner-title";153 titleEl.textContent = title;154155 const subEl = banner.appendChild(document.createElement("p"));156 subEl.className = "banner-sub";157 subEl.textContent = subtitle;158159 const actionsEl = banner.appendChild(document.createElement("div"));160 actionsEl.className = "banner-actions";161162 const saveBtn = actionsEl.appendChild(document.createElement("button"));163 saveBtn.className = "btn-save";164 saveBtn.textContent = isUpdate ? "Update" : "Save";165166 const dismissBtn = actionsEl.appendChild(document.createElement("button"));167 dismissBtn.className = "btn-dismiss";168 dismissBtn.textContent = "Dismiss";169170 saveBtn.addEventListener("click", async () => {171 saveBtn.disabled = true;172 saveBtn.textContent = isUpdate ? "Updating..." : "Saving...";173 try {174 await onPrimaryAction();175 host.remove();176 } finally {177 saveBtn.disabled = false;178 saveBtn.textContent = isUpdate ? "Update" : "Save";179 }180 });181182 dismissBtn.addEventListener("click", () => {183 onDismiss();184 host.remove();185 });186187 document.body.appendChild(host);188189 return () => {190 host.remove();191 };192}193194async function checkForNewCredentials(195 credential: DetectedCredential196): Promise<void> {197 if (currentBanner) {198 currentBanner();199 currentBanner = null;200 }201202 const existing = await getMatchingEntry(credential.domain);203 const isUpdate = existing !== null && existing.password !== credential.password;204 const isNew = existing === null;205206 if (!isUpdate && !isNew) {207 if (existing && existing.canary && existing.password === credential.password) {208 chrome.runtime.sendMessage({209 type: "FIRE_CANARY_PING",210 payload: {211 webhookUrl: existing.canary.webhookUrl,212 entryTitle: existing.title,213 trigger: "submitted",214 },215 }).catch(() => {});216 chrome.runtime.sendMessage({217 type: "UPDATE_ENTRY",218 payload: {219 id: existing.id,220 patch: {221 canary: {222 ...existing.canary,223 lastPinged: Date.now(),224 pingCount: (existing.canary.pingCount ?? 0) + 1,225 },226 },227 },228 }).catch(() => {});229 }230 return;231 }232233 currentBanner = showBanner(234 credential,235 isUpdate ? existing : null,236 async () => {237 if (isUpdate && existing) {238 await updateEntryPassword(existing.id, credential.password);239 } else {240 await saveEntry(credential.username, credential.password, credential.url);241 }242 currentBanner = null;243 },244 () => {245 currentBanner = null;246 }247 );248}249250export function setupSaveDetection(): void {251 let lastUrl = location.href;252253 new MutationObserver(() => {254 if (location.href !== lastUrl) {255 lastUrl = location.href;256 }257 }).observe(document.body, { childList: true, subtree: true });258259 document.addEventListener("submit", async (e) => {260 const form = e.target as HTMLFormElement;261 if (!form) return;262263 const inputs = Array.from(form.querySelectorAll("input"));264 let username = "";265 let password = "";266267 for (const input of inputs) {268 const type = input.type.toLowerCase();269 const name = (input.name + input.id + input.autocomplete).toLowerCase();270271 if (type === "password" && input.value) {272 password = input.value;273 } else if (274 !username &&275 (type === "text" || type === "email" || type === "tel") &&276 (name.includes("user") ||277 name.includes("email") ||278 name.includes("login") ||279 name.includes("name") ||280 input.autocomplete === "username" ||281 input.autocomplete === "email")282 ) {283 username = input.value;284 }285 }286287 if (!password) return;288289 const domain = extractDomain(location.href) ?? location.hostname;290 const credential: DetectedCredential = {291 username,292 password,293 url: location.href,294 domain,295 };296297 await checkForNewCredentials(credential);298 });299}