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,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 永久跳过自动提示(仍可用工具栏手动保存)。
|
||||
|
||||
## 保存流程(P0–P1)
|
||||
|
||||
对齐 KeePassXC / Bitwarden 思路,**不使用 `webRequest`**:
|
||||
|
||||
```text
|
||||
content: form submit / 提交按钮
|
||||
→ PENDING_SAVE(background 内存 + 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 session(setTimeout + 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`(P0–P1 用 `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
@@ -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 |
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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/<actor>/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>
|
||||
@@ -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();
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user