Files
mnote/extensions/mnote-vault/content.js
T
Agent Board 262e66b02e feat: purge legacy agent hosts and land vault Chrome extension path
Retire ACP/Hermes/OpenCode surfaces and rename hermes_tools to
mnote_agent_tools so Page AI stays on Pi Lab only. Add Chrome vault
extension + extension token route, pre-release purge design, and soft-retire
legacy smokes for the small-group production cut.
2026-07-25 14:25:37 +08:00

656 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 = `
<h3>保存到 MNote 密码箱</h3>
${
fromPending
? `<div class="banner">已捕获登录提交${draft.pendingReason === "nav_complete" ? "(登录后页面)" : ""} · 请确认后保存</div>`
: ""
}
<label>标题</label>
<input type="text" id="title" />
<label>URL</label>
<input type="text" id="url" />
<label>用户名</label>
<input type="text" id="username" autocomplete="off" />
<label>密码</label>
<div class="pw-wrap">
<input type="password" id="password" autocomplete="off" />
<button type="button" class="btn-pw-toggle" id="togglePw" aria-label="显示密码" title="显示/隐藏密码">显示</button>
</div>
<label>分组</label>
<select id="folder">
<option value="">(无分组)</option>
<option value="__new__"> 新建分组…</option>
</select>
<div class="folder-new" id="folderNewWrap">
<label>新分组名(可用 / 分层,如 工作/客户)</label>
<input type="text" id="folderNew" placeholder="例如:个人 / 工作 / 客户A" autocomplete="off" />
</div>
<div class="muted folder-hint" id="folderHint">读取已有分组…</div>
<label>匹配已有条目</label>
<select id="match">
<option value="create">新建条目</option>
</select>
<div class="row">
<input type="checkbox" id="saveSession" checked />
<label for="saveSession" style="margin:0;color:#e8eaed">同时保存登录态(Cookie</label>
</div>
<div class="row">
<input type="checkbox" id="shareAi" />
<label for="shareAi" style="margin:0;color:#e8eaed">同步到 AI 密码本</label>
</div>
<div class="row">
<input type="checkbox" id="neverAsk" />
<label for="neverAsk" style="margin:0;color:#e8eaed">此网站不再询问</label>
</div>
<div class="muted" id="cookieHint"></div>
<div class="msg" id="msg"></div>
<div class="actions">
<button type="button" class="btn-cancel" id="cancel">取消</button>
<button type="button" class="btn-save" id="save">保存</button>
</div>
`;
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 });
}
})();