/** * Content script: detect password form submit, confirm UI, message background. * Does NOT hold tokens or cookie secrets. * * KeePassXC-style flow: * - submit / submit-button click → PENDING_SAVE (background) * - after navigation, background opens OPEN_SAVE_UI_WITH_DRAFT * - SPA same-page: background spa fallback also opens draft UI * - blur is NOT a primary save trigger */ (() => { if (window.__mnoteVaultContentInstalled) return; window.__mnoteVaultContentInstalled = true; const HOST_ID = "mnote-vault-save-host"; /** Last known password on this document only (lost on full navigation). */ let lastPassword = ""; let lastUsername = ""; let lastEmail = ""; /** Prevent double PENDING_SAVE from submit + button click. */ let submitLockUntil = 0; const SUBMIT_LOCK_MS = 1200; function findUsernameNear(passwordInput) { const form = passwordInput?.form || passwordInput?.closest?.("form") || document; const candidates = form.querySelectorAll( 'input[type="email"], input[type="text"], input[name*="user" i], input[name*="login" i], input[name*="email" i], input[autocomplete="username"], input[autocomplete="email"]' ); for (const el of candidates) { if (el === passwordInput) continue; if (el.type === "password" || el.type === "hidden") continue; const v = (el.value || "").trim(); if (v) return { username: v, email: el.type === "email" ? v : "" }; } // Fallback: any non-empty text-like input in form const more = form.querySelectorAll( "input:not([type]), input[type=text], input[type=email], input[type=tel]" ); for (const el of more) { if (el.type === "password" || el.type === "hidden") continue; const v = (el.value || "").trim(); if (v) return { username: v, email: el.type === "email" ? v : "" }; } return { username: "", email: "" }; } function findPasswordIn(root) { const scope = root || document; const list = scope.querySelectorAll?.('input[type="password"]') || []; for (const el of list) { if (el.value && el.value.length >= 1) return el; } return list[0] || null; } function isRegisterPath() { const p = location.pathname.toLowerCase(); return /sign[-_]?up|register|signup|join|create[-_]?account/.test(p); } function buildTitle() { let title = (document.title || "").slice(0, 80); if (isRegisterPath() && title && !/注册|register|sign.?up/i.test(title)) { title = `注册 · ${title}`; } return title || location.hostname; } function collectDraftFromDom(preferPasswordInput) { const pwEl = preferPasswordInput || (document.activeElement?.type === "password" ? document.activeElement : null) || findPasswordIn(document); const pw = (pwEl && pwEl.value) || lastPassword || ""; const near = pwEl ? findUsernameNear(pwEl) : { username: lastUsername, email: lastEmail }; const username = (near.username || lastUsername || "").trim(); const email = (near.email || lastEmail || "").trim(); if (pw) lastPassword = pw; if (username) lastUsername = username; if (email) lastEmail = email; return { pageUrl: location.href, title: buildTitle(), username, email, password: pw, }; } function removeHost() { const el = document.getElementById(HOST_ID); if (el) el.remove(); } function sendPending(draft) { if (!draft?.password) return; const now = Date.now(); if (now < submitLockUntil) return; submitLockUntil = now + SUBMIT_LOCK_MS; lastPassword = draft.password; if (draft.username) lastUsername = draft.username; if (draft.email) lastEmail = draft.email; chrome.runtime.sendMessage( { type: "PENDING_SAVE", pageUrl: draft.pageUrl || location.href, title: draft.title || buildTitle(), username: draft.username || "", password: draft.password, email: draft.email || "", fromSubmit: true, }, () => { // ignore response; SW may be waking void chrome.runtime.lastError; } ); } /** * @param {object} draft * @param {{ fromPending?: boolean }} [opts] */ async function showConfirm(draft, opts = {}) { removeHost(); const host = document.createElement("div"); host.id = HOST_ID; host.style.cssText = "all:initial;position:fixed;z-index:2147483646;right:16px;bottom:16px;font-family:system-ui,sans-serif;"; const shadow = host.attachShadow({ mode: "closed" }); const style = document.createElement("style"); style.textContent = ` .card { width: 320px; max-width: 92vw; background: #1a1d23; color: #e8eaed; border: 1px solid #3a3f4b; border-radius: 12px; box-shadow: 0 12px 40px rgba(0,0,0,.45); padding: 14px 14px 12px; font-size: 13px; line-height: 1.4; } h3 { margin: 0 0 10px; font-size: 14px; font-weight: 600; } label { display:block; margin: 8px 0 2px; color:#9aa0a6; font-size:11px; } input[type=text], input[type=password] { width: 100%; box-sizing: border-box; background:#0f1115; color:#e8eaed; border:1px solid #3a3f4b; border-radius:6px; padding:6px 8px; font-size:13px; } .pw-wrap { display:flex; align-items:stretch; gap:0; border:1px solid #3a3f4b; border-radius:6px; overflow:hidden; background:#0f1115; } .pw-wrap input { flex:1; min-width:0; border:0; border-radius:0; padding:6px 8px; } .pw-wrap input:focus { outline:none; } .btn-pw-toggle { flex:0 0 auto; min-width:52px; margin:0; border:0; border-left:1px solid #3a3f4b; border-radius:0; background:#2a2f3a; color:#e8eaed; padding:0 10px; font-size:11px; cursor:pointer; line-height:1; } .btn-pw-toggle:hover { background:#343b49; } .btn-pw-toggle:focus-visible { outline:2px solid #3b82f6; outline-offset:-2px; } .row { display:flex; gap:8px; align-items:center; margin-top:8px; font-size:12px; } .row input { width:auto; } .actions { display:flex; gap:8px; justify-content:flex-end; margin-top:12px; } button { border:0; border-radius:8px; padding:7px 12px; cursor:pointer; font-size:12px; } .btn-cancel { background:#2a2f3a; color:#e8eaed; } .btn-save { background:#3b82f6; color:#fff; font-weight:600; } .btn-save:disabled { opacity:.5; cursor:wait; } .msg { margin-top:8px; font-size:11px; color:#9aa0a6; min-height:14px; } .msg.err { color:#f87171; } .msg.ok { color:#4ade80; } select { width:100%; box-sizing:border-box; background:#0f1115; color:#e8eaed; border:1px solid #3a3f4b; border-radius:6px; padding:6px 8px; } .muted { color:#9aa0a6; font-size:11px; margin-top:4px; } .banner { margin: 0 0 8px; padding: 6px 8px; border-radius: 6px; background: #243044; color: #93c5fd; font-size: 11px; } .folder-new { display:none; margin-top:6px; } .folder-new.is-open { display:block; } .folder-hint { margin-top:4px; } `; const fromPending = Boolean(draft.fromPending || opts.fromPending); const card = document.createElement("div"); card.className = "card"; card.innerHTML = `

保存到 MNote 密码箱

${ fromPending ? `` : "" }
读取已有分组…
`; shadow.appendChild(style); shadow.appendChild(card); document.documentElement.appendChild(host); const $ = (id) => shadow.getElementById(id); $("title").value = draft.title || buildTitle(); $("url").value = draft.pageUrl || location.href; $("username").value = draft.username || lastUsername || ""; $("password").value = draft.password || lastPassword || ""; function syncFolderNewVisibility() { const isNew = $("folder").value === "__new__"; $("folderNewWrap").classList.toggle("is-open", isNew); if (isNew) { try { $("folderNew").focus(); } catch { /* ignore */ } } } function resolveFolderPath() { const sel = $("folder").value; if (sel === "__new__") { return String($("folderNew").value || "").trim(); } return String(sel || "").trim(); } $("folder").onchange = syncFolderNewVisibility; $("togglePw").onclick = () => { const input = $("password"); const btn = $("togglePw"); const show = input.type === "password"; input.type = show ? "text" : "password"; btn.textContent = show ? "隐藏" : "显示"; btn.setAttribute("aria-label", show ? "隐藏密码" : "显示密码"); }; // status + match chrome.runtime.sendMessage({ type: "GET_STATUS" }, (st) => { void chrome.runtime.lastError; const r = st?.result || st; if (!st?.ok || !r?.connected) { $("msg").className = "msg err"; $("msg").textContent = "未连接:请打开扩展 Options 登录 MNote"; $("save").disabled = true; } }); const matchUrl = $("url").value.trim() || location.href; chrome.runtime.sendMessage( { type: "MATCH_URL", pageUrl: matchUrl }, (res) => { void chrome.runtime.lastError; const items = res?.result?.items || []; const sel = $("match"); for (const it of items) { const opt = document.createElement("option"); opt.value = it.id; opt.textContent = `追加到 · ${it.title || it.id}`; sel.appendChild(opt); } } ); // 已有分组 + 上次选用 chrome.runtime.sendMessage({ type: "LIST_FOLDERS" }, (res) => { void chrome.runtime.lastError; const r = res?.result || res || {}; const folders = Array.isArray(r.folders) ? r.folders : []; const last = String(r.lastFolderPath || "").trim(); const sel = $("folder"); // 保留「无分组」「新建」两端选项,中间插入已有路径 const newOpt = sel.querySelector('option[value="__new__"]'); for (const fp of folders) { const opt = document.createElement("option"); opt.value = fp; opt.textContent = fp; sel.insertBefore(opt, newOpt); } if (last) { // 上次路径若不在列表中,也加一条再选中 let found = false; for (const o of sel.options) { if (o.value === last) { found = true; break; } } if (!found) { const opt = document.createElement("option"); opt.value = last; opt.textContent = last; sel.insertBefore(opt, newOpt); } sel.value = last; } else { sel.value = ""; } syncFolderNewVisibility(); $("folderHint").textContent = folders.length ? `共 ${folders.length} 个已有分组;可选已有或新建` : "暂无已有分组,可选择「新建分组」并填写名称"; }); // Request broad host access so cookies.getAll can see session cookies. // Best-effort here; the critical grant happens on Save click (user gesture). chrome.runtime.sendMessage( { type: "ENSURE_PAGE_HOST", pageUrl: matchUrl, broad: true }, () => { void chrome.runtime.lastError; chrome.runtime.sendMessage( { type: "CAPTURE_COOKIE_COUNT", pageUrl: matchUrl }, (res) => { void chrome.runtime.lastError; const n = res?.result?.count ?? 0; const err = res?.result?.error; if (n > 0) { $("cookieHint").textContent = `将捕获约 ${n} 条 Cookie`; } else if (err) { $("cookieHint").textContent = `无法读取 Cookie:${err}。点「保存」时会再次申请网站权限;也可在扩展详情设为「所有网站」。`; } else { $("cookieHint").textContent = "当前可能尚无会话 Cookie。点「保存」时会申请 Cookie 权限并捕获;若仍为 0,请检查扩展网站访问权限。"; } } ); } ); $("cancel").onclick = () => { const forever = Boolean($("neverAsk")?.checked); chrome.runtime.sendMessage( { type: "DISMISS_SAVE", pageUrl: $("url").value.trim() || location.href, username: $("username").value.trim(), forever, }, () => { void chrome.runtime.lastError; removeHost(); } ); }; $("save").onclick = async () => { $("save").disabled = true; $("msg").className = "msg"; $("msg").textContent = "保存中…"; const matchVal = $("match").value; const folderPath = resolveFolderPath(); if ($("folder").value === "__new__" && !folderPath) { $("msg").className = "msg err"; $("msg").textContent = "请填写新分组名称"; $("save").disabled = false; return; } // Critical: request cookie host permission in this click gesture. // Without it, chrome.cookies.getAll returns [] and login session cannot be saved. let hostPermissionGranted = false; try { hostPermissionGranted = await new Promise((resolve) => { chrome.runtime.sendMessage( { type: "ENSURE_PAGE_HOST", pageUrl: $("url").value.trim() || location.href, broad: true, }, (res) => { void chrome.runtime.lastError; resolve(Boolean(res?.ok || res?.result?.ok || res?.result?.hasHost)); } ); }); } catch { hostPermissionGranted = false; } const payload = { pageUrl: $("url").value.trim() || location.href, title: $("title").value.trim(), username: $("username").value.trim(), password: $("password").value, email: draft.email || lastEmail || "", folderPath, saveSession: $("saveSession").checked, shareToAi: $("shareAi").checked, mode: matchVal === "create" ? "create" : "append", existingId: matchVal === "create" ? null : matchVal, recaptureSession: true, hostPermissionGranted, }; chrome.runtime.sendMessage( { type: "SAVE_CREDENTIAL", payload }, (res) => { void chrome.runtime.lastError; if (!res?.ok) { $("msg").className = "msg err"; $("msg").textContent = res?.error || "保存失败"; $("save").disabled = false; return; } const r = res.result || {}; let text = `已保存 ${r.credentialId || ""}`; if (folderPath && matchVal === "create") text += ` · 分组 ${folderPath}`; if (r.sessionError) text += ` · 登录态: ${r.sessionError}`; else if (payload.saveSession && r.sessionSaved) text += " · 含登录态"; else if (payload.saveSession) text += " · 登录态待二次捕获"; if (r.sessionRecaptureScheduled && !r.sessionSaved) { text += " · 将二次捕获 Cookie"; } $("msg").className = "msg ok"; $("msg").textContent = text; setTimeout(removeHost, r.sessionError ? 3200 : 1800); } ); }; } /** * Manual toolbar open: prefer pending draft, else live form / last known. */ function openFromManual() { chrome.runtime.sendMessage({ type: "GET_PENDING" }, (res) => { void chrome.runtime.lastError; const pending = res?.result?.pending; if (pending?.password) { showConfirm( { pageUrl: pending.pageUrl || location.href, title: pending.title || buildTitle(), username: pending.username || "", email: pending.email || "", password: pending.password, fromPending: true, pendingReason: "manual", }, { fromPending: true } ); return; } const draft = collectDraftFromDom(); showConfirm(draft); }); } function onSubmit(e) { const form = e.target; if (!(form instanceof HTMLFormElement)) return; const pw = findPasswordIn(form) || findPasswordIn(document); if (!pw || !pw.value) return; const draft = collectDraftFromDom(pw); if (!draft.password) return; // Do not block navigation; stash in background immediately. sendPending(draft); } /** * Capture click on submit-like controls (including formless login UIs). */ function onPointerDownCapture(e) { const t = e.target; if (!(t instanceof Element)) return; const btn = t.closest( 'button[type="submit"], input[type="submit"], button:not([type]), [type="submit"], [role="button"]' ); if (!btn) return; // Avoid random buttons far from any password field. const form = btn.closest("form"); const pw = (form && findPasswordIn(form)) || findPasswordIn(btn.closest("div,section,main,body") || document); if (!pw || !pw.value || pw.value.length < 1) return; // Heuristic: prefer buttons that look like login/submit. const label = ( btn.getAttribute("aria-label") || btn.value || btn.textContent || "" ) .trim() .toLowerCase(); const looksSubmit = btn.matches('button[type="submit"], input[type="submit"], [type="submit"]') || /log\s*in|sign\s*in|sign\s*up|register|提交|登录|注册|continue|next|进入/.test( label ) || Boolean(form); if (!looksSubmit) return; const draft = collectDraftFromDom(pw); if (draft.password) sendPending(draft); } // Remember password as user types (still only in this document). function onPasswordInput(e) { const t = e.target; if (!(t instanceof HTMLInputElement) || t.type !== "password") return; if (t.value) lastPassword = t.value; const near = findUsernameNear(t); if (near.username) lastUsername = near.username; if (near.email) lastEmail = near.email; } document.addEventListener("submit", onSubmit, true); document.addEventListener("pointerdown", onPointerDownCapture, true); document.addEventListener("input", onPasswordInput, true); chrome.runtime.onMessage.addListener((msg, _s, sendResponse) => { if (msg?.type === "OPEN_SAVE_UI_WITH_DRAFT") { const d = msg.draft || {}; showConfirm( { pageUrl: d.pageUrl || location.href, title: d.title || buildTitle(), username: d.username || lastUsername || "", email: d.email || lastEmail || "", password: d.password || lastPassword || "", fromPending: true, pendingReason: d.pendingReason || "pending", }, { fromPending: true } ); sendResponse({ ok: true }); return false; } if (msg?.type === "OPEN_SAVE_UI") { openFromManual(); sendResponse({ ok: true }); return false; } return false; }); // After full navigation inject: if background still has pending for this tab // and did not manage to message us yet, pull once (belt-and-suspenders). function pullPendingOnLoad() { // Let tabs.onUpdated / OPEN_SAVE_UI_WITH_DRAFT win the race first. setTimeout(() => { if (document.getElementById(HOST_ID)) return; chrome.runtime.sendMessage({ type: "GET_PENDING" }, (res) => { void chrome.runtime.lastError; const p = res?.result?.pending; if (!p?.password || p.prompted) return; if (document.getElementById(HOST_ID)) return; // Dedupe before content-side pull opens UI (same site already saved). chrome.runtime.sendMessage( { type: "CHECK_DEDUPE", pageUrl: p.pageUrl || location.href, username: p.username || "", password: p.password || "", }, (dedupeRes) => { void chrome.runtime.lastError; const d = dedupeRes?.result || dedupeRes; if (d?.suppress) { chrome.runtime.sendMessage({ type: "CLEAR_PENDING" }, () => { void chrome.runtime.lastError; }); return; } if (document.getElementById(HOST_ID)) return; showConfirm( { pageUrl: p.pageUrl || location.href, title: p.title || buildTitle(), username: p.username || "", email: p.email || "", password: p.password, fromPending: true, pendingReason: "content_pull", }, { fromPending: true } ); chrome.runtime.sendMessage( { type: "MARK_PENDING_PROMPTED", promptedUrl: location.href, }, () => { void chrome.runtime.lastError; } ); } ); }); }, 700); } if (document.readyState === "complete") { pullPendingOnLoad(); } else { window.addEventListener("load", pullPendingOnLoad, { once: true }); } })();