656 lines
23 KiB
JavaScript
656 lines
23 KiB
JavaScript
/**
|
||||
|
|
* 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 });
|
|||
|
|
}
|
|||
|
|
})();
|