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