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:
Agent Board
2026-07-25 14:25:37 +08:00
parent bc6f8488ee
commit 262e66b02e
137 changed files with 9018 additions and 46049 deletions
+96
View File
@@ -0,0 +1,96 @@
# MNote Vault · Chrome 扩展(MV3
将外站登录/注册账号与浏览器 Cookie 保存到 MNote 密码箱。
设计稿:`design/12-vault/process/12-3-chrome-extension-vault-save-v1.md`
## 安装(unpacked
1. 启动 mnote-web(默认 `http://127.0.0.1:3000`)。
2. Chrome → `chrome://extensions` → 开启「开发者模式」→「加载已解压的扩展程序」。
3. 选择本目录:`extensions/mnote-vault`
4. 打开扩展 **Options**
- baseUrl:本机 `http://127.0.0.1:3000`,或外网 frp 完整地址(如 `https://www.xxx.nyat.app:44938`,含端口)
- MNote 账号/密码 → **连接并登录**
- **首次外网连接会弹「允许访问该网站」**,必须点允许;否则 Chrome 报 `Failed to fetch`(不是隧道挂了)
- **rootUri 一般不用填**:登录后自动取该账号默认工作区
`POST /api/local-folder/workspaces/default``…/users/<actor>/workspaces/my-space`)。
仅当要把密码箱写到别的 local_folder 时,在「高级」里覆盖。
5. 在登录页 **提交** 表单;跳转成功后页面右下角弹出确认层(可显示/隐藏密码核对)。
也可点工具栏「保存当前页账号」(优先使用 pending draft)。
6. **登录态(Cookie)依赖网站访问权限**v0.2.3 起连接 Options / 点保存时会申请 `http://*/*` + `https://*/*`
Chrome 提示「读取和更改所有网站的数据」时必须点允许,否则只能保存账号密码、无法写入 `sessions/`
若仍提示「未捕获到 Cookie」:`chrome://extensions` → MNote Vault → 网站访问权限 → **所有网站**
7. **去重(v0.2.3**:同一站点 + 同一用户名 + 同一密码已保存后,再次登录**不再弹确认层**(仅必要时静默补写 Cookie)。
点「取消」会暂时不再询问;勾选「此网站不再询问」后该 origin 永久跳过自动提示(仍可用工具栏手动保存)。
## 保存流程(P0P1
对齐 KeePassXC / Bitwarden 思路,**不使用 `webRequest`**
```text
content: form submit / 提交按钮
→ PENDING_SAVEbackground 内存 + chrome.storage.session
→ SPA 约 1.5s 仍在同 URL:直接弹确认层
→ 整页跳转:tabs.onUpdated status=complete
同站 hostname 匹配 → OPEN_SAVE_UI_WITH_DRAFT
用户确认
→ SAVE_CREDENTIAL(此时再 cookies.getAll
→ P1:约 2.5s 后二次 PUT sessionsetTimeout + alarms 备份)
```
| 项 | 说明 |
|----|------|
| 主触发 | **submit / 提交按钮**,不是 password blur |
| 凭证跨导航 | background `pendingByTab`TTL 约 3 分钟 |
| 弹窗时机 | **登录后页面**(或 SPA 同页 fallback |
| Cookie | 确认保存时抓取;空则提示 + 自动二次捕获 |
| 密码核对 | 确认层密码框可「显示/隐藏」 |
| 鉴权 | `POST /api/auth``POST /api/vault/extension/token` → Bearer `mnext1.*` |
| 写账号 | `POST /api/vault/items``PATCH` 追加账号 |
| 写登录态 | `PUT /api/vault/items/{id}/session``sessions/{id}/{accountId}.json` |
### 消息类型(background
| type | 作用 |
|------|------|
| `PENDING_SAVE` | content 提交时缓存 draft |
| `GET_PENDING` / `CLEAR_PENDING` / `MARK_PENDING_PROMPTED` | 查询 / 取消 / 防重复弹窗 |
| `PROMPT_PENDING` | 工具栏打开确认层(优先 pending) |
| `OPEN_SAVE_UI` / `OPEN_SAVE_UI_WITH_DRAFT` | background → content 展示 UI |
| `SAVE_CREDENTIAL` | 真正写 vault + session |
| `LIST_FOLDERS` | 拉取已有 `folderPath` 列表 + 上次选用分组(确认层下拉) |
| `UPDATE_SESSION` | 工具栏手动更新 Cookie |
### 分组(folderPath
确认层提供:
- 下拉:已有分组(来自 vault list 的 `folderPath`,含父路径前缀)+「(无分组)」+「+ 新建分组…」
- 新建:自由填写名称,支持 `/` 分层(如 `工作/客户`
- 默认选中:上次保存时选用的分组(`chrome.storage.local.lastFolderPath`
- **不再**硬编码 `imported/browser`
## 自测建议
1. 普通表单登录(整页跳转)→ 登录后应出现确认层,密码可显示核对。
2. SPA 登录(URL 不变)→ 约 1.5s 后同页弹层。
3. 工具栏「保存当前页」:若刚提交过,应预填 pending 密码。
4. 取消确认层 → pending 清除,不再重复弹。
5. 保存后 cookie 为空时:提示二次捕获;稍后可用「更新登录态」。
```bash
# 后端 token + session 单测
cd rust && cargo test -p mnote-web --lib routes::vault_extension_token::tests
cd rust && cargo test -p mnote-web --lib routes::vault_store::tests::session_file_put_resolve_and_share_copy
# API smoke(需 3000 已起 + 测试账号)
node scripts/vault-extension-api-smoke.js
```
## 权限
- `storage` / `cookies` / `activeTab` / `scripting` / `tabs` / `alarms`
- **无** `webRequest` / `webNavigation`P0P1 用 `tabs.onUpdated` 足够)
- `optional_host_permissions`Options 点击时申请 baseUrl;保存 Cookie 时申请目标站 origin
- 外网带非标端口时 origin 含端口,例如 `https://www.xxx.nyat.app:44938/*`
File diff suppressed because it is too large Load Diff
+655
View File
@@ -0,0 +1,655 @@
/**
* Content script: detect password form submit, confirm UI, message background.
* Does NOT hold tokens or cookie secrets.
*
* KeePassXC-style flow:
* - submit / submit-button click → PENDING_SAVE (background)
* - after navigation, background opens OPEN_SAVE_UI_WITH_DRAFT
* - SPA same-page: background spa fallback also opens draft UI
* - blur is NOT a primary save trigger
*/
(() => {
if (window.__mnoteVaultContentInstalled) return;
window.__mnoteVaultContentInstalled = true;
const HOST_ID = "mnote-vault-save-host";
/** Last known password on this document only (lost on full navigation). */
let lastPassword = "";
let lastUsername = "";
let lastEmail = "";
/** Prevent double PENDING_SAVE from submit + button click. */
let submitLockUntil = 0;
const SUBMIT_LOCK_MS = 1200;
function findUsernameNear(passwordInput) {
const form =
passwordInput?.form || passwordInput?.closest?.("form") || document;
const candidates = form.querySelectorAll(
'input[type="email"], input[type="text"], input[name*="user" i], input[name*="login" i], input[name*="email" i], input[autocomplete="username"], input[autocomplete="email"]'
);
for (const el of candidates) {
if (el === passwordInput) continue;
if (el.type === "password" || el.type === "hidden") continue;
const v = (el.value || "").trim();
if (v) return { username: v, email: el.type === "email" ? v : "" };
}
// Fallback: any non-empty text-like input in form
const more = form.querySelectorAll(
"input:not([type]), input[type=text], input[type=email], input[type=tel]"
);
for (const el of more) {
if (el.type === "password" || el.type === "hidden") continue;
const v = (el.value || "").trim();
if (v) return { username: v, email: el.type === "email" ? v : "" };
}
return { username: "", email: "" };
}
function findPasswordIn(root) {
const scope = root || document;
const list = scope.querySelectorAll?.('input[type="password"]') || [];
for (const el of list) {
if (el.value && el.value.length >= 1) return el;
}
return list[0] || null;
}
function isRegisterPath() {
const p = location.pathname.toLowerCase();
return /sign[-_]?up|register|signup|join|create[-_]?account/.test(p);
}
function buildTitle() {
let title = (document.title || "").slice(0, 80);
if (isRegisterPath() && title && !/注册|register|sign.?up/i.test(title)) {
title = `注册 · ${title}`;
}
return title || location.hostname;
}
function collectDraftFromDom(preferPasswordInput) {
const pwEl =
preferPasswordInput ||
(document.activeElement?.type === "password"
? document.activeElement
: null) ||
findPasswordIn(document);
const pw = (pwEl && pwEl.value) || lastPassword || "";
const near = pwEl
? findUsernameNear(pwEl)
: { username: lastUsername, email: lastEmail };
const username = (near.username || lastUsername || "").trim();
const email = (near.email || lastEmail || "").trim();
if (pw) lastPassword = pw;
if (username) lastUsername = username;
if (email) lastEmail = email;
return {
pageUrl: location.href,
title: buildTitle(),
username,
email,
password: pw,
};
}
function removeHost() {
const el = document.getElementById(HOST_ID);
if (el) el.remove();
}
function sendPending(draft) {
if (!draft?.password) return;
const now = Date.now();
if (now < submitLockUntil) return;
submitLockUntil = now + SUBMIT_LOCK_MS;
lastPassword = draft.password;
if (draft.username) lastUsername = draft.username;
if (draft.email) lastEmail = draft.email;
chrome.runtime.sendMessage(
{
type: "PENDING_SAVE",
pageUrl: draft.pageUrl || location.href,
title: draft.title || buildTitle(),
username: draft.username || "",
password: draft.password,
email: draft.email || "",
fromSubmit: true,
},
() => {
// ignore response; SW may be waking
void chrome.runtime.lastError;
}
);
}
/**
* @param {object} draft
* @param {{ fromPending?: boolean }} [opts]
*/
async function showConfirm(draft, opts = {}) {
removeHost();
const host = document.createElement("div");
host.id = HOST_ID;
host.style.cssText =
"all:initial;position:fixed;z-index:2147483646;right:16px;bottom:16px;font-family:system-ui,sans-serif;";
const shadow = host.attachShadow({ mode: "closed" });
const style = document.createElement("style");
style.textContent = `
.card {
width: 320px; max-width: 92vw;
background: #1a1d23; color: #e8eaed;
border: 1px solid #3a3f4b; border-radius: 12px;
box-shadow: 0 12px 40px rgba(0,0,0,.45);
padding: 14px 14px 12px; font-size: 13px; line-height: 1.4;
}
h3 { margin: 0 0 10px; font-size: 14px; font-weight: 600; }
label { display:block; margin: 8px 0 2px; color:#9aa0a6; font-size:11px; }
input[type=text], input[type=password] {
width: 100%; box-sizing: border-box;
background:#0f1115; color:#e8eaed; border:1px solid #3a3f4b;
border-radius:6px; padding:6px 8px; font-size:13px;
}
.pw-wrap {
display:flex; align-items:stretch; gap:0;
border:1px solid #3a3f4b; border-radius:6px; overflow:hidden;
background:#0f1115;
}
.pw-wrap input {
flex:1; min-width:0; border:0; border-radius:0; padding:6px 8px;
}
.pw-wrap input:focus { outline:none; }
.btn-pw-toggle {
flex:0 0 auto; min-width:52px; margin:0; border:0; border-left:1px solid #3a3f4b;
border-radius:0; background:#2a2f3a; color:#e8eaed; padding:0 10px;
font-size:11px; cursor:pointer; line-height:1;
}
.btn-pw-toggle:hover { background:#343b49; }
.btn-pw-toggle:focus-visible { outline:2px solid #3b82f6; outline-offset:-2px; }
.row { display:flex; gap:8px; align-items:center; margin-top:8px; font-size:12px; }
.row input { width:auto; }
.actions { display:flex; gap:8px; justify-content:flex-end; margin-top:12px; }
button {
border:0; border-radius:8px; padding:7px 12px; cursor:pointer; font-size:12px;
}
.btn-cancel { background:#2a2f3a; color:#e8eaed; }
.btn-save { background:#3b82f6; color:#fff; font-weight:600; }
.btn-save:disabled { opacity:.5; cursor:wait; }
.msg { margin-top:8px; font-size:11px; color:#9aa0a6; min-height:14px; }
.msg.err { color:#f87171; }
.msg.ok { color:#4ade80; }
select {
width:100%; box-sizing:border-box; background:#0f1115; color:#e8eaed;
border:1px solid #3a3f4b; border-radius:6px; padding:6px 8px;
}
.muted { color:#9aa0a6; font-size:11px; margin-top:4px; }
.banner {
margin: 0 0 8px; padding: 6px 8px; border-radius: 6px;
background: #243044; color: #93c5fd; font-size: 11px;
}
.folder-new {
display:none; margin-top:6px;
}
.folder-new.is-open { display:block; }
.folder-hint { margin-top:4px; }
`;
const fromPending = Boolean(draft.fromPending || opts.fromPending);
const card = document.createElement("div");
card.className = "card";
card.innerHTML = `
<h3>保存到 MNote 密码箱</h3>
${
fromPending
? `<div class="banner">已捕获登录提交${draft.pendingReason === "nav_complete" ? "(登录后页面)" : ""} · 请确认后保存</div>`
: ""
}
<label>标题</label>
<input type="text" id="title" />
<label>URL</label>
<input type="text" id="url" />
<label>用户名</label>
<input type="text" id="username" autocomplete="off" />
<label>密码</label>
<div class="pw-wrap">
<input type="password" id="password" autocomplete="off" />
<button type="button" class="btn-pw-toggle" id="togglePw" aria-label="显示密码" title="显示/隐藏密码">显示</button>
</div>
<label>分组</label>
<select id="folder">
<option value="">(无分组)</option>
<option value="__new__"> 新建分组…</option>
</select>
<div class="folder-new" id="folderNewWrap">
<label>新分组名(可用 / 分层,如 工作/客户)</label>
<input type="text" id="folderNew" placeholder="例如:个人 / 工作 / 客户A" autocomplete="off" />
</div>
<div class="muted folder-hint" id="folderHint">读取已有分组…</div>
<label>匹配已有条目</label>
<select id="match">
<option value="create">新建条目</option>
</select>
<div class="row">
<input type="checkbox" id="saveSession" checked />
<label for="saveSession" style="margin:0;color:#e8eaed">同时保存登录态(Cookie</label>
</div>
<div class="row">
<input type="checkbox" id="shareAi" />
<label for="shareAi" style="margin:0;color:#e8eaed">同步到 AI 密码本</label>
</div>
<div class="row">
<input type="checkbox" id="neverAsk" />
<label for="neverAsk" style="margin:0;color:#e8eaed">此网站不再询问</label>
</div>
<div class="muted" id="cookieHint"></div>
<div class="msg" id="msg"></div>
<div class="actions">
<button type="button" class="btn-cancel" id="cancel">取消</button>
<button type="button" class="btn-save" id="save">保存</button>
</div>
`;
shadow.appendChild(style);
shadow.appendChild(card);
document.documentElement.appendChild(host);
const $ = (id) => shadow.getElementById(id);
$("title").value = draft.title || buildTitle();
$("url").value = draft.pageUrl || location.href;
$("username").value = draft.username || lastUsername || "";
$("password").value = draft.password || lastPassword || "";
function syncFolderNewVisibility() {
const isNew = $("folder").value === "__new__";
$("folderNewWrap").classList.toggle("is-open", isNew);
if (isNew) {
try {
$("folderNew").focus();
} catch {
/* ignore */
}
}
}
function resolveFolderPath() {
const sel = $("folder").value;
if (sel === "__new__") {
return String($("folderNew").value || "").trim();
}
return String(sel || "").trim();
}
$("folder").onchange = syncFolderNewVisibility;
$("togglePw").onclick = () => {
const input = $("password");
const btn = $("togglePw");
const show = input.type === "password";
input.type = show ? "text" : "password";
btn.textContent = show ? "隐藏" : "显示";
btn.setAttribute("aria-label", show ? "隐藏密码" : "显示密码");
};
// status + match
chrome.runtime.sendMessage({ type: "GET_STATUS" }, (st) => {
void chrome.runtime.lastError;
const r = st?.result || st;
if (!st?.ok || !r?.connected) {
$("msg").className = "msg err";
$("msg").textContent = "未连接:请打开扩展 Options 登录 MNote";
$("save").disabled = true;
}
});
const matchUrl = $("url").value.trim() || location.href;
chrome.runtime.sendMessage(
{ type: "MATCH_URL", pageUrl: matchUrl },
(res) => {
void chrome.runtime.lastError;
const items = res?.result?.items || [];
const sel = $("match");
for (const it of items) {
const opt = document.createElement("option");
opt.value = it.id;
opt.textContent = `追加到 · ${it.title || it.id}`;
sel.appendChild(opt);
}
}
);
// 已有分组 + 上次选用
chrome.runtime.sendMessage({ type: "LIST_FOLDERS" }, (res) => {
void chrome.runtime.lastError;
const r = res?.result || res || {};
const folders = Array.isArray(r.folders) ? r.folders : [];
const last = String(r.lastFolderPath || "").trim();
const sel = $("folder");
// 保留「无分组」「新建」两端选项,中间插入已有路径
const newOpt = sel.querySelector('option[value="__new__"]');
for (const fp of folders) {
const opt = document.createElement("option");
opt.value = fp;
opt.textContent = fp;
sel.insertBefore(opt, newOpt);
}
if (last) {
// 上次路径若不在列表中,也加一条再选中
let found = false;
for (const o of sel.options) {
if (o.value === last) {
found = true;
break;
}
}
if (!found) {
const opt = document.createElement("option");
opt.value = last;
opt.textContent = last;
sel.insertBefore(opt, newOpt);
}
sel.value = last;
} else {
sel.value = "";
}
syncFolderNewVisibility();
$("folderHint").textContent = folders.length
? `${folders.length} 个已有分组;可选已有或新建`
: "暂无已有分组,可选择「新建分组」并填写名称";
});
// Request broad host access so cookies.getAll can see session cookies.
// Best-effort here; the critical grant happens on Save click (user gesture).
chrome.runtime.sendMessage(
{ type: "ENSURE_PAGE_HOST", pageUrl: matchUrl, broad: true },
() => {
void chrome.runtime.lastError;
chrome.runtime.sendMessage(
{ type: "CAPTURE_COOKIE_COUNT", pageUrl: matchUrl },
(res) => {
void chrome.runtime.lastError;
const n = res?.result?.count ?? 0;
const err = res?.result?.error;
if (n > 0) {
$("cookieHint").textContent = `将捕获约 ${n} 条 Cookie`;
} else if (err) {
$("cookieHint").textContent =
`无法读取 Cookie${err}。点「保存」时会再次申请网站权限;也可在扩展详情设为「所有网站」。`;
} else {
$("cookieHint").textContent =
"当前可能尚无会话 Cookie。点「保存」时会申请 Cookie 权限并捕获;若仍为 0,请检查扩展网站访问权限。";
}
}
);
}
);
$("cancel").onclick = () => {
const forever = Boolean($("neverAsk")?.checked);
chrome.runtime.sendMessage(
{
type: "DISMISS_SAVE",
pageUrl: $("url").value.trim() || location.href,
username: $("username").value.trim(),
forever,
},
() => {
void chrome.runtime.lastError;
removeHost();
}
);
};
$("save").onclick = async () => {
$("save").disabled = true;
$("msg").className = "msg";
$("msg").textContent = "保存中…";
const matchVal = $("match").value;
const folderPath = resolveFolderPath();
if ($("folder").value === "__new__" && !folderPath) {
$("msg").className = "msg err";
$("msg").textContent = "请填写新分组名称";
$("save").disabled = false;
return;
}
// Critical: request cookie host permission in this click gesture.
// Without it, chrome.cookies.getAll returns [] and login session cannot be saved.
let hostPermissionGranted = false;
try {
hostPermissionGranted = await new Promise((resolve) => {
chrome.runtime.sendMessage(
{
type: "ENSURE_PAGE_HOST",
pageUrl: $("url").value.trim() || location.href,
broad: true,
},
(res) => {
void chrome.runtime.lastError;
resolve(Boolean(res?.ok || res?.result?.ok || res?.result?.hasHost));
}
);
});
} catch {
hostPermissionGranted = false;
}
const payload = {
pageUrl: $("url").value.trim() || location.href,
title: $("title").value.trim(),
username: $("username").value.trim(),
password: $("password").value,
email: draft.email || lastEmail || "",
folderPath,
saveSession: $("saveSession").checked,
shareToAi: $("shareAi").checked,
mode: matchVal === "create" ? "create" : "append",
existingId: matchVal === "create" ? null : matchVal,
recaptureSession: true,
hostPermissionGranted,
};
chrome.runtime.sendMessage(
{ type: "SAVE_CREDENTIAL", payload },
(res) => {
void chrome.runtime.lastError;
if (!res?.ok) {
$("msg").className = "msg err";
$("msg").textContent = res?.error || "保存失败";
$("save").disabled = false;
return;
}
const r = res.result || {};
let text = `已保存 ${r.credentialId || ""}`;
if (folderPath && matchVal === "create") text += ` · 分组 ${folderPath}`;
if (r.sessionError) text += ` · 登录态: ${r.sessionError}`;
else if (payload.saveSession && r.sessionSaved) text += " · 含登录态";
else if (payload.saveSession) text += " · 登录态待二次捕获";
if (r.sessionRecaptureScheduled && !r.sessionSaved) {
text += " · 将二次捕获 Cookie";
}
$("msg").className = "msg ok";
$("msg").textContent = text;
setTimeout(removeHost, r.sessionError ? 3200 : 1800);
}
);
};
}
/**
* Manual toolbar open: prefer pending draft, else live form / last known.
*/
function openFromManual() {
chrome.runtime.sendMessage({ type: "GET_PENDING" }, (res) => {
void chrome.runtime.lastError;
const pending = res?.result?.pending;
if (pending?.password) {
showConfirm(
{
pageUrl: pending.pageUrl || location.href,
title: pending.title || buildTitle(),
username: pending.username || "",
email: pending.email || "",
password: pending.password,
fromPending: true,
pendingReason: "manual",
},
{ fromPending: true }
);
return;
}
const draft = collectDraftFromDom();
showConfirm(draft);
});
}
function onSubmit(e) {
const form = e.target;
if (!(form instanceof HTMLFormElement)) return;
const pw = findPasswordIn(form) || findPasswordIn(document);
if (!pw || !pw.value) return;
const draft = collectDraftFromDom(pw);
if (!draft.password) return;
// Do not block navigation; stash in background immediately.
sendPending(draft);
}
/**
* Capture click on submit-like controls (including formless login UIs).
*/
function onPointerDownCapture(e) {
const t = e.target;
if (!(t instanceof Element)) return;
const btn = t.closest(
'button[type="submit"], input[type="submit"], button:not([type]), [type="submit"], [role="button"]'
);
if (!btn) return;
// Avoid random buttons far from any password field.
const form = btn.closest("form");
const pw =
(form && findPasswordIn(form)) ||
findPasswordIn(btn.closest("div,section,main,body") || document);
if (!pw || !pw.value || pw.value.length < 1) return;
// Heuristic: prefer buttons that look like login/submit.
const label = (
btn.getAttribute("aria-label") ||
btn.value ||
btn.textContent ||
""
)
.trim()
.toLowerCase();
const looksSubmit =
btn.matches('button[type="submit"], input[type="submit"], [type="submit"]') ||
/log\s*in|sign\s*in|sign\s*up|register|提交|登录|注册|continue|next|进入/.test(
label
) ||
Boolean(form);
if (!looksSubmit) return;
const draft = collectDraftFromDom(pw);
if (draft.password) sendPending(draft);
}
// Remember password as user types (still only in this document).
function onPasswordInput(e) {
const t = e.target;
if (!(t instanceof HTMLInputElement) || t.type !== "password") return;
if (t.value) lastPassword = t.value;
const near = findUsernameNear(t);
if (near.username) lastUsername = near.username;
if (near.email) lastEmail = near.email;
}
document.addEventListener("submit", onSubmit, true);
document.addEventListener("pointerdown", onPointerDownCapture, true);
document.addEventListener("input", onPasswordInput, true);
chrome.runtime.onMessage.addListener((msg, _s, sendResponse) => {
if (msg?.type === "OPEN_SAVE_UI_WITH_DRAFT") {
const d = msg.draft || {};
showConfirm(
{
pageUrl: d.pageUrl || location.href,
title: d.title || buildTitle(),
username: d.username || lastUsername || "",
email: d.email || lastEmail || "",
password: d.password || lastPassword || "",
fromPending: true,
pendingReason: d.pendingReason || "pending",
},
{ fromPending: true }
);
sendResponse({ ok: true });
return false;
}
if (msg?.type === "OPEN_SAVE_UI") {
openFromManual();
sendResponse({ ok: true });
return false;
}
return false;
});
// After full navigation inject: if background still has pending for this tab
// and did not manage to message us yet, pull once (belt-and-suspenders).
function pullPendingOnLoad() {
// Let tabs.onUpdated / OPEN_SAVE_UI_WITH_DRAFT win the race first.
setTimeout(() => {
if (document.getElementById(HOST_ID)) return;
chrome.runtime.sendMessage({ type: "GET_PENDING" }, (res) => {
void chrome.runtime.lastError;
const p = res?.result?.pending;
if (!p?.password || p.prompted) return;
if (document.getElementById(HOST_ID)) return;
// Dedupe before content-side pull opens UI (same site already saved).
chrome.runtime.sendMessage(
{
type: "CHECK_DEDUPE",
pageUrl: p.pageUrl || location.href,
username: p.username || "",
password: p.password || "",
},
(dedupeRes) => {
void chrome.runtime.lastError;
const d = dedupeRes?.result || dedupeRes;
if (d?.suppress) {
chrome.runtime.sendMessage({ type: "CLEAR_PENDING" }, () => {
void chrome.runtime.lastError;
});
return;
}
if (document.getElementById(HOST_ID)) return;
showConfirm(
{
pageUrl: p.pageUrl || location.href,
title: p.title || buildTitle(),
username: p.username || "",
email: p.email || "",
password: p.password,
fromPending: true,
pendingReason: "content_pull",
},
{ fromPending: true }
);
chrome.runtime.sendMessage(
{
type: "MARK_PENDING_PROMPTED",
promptedUrl: location.href,
},
() => {
void chrome.runtime.lastError;
}
);
}
);
});
}, 700);
}
if (document.readyState === "complete") {
pullPendingOnLoad();
} else {
window.addEventListener("load", pullPendingOnLoad, { once: true });
}
})();
Binary file not shown.

After

Width:  |  Height:  |  Size: 343 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 B

+416
View File
@@ -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];
}
+291
View File
@@ -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
}
}
+49
View File
@@ -0,0 +1,49 @@
{
"manifest_version": 3,
"name": "MNote Vault",
"version": "0.2.3",
"description": "将外站登录/注册账号与浏览器登录态保存到 MNote 密码箱(提交缓存 → 登录后确认;每账号独立 session 文件)。",
"permissions": [
"storage",
"activeTab",
"scripting",
"alarms",
"cookies",
"tabs"
],
"optional_host_permissions": [
"http://*/*",
"https://*/*"
],
"host_permissions": [
"http://*/*",
"https://*/*"
],
"background": {
"service_worker": "background.js",
"type": "module"
},
"action": {
"default_popup": "popup.html",
"default_title": "MNote Vault",
"default_icon": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
},
"options_page": "options.html",
"icons": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
},
"content_scripts": [
{
"matches": ["http://*/*", "https://*/*"],
"js": ["content.js"],
"run_at": "document_idle",
"all_frames": false
}
]
}
+145
View File
@@ -0,0 +1,145 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<title>MNote Vault · 连接设置</title>
<style>
:root {
color-scheme: dark;
--bg: #0f1115;
--card: #1a1d23;
--border: #3a3f4b;
--text: #e8eaed;
--muted: #9aa0a6;
--accent: #3b82f6;
--ok: #4ade80;
--err: #f87171;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: system-ui, -apple-system, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.45;
padding: 24px;
}
.wrap { max-width: 520px; margin: 0 auto; }
h1 { font-size: 1.25rem; margin: 0 0 4px; }
.sub { color: var(--muted); font-size: 0.85rem; margin-bottom: 20px; }
.card {
background: var(--card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 16px;
margin-bottom: 16px;
}
label {
display: block;
font-size: 0.75rem;
color: var(--muted);
margin: 10px 0 4px;
}
input {
width: 100%;
padding: 8px 10px;
border-radius: 8px;
border: 1px solid var(--border);
background: #0f1115;
color: var(--text);
font-size: 0.9rem;
}
.actions { display: flex; gap: 8px; margin-top: 16px; flex-wrap: wrap; }
button {
border: 0;
border-radius: 8px;
padding: 8px 14px;
cursor: pointer;
font-size: 0.85rem;
font-weight: 600;
}
.primary { background: var(--accent); color: #fff; }
.secondary { background: #2a2f3a; color: var(--text); }
.danger { background: #7f1d1d; color: #fecaca; }
#status {
margin-top: 12px;
font-size: 0.85rem;
min-height: 1.2em;
}
#status.ok { color: var(--ok); }
#status.err { color: var(--err); }
code {
background: #0f1115;
padding: 1px 6px;
border-radius: 4px;
font-size: 0.8rem;
}
.hint { font-size: 0.8rem; color: var(--muted); margin-top: 8px; }
details.adv {
margin-top: 14px;
border-top: 1px solid var(--border);
padding-top: 10px;
}
details.adv summary {
cursor: pointer;
color: var(--muted);
font-size: 0.8rem;
user-select: none;
}
details.adv[open] summary { margin-bottom: 6px; }
</style>
</head>
<body>
<div class="wrap">
<h1>MNote Vault</h1>
<p class="sub">连接远程 MNote → 签发扩展 token → 外站保存账号与登录态</p>
<div class="card">
<label for="baseUrl">MNote 基址 (baseUrl)</label>
<input id="baseUrl" type="url" placeholder="http://127.0.0.1:3000" />
<label for="email">MNote 账号 / 邮箱</label>
<input id="email" type="email" autocomplete="username" />
<label for="password">密码(仅登录瞬间使用,不落盘)</label>
<input id="password" type="password" autocomplete="current-password" />
<details class="adv" id="advRoot">
<summary>高级:覆盖工作区 rootUri(一般不用)</summary>
<label for="rootUri">rootUri</label>
<input
id="rootUri"
type="text"
placeholder="留空 = 自动使用账号默认 my-space"
/>
<p class="hint">
每个账号登录后都有固定默认工作区(
<code>…/users/&lt;actor&gt;/workspaces/my-space</code>
)。留空即可;仅当你要把密码箱写到别的 local_folder 时才填
<code>file://…</code>
</p>
</details>
<div class="actions">
<button type="button" class="primary" id="connect">连接并登录</button>
<button type="button" class="secondary" id="test">测试连接</button>
<button type="button" class="danger" id="disconnect">断开</button>
</div>
<div id="status"></div>
<p class="hint" id="workspaceHint"></p>
</div>
<div class="card">
<strong>使用说明</strong>
<ol class="hint" style="padding-left: 1.2em">
<li>确保 mnote-web 已启动;本机可用 <code>http://127.0.0.1:3000</code>,外网填 frp 完整地址(含端口)。</li>
<li>填 baseUrl + 账号密码 →「连接并登录」。外网首次会弹出「允许访问该网站」,必须点允许。</li>
<li>rootUri 自动解析,不必手填。</li>
<li>外站出现密码框后会弹出保存确认;默认勾选保存 Cookie。</li>
<li>也可点工具栏图标手动「保存当前页」。</li>
</ol>
</div>
</div>
<script type="module" src="options.js"></script>
</body>
</html>
+171
View File
@@ -0,0 +1,171 @@
import { normalizeBaseUrl, vaultFetch } from "./lib/api.js";
import { loadConfig, loadPersistent } from "./lib/storage.js";
const $ = (id) => document.getElementById(id);
function setStatus(text, kind) {
const el = $("status");
el.textContent = text || "";
el.className = kind || "";
}
function setWorkspaceHint(rootUri, rootPath, source) {
const el = $("workspaceHint");
if (!el) return;
if (!rootUri) {
el.textContent = "";
return;
}
const path = rootPath || rootUri.replace(/^file:\/\//, "");
const src =
source === "default"
? "账号默认工作区"
: source === "override"
? "手动覆盖"
: "已保存";
el.innerHTML = `密码箱写入:<code>${path}</code>${src}`;
}
/**
* 必须在用户点击手势内调用:MV3 从 service worker 弹权限常被静默拒绝,
* 外网 baseUrl(含非标端口 frp)没有 host 权限时 fetch 就是 Failed to fetch。
* 同时申请 http(s)://*/*否则外站 Cookie 读不到登录态无法保存
* @param {string} baseUrl
*/
async function requestBaseUrlPermission(baseUrl) {
const origin = new URL(normalizeBaseUrl(baseUrl)).origin;
// Broad first so cookie capture works on every login site after connect.
const broad = ["http://*/*", "https://*/*", `${origin}/*`];
try {
const haveBroad = await chrome.permissions.contains({ origins: broad });
if (haveBroad) return true;
const ok = await chrome.permissions.request({ origins: broad });
if (ok) return true;
} catch {
/* fall through to narrow */
}
const origins = [`${origin}/*`];
const have = await chrome.permissions.contains({ origins });
if (have) return true;
try {
return await chrome.permissions.request({ origins });
} catch (e) {
throw new Error(
`无法申请访问权限 ${origin}${e?.message || e}(请在扩展详情里允许该站点;登录态还需「所有网站」)`
);
}
}
function formatFetchError(e, baseUrl) {
const msg = e?.message || String(e);
if (/Failed to fetch|NetworkError|Load failed/i.test(msg)) {
return (
`无法访问 ${baseUrl || "baseUrl"}${msg}` +
`常见原因:① 未点允许扩展访问该站点 ② 外网隧道未通 ③ 证书不受信任。` +
`请重新加载扩展后再点「测试连接」,并允许弹窗中的站点权限。`
);
}
return msg;
}
async function hydrate() {
const p = await loadPersistent();
if (p.baseUrl) $("baseUrl").value = p.baseUrl;
// 默认不展示手填;仅当用户曾覆盖时回填并展开
if (p.rootUri) {
$("rootUri").value = p.rootUri;
const adv = $("advRoot");
if (adv && p.rootUri) {
// 不自动展开;连接后 hint 会显示实际路径
}
}
if (p.email) $("email").value = p.email;
const cfg = await loadConfig();
if (cfg.connected) {
setStatus(
`已连接 · ${cfg.email || cfg.userId || ""} · token 有效`,
"ok"
);
setWorkspaceHint(cfg.rootUri || p.rootUri, p.rootPath, "saved");
} else if (cfg.baseUrl) {
setStatus("已保存配置,但 token 缺失(浏览器重启后需重新登录)", "err");
setWorkspaceHint(p.rootUri, p.rootPath, "saved");
}
}
$("test").onclick = async () => {
setStatus("测试中…");
let baseUrl = "";
try {
baseUrl = normalizeBaseUrl($("baseUrl").value);
const ok = await requestBaseUrlPermission(baseUrl);
if (!ok) {
setStatus(
`已取消访问权限:需要允许扩展访问 ${new URL(baseUrl).origin}`,
"err"
);
return;
}
// whoami 不依赖 /api/health(外网入口可能未挂 health
const data = await vaultFetch(baseUrl, "/api/auth/whoami", {
method: "GET",
credentials: true,
});
setStatus(
`可达 · ${baseUrl} · whoami ok=${Boolean(data?.ok ?? true)}`,
"ok"
);
} catch (e) {
setStatus(formatFetchError(e, baseUrl || $("baseUrl").value), "err");
}
};
$("connect").onclick = async () => {
setStatus("登录中…");
setWorkspaceHint("", "");
const password = $("password").value;
let baseUrl = "";
try {
baseUrl = normalizeBaseUrl($("baseUrl").value);
// 关键:在点击手势内申请 host 权限,再交给 background 登录
const ok = await requestBaseUrlPermission(baseUrl);
if (!ok) {
setStatus(
`已取消访问权限:需要允许扩展访问 ${new URL(baseUrl).origin}(含端口)`,
"err"
);
return;
}
const res = await chrome.runtime.sendMessage({
type: "CONNECT",
baseUrl,
// 留空 → background 调 default workspace
rootUri: ($("rootUri").value || "").trim(),
email: $("email").value,
password,
});
$("password").value = "";
if (!res?.ok) {
setStatus(res?.error || "连接失败", "err");
return;
}
const r = res.result || {};
if (r.rootUri) $("rootUri").value = r.rootUri;
setStatus(
`已连接 · ${r.email || r.userId || ""} · 过期 ${r.expiresAt || "—"}`,
"ok"
);
setWorkspaceHint(r.rootUri, r.rootPath, r.workspaceSource || "default");
} catch (e) {
$("password").value = "";
setStatus(formatFetchError(e, baseUrl || $("baseUrl").value), "err");
}
};
$("disconnect").onclick = async () => {
await chrome.runtime.sendMessage({ type: "DISCONNECT" });
setStatus("已断开", "err");
setWorkspaceHint("", "");
};
hydrate();
+45
View File
@@ -0,0 +1,45 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<title>MNote Vault</title>
<style>
body {
width: 280px;
margin: 0;
padding: 12px;
font-family: system-ui, sans-serif;
background: #1a1d23;
color: #e8eaed;
font-size: 13px;
}
h1 { font-size: 14px; margin: 0 0 8px; }
.status { color: #9aa0a6; margin-bottom: 10px; font-size: 12px; }
.status.on { color: #4ade80; }
.status.off { color: #f87171; }
button {
width: 100%;
margin: 4px 0;
border: 0;
border-radius: 8px;
padding: 8px;
cursor: pointer;
font-size: 12px;
font-weight: 600;
background: #3b82f6;
color: #fff;
}
button.secondary { background: #2a2f3a; color: #e8eaed; }
#msg { margin-top: 8px; font-size: 11px; color: #9aa0a6; min-height: 1em; }
</style>
</head>
<body>
<h1>MNote Vault</h1>
<div id="status" class="status">检查中…</div>
<button type="button" id="savePage">保存当前页账号</button>
<button type="button" class="secondary" id="updateSession">更新当前页登录态</button>
<button type="button" class="secondary" id="options">打开连接设置</button>
<div id="msg"></div>
<script type="module" src="popup.js"></script>
</body>
</html>
+80
View File
@@ -0,0 +1,80 @@
const $ = (id) => document.getElementById(id);
async function refresh() {
const res = await chrome.runtime.sendMessage({ type: "GET_STATUS" });
const r = res?.result || {};
const el = $("status");
if (r.connected) {
el.className = "status on";
el.textContent = `已连接 · ${r.email || r.userId || ""}`;
} else {
el.className = "status off";
el.textContent = "未连接 — 请先在 Options 登录";
}
}
$("options").onclick = () => {
chrome.runtime.openOptionsPage();
};
$("savePage").onclick = async () => {
$("msg").textContent = "";
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (!tab?.id) {
$("msg").textContent = "无活动标签";
return;
}
try {
// Prefer pending draft (post-login) via background; falls back to live form.
const res = await chrome.runtime.sendMessage({
type: "PROMPT_PENDING",
tabId: tab.id,
});
if (!res?.ok) {
$("msg").textContent = res?.error || "无法打开确认层";
return;
}
$("msg").textContent = "已在页面打开确认层";
window.close();
} catch (e) {
$("msg").textContent =
e?.message ||
"无法注入当前页(受保护页面或未加载)。请在普通 http(s) 页重试。";
}
};
$("updateSession").onclick = async () => {
$("msg").textContent = "匹配条目…";
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (!tab?.url) {
$("msg").textContent = "无活动标签 URL";
return;
}
try {
const match = await chrome.runtime.sendMessage({
type: "MATCH_URL",
pageUrl: tab.url,
});
const items = match?.result?.items || [];
if (!items.length) {
$("msg").textContent = "无同站条目:请先保存账号";
return;
}
const id = items[0].id;
const res = await chrome.runtime.sendMessage({
type: "UPDATE_SESSION",
credentialId: id,
pageUrl: tab.url,
accountId: "primary",
});
if (!res?.ok) {
$("msg").textContent = res?.error || "更新失败";
return;
}
$("msg").textContent = `已更新登录态 · ${id}`;
} catch (e) {
$("msg").textContent = e?.message || String(e);
}
};
refresh();