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.
292 lines
8.8 KiB
JavaScript
292 lines
8.8 KiB
JavaScript
/**
|
|
* Persistent config (sync/local) vs session token.
|
|
*/
|
|
|
|
const SYNC_KEYS = [
|
|
"baseUrl",
|
|
"rootUri",
|
|
"rootPath",
|
|
"userId",
|
|
"email",
|
|
"connectedAt",
|
|
/** 上次保存选用的分组路径,扩展确认层默认选中 */
|
|
"lastFolderPath",
|
|
/**
|
|
* 去重记忆(不跨浏览器配置同步,仅 local):
|
|
* - savedSites: { [origin]: { [usernameKey]: { credentialId, passwordFp, savedAt, hasSession? } } }
|
|
* - dismissedSites: { [origin]: { [usernameKey]: dismissedAt }
|
|
* - neverAskOrigins: { [origin]: true }
|
|
*/
|
|
"savedSites",
|
|
"dismissedSites",
|
|
"neverAskOrigins",
|
|
];
|
|
const SESSION_KEYS = ["token", "tokenExpiresAt", "jti"];
|
|
|
|
/** Normalize username for dedupe keys. */
|
|
export function normalizeUsernameKey(username) {
|
|
return String(username || "")
|
|
.trim()
|
|
.toLowerCase();
|
|
}
|
|
|
|
/**
|
|
* Lightweight non-cryptographic fingerprint for password equality checks.
|
|
* Not a security hash — only used to avoid re-prompting the same login.
|
|
* @param {string} password
|
|
*/
|
|
export function passwordFingerprint(password) {
|
|
const s = String(password || "");
|
|
// FNV-1a 32-bit + length, enough for local dedupe.
|
|
let h = 0x811c9dc5;
|
|
for (let i = 0; i < s.length; i++) {
|
|
h ^= s.charCodeAt(i);
|
|
h = Math.imul(h, 0x01000193);
|
|
}
|
|
return `fp_${(h >>> 0).toString(16)}_${s.length}`;
|
|
}
|
|
|
|
/**
|
|
* @param {string} pageUrl
|
|
* @returns {string} origin or ""
|
|
*/
|
|
export function originFromUrl(pageUrl) {
|
|
try {
|
|
return new URL(String(pageUrl || "")).origin;
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @returns {Promise<{savedSites:object, dismissedSites:object, neverAskOrigins:object}>}
|
|
*/
|
|
export async function loadDedupeState() {
|
|
const data = await chrome.storage.local.get([
|
|
"savedSites",
|
|
"dismissedSites",
|
|
"neverAskOrigins",
|
|
]);
|
|
return {
|
|
savedSites:
|
|
data.savedSites && typeof data.savedSites === "object"
|
|
? data.savedSites
|
|
: {},
|
|
dismissedSites:
|
|
data.dismissedSites && typeof data.dismissedSites === "object"
|
|
? data.dismissedSites
|
|
: {},
|
|
neverAskOrigins:
|
|
data.neverAskOrigins && typeof data.neverAskOrigins === "object"
|
|
? data.neverAskOrigins
|
|
: {},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Remember a successful save so the same site+user+password does not re-prompt.
|
|
* @param {{ pageUrl: string, username?: string, password?: string, credentialId?: string, hasSession?: boolean }} info
|
|
*/
|
|
export async function rememberSavedSite(info) {
|
|
const origin = originFromUrl(info.pageUrl);
|
|
if (!origin) return;
|
|
const userKey = normalizeUsernameKey(info.username) || "_";
|
|
const state = await loadDedupeState();
|
|
const byOrigin = { ...(state.savedSites[origin] || {}) };
|
|
byOrigin[userKey] = {
|
|
credentialId: info.credentialId || byOrigin[userKey]?.credentialId || "",
|
|
passwordFp: passwordFingerprint(info.password || ""),
|
|
savedAt: new Date().toISOString(),
|
|
hasSession: Boolean(info.hasSession),
|
|
};
|
|
const savedSites = { ...state.savedSites, [origin]: byOrigin };
|
|
// Clear dismiss for this user after explicit save.
|
|
const dismissed = { ...(state.dismissedSites[origin] || {}) };
|
|
delete dismissed[userKey];
|
|
const dismissedSites = { ...state.dismissedSites };
|
|
if (Object.keys(dismissed).length) dismissedSites[origin] = dismissed;
|
|
else delete dismissedSites[origin];
|
|
await chrome.storage.local.set({ savedSites, dismissedSites });
|
|
}
|
|
|
|
/**
|
|
* User cancelled save prompt for this site+username (suppress for a while).
|
|
* @param {{ pageUrl: string, username?: string, forever?: boolean }} info
|
|
*/
|
|
export async function rememberDismissedSite(info) {
|
|
const origin = originFromUrl(info.pageUrl);
|
|
if (!origin) return;
|
|
if (info.forever) {
|
|
const state = await loadDedupeState();
|
|
await chrome.storage.local.set({
|
|
neverAskOrigins: { ...state.neverAskOrigins, [origin]: true },
|
|
});
|
|
return;
|
|
}
|
|
const userKey = normalizeUsernameKey(info.username) || "_";
|
|
const state = await loadDedupeState();
|
|
const byOrigin = { ...(state.dismissedSites[origin] || {}) };
|
|
byOrigin[userKey] = Date.now();
|
|
await chrome.storage.local.set({
|
|
dismissedSites: { ...state.dismissedSites, [origin]: byOrigin },
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Decide whether auto-prompt should be suppressed for this login draft.
|
|
* @param {{ pageUrl: string, username?: string, password?: string }} draft
|
|
* @param {{ dismissTtlMs?: number }} [opts]
|
|
* @returns {Promise<{ suppress: boolean, reason?: string, credentialId?: string, needSessionOnly?: boolean }>}
|
|
*/
|
|
export async function shouldSuppressPrompt(draft, opts = {}) {
|
|
const origin = originFromUrl(draft.pageUrl);
|
|
if (!origin) return { suppress: false };
|
|
const userKey = normalizeUsernameKey(draft.username) || "_";
|
|
const state = await loadDedupeState();
|
|
if (state.neverAskOrigins[origin]) {
|
|
return { suppress: true, reason: "never_ask_origin" };
|
|
}
|
|
const dismissTtlMs = opts.dismissTtlMs ?? 14 * 24 * 60 * 60 * 1000;
|
|
const dismissedAt = state.dismissedSites?.[origin]?.[userKey];
|
|
if (typeof dismissedAt === "number" && Date.now() - dismissedAt < dismissTtlMs) {
|
|
return { suppress: true, reason: "recently_dismissed" };
|
|
}
|
|
const saved = state.savedSites?.[origin]?.[userKey];
|
|
if (saved && draft.password) {
|
|
const fp = passwordFingerprint(draft.password);
|
|
if (saved.passwordFp === fp) {
|
|
// Same account+password already saved: do not re-ask for credential.
|
|
// If session was never captured, allow a silent session-only update path.
|
|
return {
|
|
suppress: true,
|
|
reason: "already_saved_same_password",
|
|
credentialId: saved.credentialId || "",
|
|
needSessionOnly: !saved.hasSession,
|
|
};
|
|
}
|
|
}
|
|
return { suppress: false };
|
|
}
|
|
|
|
/**
|
|
* @returns {Promise<{baseUrl?:string, rootUri?:string, userId?:string, email?:string, connectedAt?:string, lastFolderPath?:string}>}
|
|
*/
|
|
export async function loadPersistent() {
|
|
return chrome.storage.local.get(SYNC_KEYS);
|
|
}
|
|
|
|
/**
|
|
* @param {object} data
|
|
*/
|
|
export async function savePersistent(data) {
|
|
/** @type {Record<string, unknown>} */
|
|
const patch = {};
|
|
for (const k of SYNC_KEYS) {
|
|
if (data[k] !== undefined) patch[k] = data[k];
|
|
}
|
|
await chrome.storage.local.set(patch);
|
|
}
|
|
|
|
/**
|
|
* @returns {Promise<{token?:string, tokenExpiresAt?:string, jti?:string}>}
|
|
*/
|
|
export async function loadSession() {
|
|
// Prefer session storage (cleared on browser restart) when available.
|
|
if (chrome.storage.session) {
|
|
return chrome.storage.session.get(SESSION_KEYS);
|
|
}
|
|
return chrome.storage.local.get(SESSION_KEYS);
|
|
}
|
|
|
|
/**
|
|
* @param {object} data
|
|
*/
|
|
export async function saveSession(data) {
|
|
/** @type {Record<string, unknown>} */
|
|
const patch = {};
|
|
for (const k of SESSION_KEYS) {
|
|
if (data[k] !== undefined) patch[k] = data[k];
|
|
}
|
|
if (chrome.storage.session) {
|
|
await chrome.storage.session.set(patch);
|
|
} else {
|
|
await chrome.storage.local.set(patch);
|
|
}
|
|
}
|
|
|
|
export async function clearSession() {
|
|
if (chrome.storage.session) {
|
|
await chrome.storage.session.remove(SESSION_KEYS);
|
|
}
|
|
await chrome.storage.local.remove(SESSION_KEYS);
|
|
}
|
|
|
|
/**
|
|
* Full runtime config for API calls.
|
|
* @returns {Promise<{baseUrl:string, rootUri:string, token?:string, userId?:string, email?:string, connected:boolean}>}
|
|
*/
|
|
export async function loadConfig() {
|
|
const [p, s] = await Promise.all([loadPersistent(), loadSession()]);
|
|
const baseUrl = (p.baseUrl || "").trim();
|
|
const rootUri = (p.rootUri || "").trim();
|
|
const token = (s.token || "").trim();
|
|
const connected = Boolean(baseUrl && rootUri && token);
|
|
return {
|
|
baseUrl,
|
|
rootUri,
|
|
token: token || undefined,
|
|
userId: p.userId,
|
|
email: p.email,
|
|
connected,
|
|
tokenExpiresAt: s.tokenExpiresAt,
|
|
jti: s.jti,
|
|
lastFolderPath: p.lastFolderPath || "",
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 规范化逻辑分组路径(与 vault store normalize_folder_path 对齐)。
|
|
* @param {string} [raw]
|
|
* @returns {string}
|
|
*/
|
|
export function normalizeFolderPath(raw) {
|
|
const s = String(raw || "").trim();
|
|
if (!s) return "";
|
|
const parts = s
|
|
.split(/[/\\]+/)
|
|
.map((p) => p.trim())
|
|
.filter((p) => p && p !== "." && p !== "..");
|
|
return parts.join("/");
|
|
}
|
|
|
|
/**
|
|
* 记住用户上次选用的分组。
|
|
* @param {string} folderPath
|
|
*/
|
|
export async function saveLastFolderPath(folderPath) {
|
|
const fp = normalizeFolderPath(folderPath);
|
|
await savePersistent({ lastFolderPath: fp });
|
|
}
|
|
|
|
/**
|
|
* @param {boolean} connected
|
|
* @param {string} [label]
|
|
*/
|
|
export async function setBadge(connected, label) {
|
|
try {
|
|
if (connected) {
|
|
await chrome.action.setBadgeText({ text: "ON" });
|
|
await chrome.action.setBadgeBackgroundColor({ color: "#0a7" });
|
|
if (label) {
|
|
await chrome.action.setTitle({ title: `MNote Vault · ${label}` });
|
|
}
|
|
} else {
|
|
await chrome.action.setBadgeText({ text: "—" });
|
|
await chrome.action.setBadgeBackgroundColor({ color: "#888" });
|
|
await chrome.action.setTitle({ title: "MNote Vault · 未连接" });
|
|
}
|
|
} catch {
|
|
// ignore in non-extension env
|
|
}
|
|
}
|