Files

1237 lines
40 KiB
JavaScript
Raw Permalink Normal View History

/**
* MNote Vault extension service worker.
* Holds token + cookies + pending save drafts; content script only sends form fields.
*
* Save flow (KeePassXC-style, no webRequest):
* 1) content submit → PENDING_SAVE (background memory / session)
* 2) SPA same-page → fallback prompt after short delay
* 3) full navigation → tabs.onUpdated complete → OPEN_SAVE_UI_WITH_DRAFT
* 4) user confirms → SAVE_CREDENTIAL (+ cookies at confirm time)
* 5) P1: optional delayed session recapture after save
*/
import * as api from "./lib/api.js";
import * as storage from "./lib/storage.js";
const MAX_COOKIE_VALUE = 8 * 1024;
/** Pending draft TTL (KeePassXC-like short window across redirects). */
const PENDING_TTL_MS = 3 * 60 * 1000;
/** Max same-tab redirects before we still allow one prompt (then clear on show). */
const REDIRECT_ALLOWANCE = 5;
/** SPA: if no navigation, prompt on same page after this delay. */
const SPA_FALLBACK_MS = 1500;
/** P1: re-capture cookies after credential save (login cookies often land late). */
const SESSION_RECAPTURE_DELAY_MS = 2500;
const PENDING_STORAGE_KEY = "pendingByTab";
/**
* In-memory pending drafts: tabId → PendingDraft
* Mirrored to chrome.storage.session when available (SW restart).
* @type {Map<number, object>}
*/
const pendingByTab = new Map();
/** @type {Map<number, ReturnType<typeof setTimeout>>} */
const spaFallbackTimers = new Map();
/**
* @typedef {object} PendingDraft
* @property {string} username
* @property {string} password
* @property {string} [email]
* @property {string} pageUrl
* @property {string} origin
* @property {string} hostname
* @property {string} title
* @property {number} createdAt
* @property {number} expiresAt
* @property {number} redirectCount
* @property {boolean} [prompted]
* @property {string} [promptedUrl] URL where confirm UI was last shown
* @property {string} [submitUrl] URL at submit time (SPA vs full nav)
* @property {boolean} [fromSubmit]
*/
chrome.runtime.onInstalled.addListener(() => {
storage.loadConfig().then((cfg) => storage.setBadge(cfg.connected, cfg.email));
hydratePendingFromSession().catch(() => {});
});
chrome.runtime.onStartup.addListener(() => {
storage.loadConfig().then((cfg) => storage.setBadge(cfg.connected, cfg.email));
hydratePendingFromSession().catch(() => {});
});
// Restore pending map ASAP when SW wakes.
hydratePendingFromSession().catch(() => {});
/**
* @param {string} pageUrl
* @returns {{ origin: string, hostname: string }}
*/
function parseUrlParts(pageUrl) {
try {
const u = new URL(pageUrl);
return { origin: u.origin, hostname: u.hostname.toLowerCase() };
} catch {
return { origin: "", hostname: "" };
}
}
/**
* Same-site check for post-login redirects (e.g. login.example.com → www.example.com).
* Host equal, or one is a subdomain of the other, or same eTLD+1-ish parent.
* @param {string} a
* @param {string} b
*/
function sameSiteHost(a, b) {
if (!a || !b) return false;
const x = a.toLowerCase().replace(/^\./, "");
const y = b.toLowerCase().replace(/^\./, "");
if (x === y) return true;
if (x.endsWith("." + y) || y.endsWith("." + x)) return true;
const px = x.split(".");
const py = y.split(".");
if (px.length >= 2 && py.length >= 2) {
const rx = px.slice(-2).join(".");
const ry = py.slice(-2).join(".");
if (rx === ry && rx.includes(".")) return true;
}
return false;
}
function isExpired(draft) {
return !draft || !draft.expiresAt || Date.now() > draft.expiresAt;
}
/**
* Persist pending map for SW restarts (session storage only — never local/sync).
*/
async function persistPendingMap() {
if (!chrome.storage?.session) return;
/** @type {Record<string, object>} */
const obj = {};
for (const [tabId, draft] of pendingByTab.entries()) {
if (!isExpired(draft)) obj[String(tabId)] = draft;
}
try {
await chrome.storage.session.set({ [PENDING_STORAGE_KEY]: obj });
} catch (e) {
console.warn("[mnote-vault] persist pending failed", e);
}
}
async function hydratePendingFromSession() {
if (!chrome.storage?.session) return;
try {
const data = await chrome.storage.session.get(PENDING_STORAGE_KEY);
const obj = data?.[PENDING_STORAGE_KEY] || {};
for (const [k, draft] of Object.entries(obj)) {
const tabId = Number(k);
if (!Number.isFinite(tabId) || isExpired(draft)) continue;
pendingByTab.set(tabId, draft);
}
} catch (e) {
console.warn("[mnote-vault] hydrate pending failed", e);
}
}
/**
* @param {number} tabId
*/
function clearSpaFallback(tabId) {
const t = spaFallbackTimers.get(tabId);
if (t) {
clearTimeout(t);
spaFallbackTimers.delete(tabId);
}
}
/**
* @param {number} tabId
* @param {boolean} [persist]
*/
async function clearPending(tabId, persist = true) {
clearSpaFallback(tabId);
pendingByTab.delete(tabId);
if (persist) await persistPendingMap();
}
/**
* @param {number} tabId
* @param {object} fields
* @returns {PendingDraft}
*/
function buildPendingDraft(tabId, fields) {
const pageUrl = String(fields.pageUrl || "").trim();
const { origin, hostname } = parseUrlParts(pageUrl);
const now = Date.now();
const prev = pendingByTab.get(tabId);
// Fresh submit always resets prompted so a new login can show again.
const isFreshSubmit = fields.fromSubmit !== false && fields.resetPrompted !== false;
/** @type {PendingDraft} */
const draft = {
username: String(fields.username || prev?.username || "").trim(),
password: String(fields.password || prev?.password || ""),
email: String(fields.email || prev?.email || "").trim() || undefined,
pageUrl: pageUrl || prev?.pageUrl || "",
origin: origin || prev?.origin || "",
hostname: hostname || prev?.hostname || "",
title: String(fields.title || prev?.title || "").trim(),
createdAt: prev?.createdAt || now,
expiresAt: now + PENDING_TTL_MS,
redirectCount: isFreshSubmit ? 0 : prev?.redirectCount || 0,
prompted: isFreshSubmit ? false : Boolean(prev?.prompted),
promptedUrl: isFreshSubmit ? undefined : prev?.promptedUrl,
submitUrl: isFreshSubmit
? pageUrl || prev?.submitUrl || ""
: prev?.submitUrl || pageUrl || "",
fromSubmit: fields.fromSubmit !== false,
};
return draft;
}
/**
* @param {number} tabId
*/
async function markPendingPrompted(tabId, promptedUrl) {
const draft = pendingByTab.get(tabId);
if (!draft || isExpired(draft)) return false;
draft.prompted = true;
if (promptedUrl) draft.promptedUrl = promptedUrl;
pendingByTab.set(tabId, draft);
clearSpaFallback(tabId);
await persistPendingMap();
return true;
}
/**
* @param {number} tabId
* @param {PendingDraft} draft
* @param {{ spaFallback?: boolean }} [opts]
*/
async function setPending(tabId, draft, opts = {}) {
if (!draft.password && !draft.username) return null;
pendingByTab.set(tabId, draft);
await persistPendingMap();
clearSpaFallback(tabId);
if (opts.spaFallback !== false) {
const timer = setTimeout(() => {
spaFallbackTimers.delete(tabId);
const cur = pendingByTab.get(tabId);
if (!cur || isExpired(cur) || cur.prompted) return;
// SPA only if still on submit URL; full nav is handled by tabs.onUpdated.
chrome.tabs
.get(tabId)
.then((tab) => {
const cur2 = pendingByTab.get(tabId);
if (!cur2 || isExpired(cur2) || cur2.prompted) return null;
const tabUrl = tab?.url || "";
if (
tabUrl &&
cur2.submitUrl &&
tabUrl !== cur2.submitUrl &&
/^https?:/i.test(tabUrl)
) {
// Already navigated — prefer post-login prompt path.
return maybePromptPendingOnComplete(tabId, tabUrl);
}
return openSaveUiOnTab(tabId, cur2, { reason: "spa_fallback" });
})
.catch((e) =>
console.warn("[mnote-vault] spa fallback prompt failed", e)
);
}, SPA_FALLBACK_MS);
spaFallbackTimers.set(tabId, timer);
}
return draft;
}
/**
* @param {number} tabId
* @param {PendingDraft} draft
* @param {{ reason?: string, force?: boolean }} [opts]
*/
async function openSaveUiOnTab(tabId, draft, opts = {}) {
if (!draft || isExpired(draft)) {
await clearPending(tabId);
return { ok: false, error: "pending expired" };
}
if (draft.prompted && !opts.force) {
return { ok: true, skipped: true, reason: "already_prompted" };
}
let tabUrl = draft.pageUrl;
try {
const tab = await chrome.tabs.get(tabId);
if (tab?.url) tabUrl = tab.url;
} catch {
/* ignore */
}
// Dedupe: already saved same site+user+password → silent session update or skip.
if (!opts.force) {
try {
let decision = await storage.shouldSuppressPrompt({
pageUrl: draft.pageUrl || tabUrl,
username: draft.username,
password: draft.password,
});
// Local memory may be empty (first install / other device). Fall back to vault.
if (!decision.suppress && draft.password) {
const vaultDecision = await matchVaultSameCredential({
pageUrl: draft.pageUrl || tabUrl,
username: draft.username,
password: draft.password,
});
if (vaultDecision) {
decision = vaultDecision;
// Seed local memory so later prompts are free.
try {
await storage.rememberSavedSite({
pageUrl: draft.pageUrl || tabUrl,
username: draft.username,
password: draft.password,
credentialId: vaultDecision.credentialId,
hasSession: !vaultDecision.needSessionOnly,
});
} catch {
/* ignore */
}
}
}
if (decision.suppress) {
if (
(decision.reason === "already_saved_same_password" ||
decision.reason === "vault_same_password") &&
decision.credentialId
) {
// Silent session-only refresh when missing session, or best-effort refresh.
if (decision.needSessionOnly !== false) {
try {
const cfg = await storage.loadConfig();
if (cfg.connected) {
await ensureHostPermission(cfg.baseUrl, tabUrl || draft.pageUrl);
const cap = await captureCookies(tabUrl || draft.pageUrl);
if (cap.cookieHeader || (cap.cookies && cap.cookies.length)) {
await api.putSession(cfg, decision.credentialId, {
accountId: decision.accountId || "primary",
cookieHeader: cap.cookieHeader,
cookies: cap.cookies,
origin: cap.origin,
});
await storage.rememberSavedSite({
pageUrl: draft.pageUrl || tabUrl,
username: draft.username,
password: draft.password,
credentialId: decision.credentialId,
hasSession: true,
});
console.info(
"[mnote-vault] silent session update ok",
decision.credentialId
);
}
}
} catch (e) {
console.warn("[mnote-vault] silent session update failed", e);
}
}
}
draft.prompted = true;
draft.promptedUrl = tabUrl;
pendingByTab.set(tabId, draft);
await persistPendingMap();
clearSpaFallback(tabId);
// Drop pending so further redirects do not re-evaluate.
await clearPending(tabId);
return {
ok: true,
skipped: true,
reason: decision.reason || "deduped",
credentialId: decision.credentialId,
};
}
} catch (e) {
console.warn("[mnote-vault] dedupe check failed", e);
}
}
draft.prompted = true;
draft.promptedUrl = tabUrl;
pendingByTab.set(tabId, draft);
await persistPendingMap();
clearSpaFallback(tabId);
const payload = {
type: "OPEN_SAVE_UI_WITH_DRAFT",
draft: {
pageUrl: draft.pageUrl || tabUrl,
title: draft.title,
username: draft.username,
password: draft.password,
email: draft.email || "",
fromPending: true,
pendingReason: opts.reason || "pending",
},
};
try {
await chrome.tabs.sendMessage(tabId, payload);
return { ok: true, reason: opts.reason || "pending" };
} catch (e) {
// Content script may not be ready yet — retry once shortly.
await new Promise((r) => setTimeout(r, 400));
try {
await chrome.tabs.sendMessage(tabId, payload);
return { ok: true, reason: opts.reason || "pending", retried: true };
} catch (e2) {
// Allow another attempt on next complete if injection still fails.
draft.prompted = false;
draft.promptedUrl = undefined;
pendingByTab.set(tabId, draft);
await persistPendingMap();
console.warn("[mnote-vault] OPEN_SAVE_UI_WITH_DRAFT failed", e2);
return { ok: false, error: e2?.message || String(e2) };
}
}
}
/**
* After full navigation completes: same tab + same site → show confirm with draft.
* @param {number} tabId
* @param {string} tabUrl
*/
async function maybePromptPendingOnComplete(tabId, tabUrl) {
const draft = pendingByTab.get(tabId);
if (!draft || isExpired(draft)) {
if (draft) await clearPending(tabId);
return;
}
const { hostname } = parseUrlParts(tabUrl || "");
if (hostname && draft.hostname && !sameSiteHost(hostname, draft.hostname)) {
// Navigated away to unrelated site — drop pending.
await clearPending(tabId);
return;
}
// If we already showed UI on this exact URL, do not spam.
if (draft.prompted && draft.promptedUrl && tabUrl && draft.promptedUrl === tabUrl) {
return;
}
// After a successful prompt on the login URL, do NOT re-open on every
// post-login hop (that is the main "keeps asking" UX bug). User can still
// open popup manually if the first prompt was closed accidentally.
if (draft.prompted) return;
draft.redirectCount = (draft.redirectCount || 0) + 1;
if (draft.redirectCount > REDIRECT_ALLOWANCE) {
await clearPending(tabId);
return;
}
// Prefer post-login URL for matching / cookie capture display.
if (tabUrl && /^https?:/i.test(tabUrl)) {
draft.pageUrl = tabUrl;
const parts = parseUrlParts(tabUrl);
if (parts.origin) draft.origin = parts.origin;
if (parts.hostname) draft.hostname = parts.hostname;
}
pendingByTab.set(tabId, draft);
await persistPendingMap();
await openSaveUiOnTab(tabId, draft, { reason: "nav_complete" });
}
chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
if (changeInfo.status !== "complete") return;
const url = tab?.url || changeInfo.url || "";
if (!url || !/^https?:/i.test(url)) return;
if (!pendingByTab.has(tabId)) return;
maybePromptPendingOnComplete(tabId, url).catch((e) =>
console.warn("[mnote-vault] pending onUpdated failed", e)
);
});
chrome.tabs.onRemoved.addListener((tabId) => {
clearPending(tabId).catch(() => {});
});
chrome.alarms.onAlarm.addListener((alarm) => {
if (!alarm?.name?.startsWith("mnote-vault-recapture:")) return;
// name: mnote-vault-recapture:<credentialId>:<accountId>
// pageUrl stored via chrome.storage.session under same key for SW restart.
const raw = alarm.name.slice("mnote-vault-recapture:".length);
runSessionRecaptureFromAlarm(raw).catch((e) =>
console.warn("[mnote-vault] session recapture failed", e)
);
});
/**
* Ensure host permission for baseUrl (+ optional page origin for cookies).
* Prefer broad http(s) origins when available so cookies.getAll works on any site.
* @param {string} baseUrl
* @param {string} [pageUrl]
* @param {{ broad?: boolean }} [opts]
*/
async function ensureHostPermission(baseUrl, pageUrl, opts = {}) {
const origins = [];
// Broad grant: required to read cookies on arbitrary login sites.
if (opts.broad !== false) {
origins.push("http://*/*", "https://*/*");
}
try {
if (baseUrl) {
const b = new URL(api.normalizeBaseUrl(baseUrl));
origins.push(`${b.origin}/*`);
}
} catch {
/* ignore */
}
if (pageUrl) {
try {
const p = new URL(pageUrl);
origins.push(`${p.origin}/*`);
} catch {
/* ignore */
}
}
// de-dupe
const uniq = [...new Set(origins.filter(Boolean))];
if (!uniq.length) return true;
try {
const have = await chrome.permissions.contains({ origins: uniq });
if (have) return true;
// service worker 内 request 常无用户手势而失败;保存确认层应在 content 点击手势内先申请。
return await chrome.permissions.request({ origins: uniq });
} catch {
// Fallback: try page origin only (narrower permission dialog).
if (pageUrl) {
try {
const p = new URL(pageUrl);
const narrow = [`${p.origin}/*`];
if (baseUrl) {
try {
narrow.push(`${new URL(api.normalizeBaseUrl(baseUrl)).origin}/*`);
} catch {
/* ignore */
}
}
const haveN = await chrome.permissions.contains({ origins: narrow });
if (haveN) return true;
return await chrome.permissions.request({ origins: narrow });
} catch {
return false;
}
}
return false;
}
}
/**
* Whether extension currently has host access for cookies on pageUrl.
* @param {string} pageUrl
*/
async function hasCookieHostAccess(pageUrl) {
try {
const broad = await chrome.permissions.contains({
origins: ["http://*/*", "https://*/*"],
});
if (broad) return true;
if (!pageUrl) return false;
const origin = new URL(pageUrl).origin;
return await chrome.permissions.contains({ origins: [`${origin}/*`] });
} catch {
return false;
}
}
/**
* Server-side dedupe: same origin + same username + same password already in vault.
* Uses list + reveal (extension token has view/edit scopes).
* @param {{ pageUrl: string, username?: string, password?: string }} draft
* @returns {Promise<null | { suppress: true, reason: string, credentialId: string, accountId?: string, needSessionOnly?: boolean }>}
*/
async function matchVaultSameCredential(draft) {
const password = String(draft.password || "");
if (!password) return null;
const cfg = await storage.loadConfig();
if (!cfg.connected) return null;
try {
await ensureHostPermission(cfg.baseUrl, undefined, { broad: false });
const listed = await api.listVault(cfg);
const items =
listed?.result?.items || listed?.items || listed?.result || [];
const arr = Array.isArray(items) ? items : [];
const hit = api.findMatchingCredential(arr, draft.pageUrl, draft.username);
if (!hit?.id) return null;
// Prefer matching account slot when multi-account.
let accountId;
if (Array.isArray(hit.accounts) && hit.accounts.length) {
const want = String(draft.username || "")
.trim()
.toLowerCase();
const acc =
hit.accounts.find((a) => {
const u = String(a?.username || a?.email || "")
.trim()
.toLowerCase();
return want && u === want;
}) || hit.accounts[0];
accountId = acc?.id ? String(acc.id) : undefined;
}
const revealed = await api.revealField(cfg, hit.id, {
field: "password",
accountId,
});
// Response shape: { result: { secret: { state, value } } } or unwrapped.
const body = revealed?.result || revealed;
const plain =
body?.secret?.value ??
body?.value ??
body?.password ??
body?.field?.value ??
"";
if (!plain || String(plain) !== password) {
// Different / missing password for same site/user → still prompt.
return null;
}
const hasSession = Boolean(
hit.hasLoginSession ||
(Array.isArray(hit.accounts) &&
hit.accounts.some((a) => a?.hasLoginSession))
);
return {
suppress: true,
reason: "vault_same_password",
credentialId: String(hit.id),
accountId: accountId || "primary",
needSessionOnly: !hasSession,
};
} catch (e) {
console.warn("[mnote-vault] vault dedupe failed", e);
return null;
}
}
/**
* Capture cookies for page origin (background only).
* Prefer domain-wide getAll so path/subdomain session cookies are not missed.
* Requires host permission for the page origin (see manifest host_permissions).
* @param {string} pageUrl
*/
async function captureCookies(pageUrl) {
let origin;
let hostname = "";
try {
const u = new URL(pageUrl);
origin = u.origin;
hostname = u.hostname || "";
} catch {
origin = undefined;
}
/** @type {chrome.cookies.Cookie[]} */
let list = [];
try {
// domain query covers all paths under host; url-only often misses sibling paths.
if (hostname) {
list = await chrome.cookies.getAll({ domain: hostname });
// also include parent-domain cookies (e.g. .heyunidc.cn) when host is www.
const parts = hostname.split(".").filter(Boolean);
if (parts.length >= 2) {
const parent = parts.slice(-2).join(".");
if (parent && parent !== hostname) {
const extra = await chrome.cookies.getAll({ domain: parent });
list = list.concat(extra);
}
}
}
if ((!list || list.length === 0) && origin) {
list = await chrome.cookies.getAll({ url: origin + "/" });
}
if ((!list || list.length === 0) && pageUrl) {
list = await chrome.cookies.getAll({ url: pageUrl });
}
} catch (e) {
console.warn("[mnote-vault] cookies.getAll failed", e);
return {
cookieHeader: "",
cookies: [],
count: 0,
origin,
error: e?.message || String(e),
};
}
// de-dupe by name+domain+path
const seen = new Set();
const cookies = [];
const parts = [];
for (const c of list || []) {
const key = `${c.name}\0${c.domain}\0${c.path}`;
if (seen.has(key)) continue;
seen.add(key);
let value = c.value || "";
if (value.length > MAX_COOKIE_VALUE) {
value = value.slice(0, MAX_COOKIE_VALUE);
}
cookies.push({
name: c.name,
value,
domain: c.domain,
path: c.path,
secure: c.secure,
httpOnly: c.httpOnly,
sameSite: c.sameSite,
expirationDate: c.expirationDate,
});
parts.push(`${c.name}=${value}`);
}
return {
cookieHeader: parts.join("; "),
cookies,
count: cookies.length,
origin,
};
}
/**
* @param {string} alarmKey credentialId:accountId (pageUrl in session storage)
*/
async function runSessionRecaptureFromAlarm(alarmKey) {
const storeKey = `recapture:${alarmKey}`;
let job = null;
try {
if (chrome.storage?.session) {
const data = await chrome.storage.session.get(storeKey);
job = data?.[storeKey] || null;
await chrome.storage.session.remove(storeKey);
}
} catch {
/* ignore */
}
if (!job?.credentialId || !job?.pageUrl) return;
const { credentialId, accountId = "primary", pageUrl } = job;
const cfg = await storage.loadConfig();
if (!cfg.connected) return;
await ensureHostPermission(cfg.baseUrl, pageUrl);
const cap = await captureCookies(pageUrl);
if (!cap.cookieHeader && !(cap.cookies && cap.cookies.length)) {
console.info(
"[mnote-vault] session recapture: still no cookies",
credentialId
);
return;
}
await api.putSession(cfg, credentialId, {
accountId,
cookieHeader: cap.cookieHeader,
cookies: cap.cookies,
origin: cap.origin,
});
console.info("[mnote-vault] session recapture ok", credentialId);
}
/**
* P1: after save, wait for session cookies then PUT again (alarms survive SW sleep).
* Note: Chrome may clamp delayInMinutes to ~1 min minimum in practice.
* @param {object} opts
* @param {string} opts.credentialId
* @param {string} opts.accountId
* @param {string} opts.pageUrl
* @param {number} [opts.delayMs]
*/
async function scheduleSessionRecapture(opts) {
const {
credentialId,
accountId = "primary",
pageUrl,
delayMs = SESSION_RECAPTURE_DELAY_MS,
} = opts;
if (!credentialId || !pageUrl) return;
const alarmKey = `${credentialId}:${accountId}`;
const storeKey = `recapture:${alarmKey}`;
try {
if (chrome.storage?.session) {
await chrome.storage.session.set({
[storeKey]: { credentialId, accountId, pageUrl },
});
}
} catch (e) {
console.warn("[mnote-vault] recapture store failed", e);
}
// Prefer short setTimeout while SW is warm; also schedule alarm as backup.
setTimeout(() => {
runSessionRecaptureFromAlarm(alarmKey).catch((e) =>
console.warn("[mnote-vault] session recapture failed", e)
);
}, delayMs);
try {
const delayInMinutes = Math.max(delayMs / 60000, 0.05);
chrome.alarms.create(`mnote-vault-recapture:${alarmKey}`, {
delayInMinutes,
});
} catch {
/* alarms optional */
}
}
/**
* @param {object} payload
* @param {string} payload.pageUrl
* @param {string} payload.title
* @param {string} payload.username
* @param {string} payload.password
* @param {string} [payload.email]
* @param {boolean} [payload.saveSession]
* @param {boolean} [payload.shareToAi]
* @param {string} [payload.mode] create | append
* @param {string} [payload.existingId]
* @param {number} [payload.tabId]
* @param {boolean} [payload.recaptureSession]
*/
async function saveCredential(payload) {
const cfg = await storage.loadConfig();
if (!cfg.connected) {
throw new Error("未连接密码箱:请先在扩展 Options 用账号密码连接");
}
await ensureHostPermission(cfg.baseUrl, payload.pageUrl);
let credentialId = payload.existingId || null;
let accountId = "primary";
if (payload.mode === "append" && payload.existingId) {
const got = await api.getItem(cfg, payload.existingId);
const item = got?.result?.item || got?.item || got?.result;
const accounts = Array.isArray(item?.accounts) ? [...item.accounts] : [];
const newId = `acc_${Date.now().toString(36)}`;
accounts.push({
id: newId,
label: "from-extension",
username: payload.username || "",
password: payload.password || "",
email: payload.email || undefined,
});
await api.updateItem(cfg, payload.existingId, { accounts });
credentialId = payload.existingId;
accountId = newId;
} else {
const title =
(payload.title || "").trim() ||
(() => {
try {
return new URL(payload.pageUrl).hostname;
} catch {
return "Saved credential";
}
})();
// folderPath:用户显式选择(含空=未分组)优先;否则上次选用
const folderPath =
payload.folderPath !== undefined && payload.folderPath !== null
? storage.normalizeFolderPath(payload.folderPath)
: storage.normalizeFolderPath(cfg.lastFolderPath) || "";
const created = await api.createItem(cfg, {
title,
url: payload.pageUrl,
username: payload.username || "",
password: payload.password || "",
email: payload.email || undefined,
tags: ["from-extension"],
...(folderPath ? { folderPath } : {}),
notesMarkdown: "Saved via MNote Vault extension",
});
// 记住本次分组,下次确认层默认选中
await storage.saveLastFolderPath(folderPath);
const item = created?.result?.item || created?.item;
credentialId = item?.id;
if (!credentialId) {
throw new Error("创建条目失败:响应无 id");
}
// 与服务端账号槽 id 对齐;无 accounts 时仍用 primary 文件名
const firstAcc = Array.isArray(item?.accounts) && item.accounts[0];
if (firstAcc && firstAcc.id) {
accountId = String(firstAcc.id);
}
}
let sessionResult = null;
let sessionError = null;
let sessionSaved = false;
const wantSession = payload.saveSession !== false;
if (wantSession) {
try {
// Prefer host grant granted earlier from content click gesture.
if (payload.hostPermissionGranted) {
/* already requested in content */
} else {
await ensureHostPermission(cfg.baseUrl, payload.pageUrl, {
broad: true,
});
}
const hasHost = await hasCookieHostAccess(payload.pageUrl);
const cap = await captureCookies(payload.pageUrl);
if (cap.cookieHeader || (cap.cookies && cap.cookies.length)) {
sessionResult = await api.putSession(cfg, credentialId, {
accountId,
cookieHeader: cap.cookieHeader,
cookies: cap.cookies,
origin: cap.origin,
});
sessionSaved = true;
} else {
const detail = cap.error ? `${cap.error}` : "";
if (!hasHost) {
sessionError =
`未授权读取此站 Cookie${detail}。请在保存前允许「网站访问权限→所有网站」,或点扩展详情手动开启。`;
} else {
sessionError =
`未捕获到 Cookie${detail}(将在数秒后自动重试;登录 Cookie 可能尚未写入)`;
}
}
} catch (e) {
sessionError = e?.message || String(e);
}
// P1: always schedule one delayed recapture when user asked for session.
if (payload.recaptureSession !== false) {
await scheduleSessionRecapture({
credentialId,
accountId,
pageUrl: payload.pageUrl,
});
}
}
let shareResult = null;
if (payload.shareToAi) {
try {
shareResult = await api.shareToAi(cfg, credentialId);
} catch (e) {
shareResult = { error: e?.message || String(e) };
}
}
// Remember save for auto-prompt dedupe (same origin+user+password).
try {
await storage.rememberSavedSite({
pageUrl: payload.pageUrl,
username: payload.username,
password: payload.password,
credentialId,
hasSession: sessionSaved,
});
} catch (e) {
console.warn("[mnote-vault] rememberSavedSite failed", e);
}
// Clear pending for this tab after successful save.
if (payload.tabId != null) {
await clearPending(payload.tabId);
}
return {
ok: true,
credentialId,
accountId,
sessionSaved,
sessionResult: sessionResult?.result || sessionResult,
sessionError,
shareResult: shareResult?.result || shareResult,
sessionRecaptureScheduled: wantSession && payload.recaptureSession !== false,
};
}
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
handleMessage(message, sender)
.then((result) => sendResponse({ ok: true, result }))
.catch((err) =>
sendResponse({
ok: false,
error: err?.message || String(err),
code: err?.code,
})
);
return true; // async
});
/**
* @param {object} message
* @param {chrome.runtime.MessageSender} [sender]
*/
async function handleMessage(message, sender) {
const type = message?.type;
const senderTabId = sender?.tab?.id;
switch (type) {
case "GET_STATUS": {
const cfg = await storage.loadConfig();
await storage.setBadge(cfg.connected, cfg.email);
return {
connected: cfg.connected,
baseUrl: cfg.baseUrl,
rootUri: cfg.rootUri,
email: cfg.email,
userId: cfg.userId,
hasToken: Boolean(cfg.token),
};
}
case "CONNECT": {
const baseUrl = api.normalizeBaseUrl(message.baseUrl);
const email = String(message.email || message.account || "").trim();
const password = String(message.password || "");
if (!email || !password) {
throw new Error("需要 MNote 账号与密码");
}
const okPerm = await ensureHostPermission(baseUrl);
if (!okPerm) {
throw new Error(
`需要允许扩展访问 ${new URL(baseUrl).origin}(请在 Options 点连接时允许权限弹窗;或 chrome://extensions → 扩展详情 → 网站访问权限)`
);
}
try {
await api.signIn(baseUrl, { email, password });
} catch (e) {
const m = e?.message || String(e);
if (/Failed to fetch|NetworkError|Load failed/i.test(m)) {
throw new Error(
`无法连接 ${baseUrl}${m}。请确认外网隧道可达,且已允许扩展访问该 origin(含端口)`
);
}
throw e;
}
// 默认:用账号固定 my-space;仅当用户显式填了 file:// 才覆盖
let rootUri = String(message.rootUri || "").trim();
let rootPath;
let workspaceSource = "override";
if (!rootUri || rootUri === "auto") {
const ws = await api.ensureDefaultWorkspace(baseUrl, {
credentials: true,
});
rootUri = ws.rootUri;
rootPath = ws.rootPath;
workspaceSource = "default";
} else if (!rootUri.startsWith("file://")) {
throw new Error("rootUri 若填写必须是 file:// 本地工作区路径");
}
const issued = await api.issueExtensionToken(baseUrl, {
email,
extensionId: chrome.runtime.id,
ttlHours: message.ttlHours ?? 168,
});
const result = issued?.result || issued;
const token = result?.token;
if (!token || !String(token).startsWith("mnext1.")) {
throw new Error("签发 extension token 失败");
}
await storage.savePersistent({
baseUrl,
rootUri,
rootPath: rootPath || undefined,
userId: result.userId || email,
email: result.email || email,
connectedAt: new Date().toISOString(),
});
await storage.saveSession({
token,
tokenExpiresAt: result.expiresAt,
jti: result.jti,
});
await storage.setBadge(true, result.email || email);
return {
connected: true,
userId: result.userId,
email: result.email || email,
expiresAt: result.expiresAt,
rootUri,
rootPath,
workspaceSource,
};
}
case "DISCONNECT": {
await storage.clearSession();
await storage.setBadge(false);
return { connected: false };
}
case "MATCH_URL": {
const cfg = await storage.loadConfig();
if (!cfg.connected) return { connected: false, items: [] };
await ensureHostPermission(cfg.baseUrl);
const listed = await api.listVault(cfg);
const items =
listed?.result?.items || listed?.items || listed?.result || [];
const arr = Array.isArray(items) ? items : [];
return {
connected: true,
items: api.matchItemsByUrl(arr, message.pageUrl || "").slice(0, 5),
};
}
case "LIST_FOLDERS": {
const cfg = await storage.loadConfig();
if (!cfg.connected) {
return { connected: false, folders: [], lastFolderPath: "" };
}
await ensureHostPermission(cfg.baseUrl);
const listed = await api.listVault(cfg);
const items =
listed?.result?.items || listed?.items || listed?.result || [];
const arr = Array.isArray(items) ? items : [];
return {
connected: true,
folders: api.collectFolderPaths(arr),
lastFolderPath: cfg.lastFolderPath || "",
};
}
case "ENSURE_PAGE_HOST": {
const pageUrl = message.pageUrl || "";
const cfg = await storage.loadConfig();
// Prefer broad http(s) so future sites work without another prompt.
const ok = await ensureHostPermission(cfg.baseUrl, pageUrl, {
broad: message.broad !== false,
});
const hasHost = await hasCookieHostAccess(pageUrl);
return { ok, pageUrl, hasHost };
}
case "DISMISS_SAVE": {
const pageUrl = message.pageUrl || "";
const username = message.username || "";
const forever = Boolean(message.forever);
await storage.rememberDismissedSite({ pageUrl, username, forever });
const tabId = message.tabId ?? senderTabId;
if (tabId != null) await clearPending(tabId);
return { ok: true, forever };
}
case "CHECK_DEDUPE": {
return storage.shouldSuppressPrompt({
pageUrl: message.pageUrl || "",
username: message.username || "",
password: message.password || "",
});
}
case "PENDING_SAVE": {
const tabId = message.tabId ?? senderTabId;
if (tabId == null) throw new Error("PENDING_SAVE 需要 tabId");
const draft = buildPendingDraft(tabId, {
username: message.username ?? message.payload?.username,
password: message.password ?? message.payload?.password,
email: message.email ?? message.payload?.email,
pageUrl: message.pageUrl ?? message.payload?.pageUrl,
title: message.title ?? message.payload?.title,
fromSubmit: message.fromSubmit !== false,
});
if (!draft.password) {
throw new Error("PENDING_SAVE 需要 password");
}
await setPending(tabId, draft, {
spaFallback: message.spaFallback !== false,
});
return {
ok: true,
tabId,
expiresAt: draft.expiresAt,
hostname: draft.hostname,
};
}
case "GET_PENDING": {
const tabId = message.tabId ?? senderTabId;
if (tabId == null) return { pending: null };
const draft = pendingByTab.get(tabId);
if (!draft || isExpired(draft)) {
if (draft) await clearPending(tabId);
return { pending: null };
}
return {
pending: {
username: draft.username,
password: draft.password,
email: draft.email || "",
pageUrl: draft.pageUrl,
title: draft.title,
hostname: draft.hostname,
expiresAt: draft.expiresAt,
redirectCount: draft.redirectCount,
prompted: Boolean(draft.prompted),
},
};
}
case "CLEAR_PENDING": {
const tabId = message.tabId ?? senderTabId;
if (tabId != null) await clearPending(tabId);
return { cleared: true };
}
case "MARK_PENDING_PROMPTED": {
const tabId = message.tabId ?? senderTabId;
if (tabId == null) return { marked: false };
const marked = await markPendingPrompted(
tabId,
message.promptedUrl || sender?.tab?.url || message.pageUrl
);
return { marked };
}
case "PROMPT_PENDING": {
// Force-open confirm from popup / toolbar using pending or empty draft.
const tabId = message.tabId ?? senderTabId;
if (tabId == null) throw new Error("需要 tabId");
let draft = pendingByTab.get(tabId);
if (draft && isExpired(draft)) {
await clearPending(tabId);
draft = undefined;
}
if (draft) {
return openSaveUiOnTab(tabId, draft, {
reason: "manual_prompt",
force: true,
});
}
// No pending: ask content to open UI from live form / empty.
try {
await chrome.tabs.sendMessage(tabId, { type: "OPEN_SAVE_UI" });
return { ok: true, reason: "live_form" };
} catch (e) {
throw new Error(
e?.message ||
"无法打开确认层(受保护页面或 content script 未加载)"
);
}
}
case "SAVE_CREDENTIAL": {
const payload = { ...(message.payload || message) };
if (payload.tabId == null && senderTabId != null) {
payload.tabId = senderTabId;
}
return saveCredential(payload);
}
case "UPDATE_SESSION": {
const cfg = await storage.loadConfig();
if (!cfg.connected) throw new Error("未连接");
const pageUrl = message.pageUrl;
const id = message.credentialId || message.id;
if (!id || !pageUrl) throw new Error("需要 credentialId 与 pageUrl");
await ensureHostPermission(cfg.baseUrl, pageUrl);
const cap = await captureCookies(pageUrl);
if (!cap.cookieHeader && !(cap.cookies && cap.cookies.length)) {
throw new Error("未捕获到 Cookie");
}
const res = await api.putSession(cfg, id, {
accountId: message.accountId || "primary",
cookieHeader: cap.cookieHeader,
cookies: cap.cookies,
origin: cap.origin,
});
return res?.result || res;
}
case "CAPTURE_COOKIE_COUNT": {
const pageUrl = message.pageUrl;
if (!pageUrl) return { count: 0 };
await ensureHostPermission("", pageUrl);
const cap = await captureCookies(pageUrl);
return { count: cap.count, origin: cap.origin, error: cap.error || null };
}
default:
throw new Error(`未知消息类型: ${type}`);
}
}