Files
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

1237 lines
40 KiB
JavaScript
Raw Permalink 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.
/**
* 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}`);
}
}