/** * 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} */ const pendingByTab = new Map(); /** @type {Map>} */ 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} */ 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:: // 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} */ 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}`); } }