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.
This commit is contained in:
@@ -0,0 +1,416 @@
|
||||
/**
|
||||
* mnote-web vault API client (background only).
|
||||
* Token / secrets never enter content scripts.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} VaultConfig
|
||||
* @property {string} baseUrl
|
||||
* @property {string} rootUri
|
||||
* @property {string} [token]
|
||||
* @property {string} [userId]
|
||||
* @property {string} [email]
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {string} baseUrl
|
||||
* @returns {string}
|
||||
*/
|
||||
export function normalizeBaseUrl(baseUrl) {
|
||||
const raw = String(baseUrl || "").trim().replace(/\/+$/, "");
|
||||
if (!raw) throw new Error("baseUrl 不能为空");
|
||||
let u;
|
||||
try {
|
||||
u = new URL(raw);
|
||||
} catch {
|
||||
throw new Error("baseUrl 不是合法 URL");
|
||||
}
|
||||
if (u.protocol !== "http:" && u.protocol !== "https:") {
|
||||
throw new Error("baseUrl 仅支持 http/https");
|
||||
}
|
||||
return u.origin + (u.pathname === "/" ? "" : u.pathname.replace(/\/+$/, ""));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} baseUrl
|
||||
* @param {string} path
|
||||
* @param {object} [opts]
|
||||
* @param {string} [opts.token]
|
||||
* @param {string} [opts.method]
|
||||
* @param {object} [opts.body]
|
||||
* @param {boolean} [opts.credentials]
|
||||
*/
|
||||
export async function vaultFetch(baseUrl, path, opts = {}) {
|
||||
const base = normalizeBaseUrl(baseUrl);
|
||||
const url = `${base}${path.startsWith("/") ? path : `/${path}`}`;
|
||||
/** @type {Record<string, string>} */
|
||||
const headers = {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
if (opts.token) {
|
||||
headers.Authorization = `Bearer ${opts.token}`;
|
||||
}
|
||||
const init = {
|
||||
method: opts.method || (opts.body ? "POST" : "GET"),
|
||||
headers,
|
||||
credentials: opts.credentials ? "include" : "omit",
|
||||
};
|
||||
if (opts.body !== undefined) {
|
||||
init.body = JSON.stringify(opts.body);
|
||||
}
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(url, init);
|
||||
} catch (e) {
|
||||
const err = new Error(
|
||||
`Failed to fetch ${url}: ${e?.message || e}(若是扩展请求,先确认已授权该 origin 的 host 权限)`
|
||||
);
|
||||
err.cause = e;
|
||||
err.code = "network_error";
|
||||
throw err;
|
||||
}
|
||||
let data = null;
|
||||
const text = await res.text();
|
||||
try {
|
||||
data = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
data = { raw: text };
|
||||
}
|
||||
if (!res.ok) {
|
||||
const code = data?.code || data?.error || `http_${res.status}`;
|
||||
const msg =
|
||||
data?.message || data?.error || text || `${res.status} ${res.statusText}`;
|
||||
const err = new Error(String(msg));
|
||||
err.code = code;
|
||||
err.status = res.status;
|
||||
err.body = data;
|
||||
throw err;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auth sign-in (sets session cookies when credentials:include).
|
||||
* @param {string} baseUrl
|
||||
* @param {{ email: string, password: string }} creds
|
||||
*/
|
||||
export async function signIn(baseUrl, creds) {
|
||||
const email = String(creds.email || "").trim();
|
||||
const password = String(creds.password || "");
|
||||
const name = email.includes("@") ? email.split("@")[0] : email;
|
||||
// 与 mnote-web /auth 页、scripts/mnote-vault-cli.js 对齐
|
||||
return vaultFetch(baseUrl, "/api/auth", {
|
||||
method: "POST",
|
||||
credentials: true,
|
||||
body: {
|
||||
action: "auth:signIn",
|
||||
args: {
|
||||
provider: "password",
|
||||
params: {
|
||||
password,
|
||||
flow: "signIn",
|
||||
account: email,
|
||||
email,
|
||||
name,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Issue E2 extension token (requires session cookie from signIn).
|
||||
* @param {string} baseUrl
|
||||
* @param {object} [opts]
|
||||
*/
|
||||
export async function issueExtensionToken(baseUrl, opts = {}) {
|
||||
return vaultFetch(baseUrl, "/api/vault/extension/token", {
|
||||
method: "POST",
|
||||
credentials: true,
|
||||
body: {
|
||||
clientId: "chrome-extension",
|
||||
extensionId: opts.extensionId || null,
|
||||
ttlHours: opts.ttlHours ?? 168,
|
||||
email: opts.email || null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前登录账号的默认 local_folder 工作区(每用户固定 my-space)。
|
||||
* POST /api/local-folder/workspaces/default — 不存在则创建。
|
||||
* @param {string} baseUrl
|
||||
* @param {{ token?: string, credentials?: boolean }} [opts]
|
||||
* @returns {Promise<{ rootUri: string, rootPath?: string }>}
|
||||
*/
|
||||
export async function ensureDefaultWorkspace(baseUrl, opts = {}) {
|
||||
const data = await vaultFetch(
|
||||
baseUrl,
|
||||
"/api/local-folder/workspaces/default",
|
||||
{
|
||||
method: "POST",
|
||||
credentials: opts.credentials !== false && !opts.token,
|
||||
token: opts.token,
|
||||
body: {},
|
||||
}
|
||||
);
|
||||
const ws = data?.workspace || data?.result?.workspace || data?.result || data;
|
||||
const rootUri = String(ws?.rootUri || "").trim();
|
||||
if (!rootUri.startsWith("file://")) {
|
||||
throw new Error(
|
||||
"无法解析默认工作区 rootUri(请确认账号已登录且 mnote-web 可用)"
|
||||
);
|
||||
}
|
||||
return {
|
||||
rootUri,
|
||||
rootPath: ws?.rootPath ? String(ws.rootPath) : undefined,
|
||||
workspaceId: ws?.manifest?.workspaceId || ws?.workspaceId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {VaultConfig} cfg
|
||||
*/
|
||||
export async function whoami(cfg) {
|
||||
return vaultFetch(cfg.baseUrl, "/api/auth/whoami", {
|
||||
method: "GET",
|
||||
token: cfg.token,
|
||||
credentials: !cfg.token,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {VaultConfig} cfg
|
||||
*/
|
||||
export async function listVault(cfg) {
|
||||
const q = new URLSearchParams({
|
||||
rootUri: cfg.rootUri,
|
||||
sourceKind: "local_folder",
|
||||
});
|
||||
return vaultFetch(cfg.baseUrl, `/api/vault/list?${q}`, {
|
||||
method: "GET",
|
||||
token: cfg.token,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {VaultConfig} cfg
|
||||
* @param {object} item
|
||||
*/
|
||||
export async function createItem(cfg, item) {
|
||||
return vaultFetch(cfg.baseUrl, "/api/vault/items", {
|
||||
method: "POST",
|
||||
token: cfg.token,
|
||||
body: {
|
||||
rootUri: cfg.rootUri,
|
||||
sourceKind: "local_folder",
|
||||
...item,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {VaultConfig} cfg
|
||||
* @param {string} id
|
||||
* @param {object} patch
|
||||
*/
|
||||
export async function updateItem(cfg, id, patch) {
|
||||
return vaultFetch(cfg.baseUrl, `/api/vault/items/${encodeURIComponent(id)}`, {
|
||||
method: "PATCH",
|
||||
token: cfg.token,
|
||||
body: {
|
||||
rootUri: cfg.rootUri,
|
||||
sourceKind: "local_folder",
|
||||
...patch,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {VaultConfig} cfg
|
||||
* @param {string} id
|
||||
*/
|
||||
export async function getItem(cfg, id) {
|
||||
const q = new URLSearchParams({
|
||||
rootUri: cfg.rootUri,
|
||||
sourceKind: "local_folder",
|
||||
});
|
||||
return vaultFetch(
|
||||
cfg.baseUrl,
|
||||
`/api/vault/items/${encodeURIComponent(id)}?${q}`,
|
||||
{
|
||||
method: "GET",
|
||||
token: cfg.token,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reveal a secret field (password etc.) for dedupe / autofill decisions.
|
||||
* @param {VaultConfig} cfg
|
||||
* @param {string} id
|
||||
* @param {{ field?: string, accountId?: string, secretId?: string }} [opts]
|
||||
*/
|
||||
export async function revealField(cfg, id, opts = {}) {
|
||||
return vaultFetch(
|
||||
cfg.baseUrl,
|
||||
`/api/vault/items/${encodeURIComponent(id)}/reveal`,
|
||||
{
|
||||
method: "POST",
|
||||
token: cfg.token,
|
||||
body: {
|
||||
rootUri: cfg.rootUri,
|
||||
sourceKind: "local_folder",
|
||||
field: opts.field || "password",
|
||||
accountId: opts.accountId,
|
||||
secretId: opts.secretId,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT session file for credential/account.
|
||||
* @param {VaultConfig} cfg
|
||||
* @param {string} id
|
||||
* @param {object} session
|
||||
*/
|
||||
export async function putSession(cfg, id, session) {
|
||||
return vaultFetch(
|
||||
cfg.baseUrl,
|
||||
`/api/vault/items/${encodeURIComponent(id)}/session`,
|
||||
{
|
||||
method: "PUT",
|
||||
token: cfg.token,
|
||||
body: {
|
||||
rootUri: cfg.rootUri,
|
||||
sourceKind: "local_folder",
|
||||
source: "chrome_extension",
|
||||
...session,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {VaultConfig} cfg
|
||||
* @param {string} id
|
||||
* @param {object} [opts]
|
||||
*/
|
||||
export async function shareToAi(cfg, id, opts = {}) {
|
||||
return vaultFetch(
|
||||
cfg.baseUrl,
|
||||
`/api/vault/items/${encodeURIComponent(id)}/share-to-ai`,
|
||||
{
|
||||
method: "POST",
|
||||
token: cfg.token,
|
||||
body: {
|
||||
rootUri: cfg.rootUri,
|
||||
sourceKind: "local_folder",
|
||||
...opts,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 list 条目收集唯一 folderPath(含父路径前缀)。
|
||||
* @param {Array} items
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function collectFolderPaths(items) {
|
||||
/** @type {Record<string, true>} */
|
||||
const set = {};
|
||||
const list = Array.isArray(items) ? items : [];
|
||||
for (const it of list) {
|
||||
const fp = String(it?.folderPath || "").trim();
|
||||
if (!fp) continue;
|
||||
set[fp] = true;
|
||||
const parts = fp.split("/").filter(Boolean);
|
||||
for (let i = 1; i < parts.length; i++) {
|
||||
set[parts.slice(0, i).join("/")] = true;
|
||||
}
|
||||
}
|
||||
return Object.keys(set).sort((a, b) => a.localeCompare(b, "zh"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Match list items by origin (client-side filter).
|
||||
* @param {Array} items
|
||||
* @param {string} pageUrl
|
||||
*/
|
||||
export function matchItemsByUrl(items, pageUrl) {
|
||||
let origin = "";
|
||||
let hostname = "";
|
||||
try {
|
||||
const u = new URL(pageUrl);
|
||||
origin = u.origin;
|
||||
hostname = u.hostname || "";
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const list = Array.isArray(items) ? items : [];
|
||||
const hostOf = (raw) => {
|
||||
try {
|
||||
return new URL(String(raw)).hostname || "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
const sameSite = (a, b) => {
|
||||
if (!a || !b) return false;
|
||||
if (a === b) return true;
|
||||
return a.endsWith(`.${b}`) || b.endsWith(`.${a}`);
|
||||
};
|
||||
return list
|
||||
.filter((it) => {
|
||||
if (it && it.status && String(it.status).toLowerCase() === "deleted") {
|
||||
return false;
|
||||
}
|
||||
const urls = [];
|
||||
if (it.url) urls.push(String(it.url));
|
||||
if (Array.isArray(it.urls)) urls.push(...it.urls.map(String));
|
||||
return urls.some((u) => {
|
||||
try {
|
||||
const ou = new URL(u);
|
||||
if (ou.origin === origin) return true;
|
||||
return sameSite(ou.hostname, hostname);
|
||||
} catch {
|
||||
const bare = origin.replace(/^https?:\/\//, "");
|
||||
return String(u).includes(bare) || sameSite(hostOf(u), hostname);
|
||||
}
|
||||
});
|
||||
})
|
||||
.sort((a, b) =>
|
||||
String(b.updatedAt || "").localeCompare(String(a.updatedAt || ""))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a list item that matches page origin + username (best effort).
|
||||
* @param {Array} items
|
||||
* @param {string} pageUrl
|
||||
* @param {string} [username]
|
||||
*/
|
||||
export function findMatchingCredential(items, pageUrl, username) {
|
||||
const matched = matchItemsByUrl(items, pageUrl);
|
||||
if (!matched.length) return null;
|
||||
const want = String(username || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!want) return matched[0];
|
||||
const byUser = matched.find((it) => {
|
||||
const candidates = [
|
||||
it.username,
|
||||
it.email,
|
||||
...(Array.isArray(it.accounts)
|
||||
? it.accounts.flatMap((a) => [a?.username, a?.email])
|
||||
: []),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.map((s) => String(s).trim().toLowerCase());
|
||||
return candidates.includes(want);
|
||||
});
|
||||
return byUser || matched[0];
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user