1import { detectHomograph, 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-warn {114 font-family: "IBM Plex Mono", "SFMono-Regular", monospace;115 font-size: 10px;116 line-height: 1.4;117 color: #C8A840;118 margin: 0 0 12px;119 }120 .banner-actions {121 display: flex;122 gap: 8px;123 }124 .btn-save {125 flex: 1;126 background: var(--peach-accent);127 color: rgba(0,0,0,0.75);128 border: none;129 border-radius: 10px;130 padding: 8px 12px;131 font-size: 10px;132 letter-spacing: 0.08em;133 text-transform: uppercase;134 font-weight: 500;135 cursor: pointer;136 font-family: Inter, sans-serif;137 }138 .btn-save:hover { opacity: 0.88; }139 .btn-dismiss {140 flex: 1;141 background: rgba(240,232,216,0.06);142 color: rgba(240,232,216,0.6);143 border: 1px solid rgba(240,232,216,0.1);144 border-radius: 10px;145 padding: 8px 12px;146 font-size: 10px;147 letter-spacing: 0.08em;148 text-transform: uppercase;149 cursor: pointer;150 font-family: Inter, sans-serif;151 }152 .btn-dismiss:hover { background: rgba(240,232,216,0.1); }153 `;154155 const banner = shadow.appendChild(document.createElement("div"));156 banner.className = "banner";157158 const titleEl = banner.appendChild(document.createElement("p"));159 titleEl.className = "banner-title";160 titleEl.textContent = title;161162 const subEl = banner.appendChild(document.createElement("p"));163 subEl.className = "banner-sub";164 subEl.textContent = subtitle;165166 167 168 if (detectHomograph(location.hostname)) {169 const warnEl = banner.appendChild(document.createElement("p"));170 warnEl.className = "banner-warn";171 warnEl.textContent =172 "⚠ This site's address contains non-ASCII or punycode characters. Verify it's the site you intend.";173 }174175 const actionsEl = banner.appendChild(document.createElement("div"));176 actionsEl.className = "banner-actions";177178 const saveBtn = actionsEl.appendChild(document.createElement("button"));179 saveBtn.className = "btn-save";180 saveBtn.textContent = isUpdate ? "Update" : "Save";181182 const dismissBtn = actionsEl.appendChild(document.createElement("button"));183 dismissBtn.className = "btn-dismiss";184 dismissBtn.textContent = "Dismiss";185186 saveBtn.addEventListener("click", async () => {187 saveBtn.disabled = true;188 saveBtn.textContent = isUpdate ? "Updating..." : "Saving...";189 try {190 await onPrimaryAction();191 host.remove();192 } finally {193 saveBtn.disabled = false;194 saveBtn.textContent = isUpdate ? "Update" : "Save";195 }196 });197198 dismissBtn.addEventListener("click", () => {199 onDismiss();200 host.remove();201 });202203 document.body.appendChild(host);204205 return () => {206 host.remove();207 };208}209210async function checkForNewCredentials(211 credential: DetectedCredential212): Promise<void> {213 if (currentBanner) {214 currentBanner();215 currentBanner = null;216 }217218 const existing = await getMatchingEntry(credential.domain);219 const isUpdate = existing !== null && existing.password !== credential.password;220 const isNew = existing === null;221222 if (!isUpdate && !isNew) {223 if (existing && existing.canary && existing.password === credential.password) {224 chrome.runtime.sendMessage({225 type: "FIRE_CANARY_PING",226 payload: {227 webhookUrl: existing.canary.webhookUrl,228 entryTitle: existing.title,229 trigger: "submitted",230 },231 }).catch(() => {});232 chrome.runtime.sendMessage({233 type: "UPDATE_ENTRY",234 payload: {235 id: existing.id,236 patch: {237 canary: {238 ...existing.canary,239 lastPinged: Date.now(),240 pingCount: (existing.canary.pingCount ?? 0) + 1,241 },242 },243 },244 }).catch(() => {});245 }246 return;247 }248249 currentBanner = showBanner(250 credential,251 isUpdate ? existing : null,252 async () => {253 if (isUpdate && existing) {254 await updateEntryPassword(existing.id, credential.password);255 } else {256 await saveEntry(credential.username, credential.password, credential.url);257 }258 currentBanner = null;259 },260 () => {261 currentBanner = null;262 }263 );264}265266export function setupSaveDetection(): void {267 268 269 document.addEventListener("submit", async (e) => {270 const form = e.target as HTMLFormElement;271 if (!form) return;272273 const inputs = Array.from(form.querySelectorAll("input"));274 let username = "";275 let password = "";276277 for (const input of inputs) {278 const type = input.type.toLowerCase();279 const name = (input.name + input.id + input.autocomplete).toLowerCase();280281 if (type === "password" && input.value) {282 password = input.value;283 } else if (284 !username &&285 (type === "text" || type === "email" || type === "tel") &&286 (name.includes("user") ||287 name.includes("email") ||288 name.includes("login") ||289 name.includes("name") ||290 input.autocomplete === "username" ||291 input.autocomplete === "email")292 ) {293 username = input.value;294 }295 }296297 if (!password) return;298299 const domain = extractDomain(location.href) ?? location.hostname;300 const credential: DetectedCredential = {301 username,302 password,303 url: location.href,304 domain,305 };306307 await checkForNewCredentials(credential);308 });309}