From 1b0c3c012cff65a4dae81f8e21a4efc50d273685 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E9=93=B6=E5=86=9B?= Date: Fri, 18 Sep 2026 00:05:20 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8C=89=E5=9F=9F=E5=90=8D=E6=8E=88?= =?UTF-8?q?=E6=9D=83=E3=80=81iframe=20=E5=A4=9A=E5=B8=A7=E5=A1=AB=E5=85=85?= =?UTF-8?q?=E3=80=81=E8=87=AA=E5=8A=A8=E6=8F=90=E4=BA=A4=E9=99=90=E6=B5=81?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3=EF=BC=8C=E5=B9=B6=E8=A1=A5=E9=BD=90=E5=B7=A5?= =?UTF-8?q?=E7=A8=8B=E8=A7=84=E8=8C=83=E4=B8=8E=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 安全与权限: - 站点访问权限改为 optional_host_permissions,保存配置时逐域名授权;移除 host_permissions 与 tabs,安装提示不再出现全站数据访问与浏览记录 - 主口令不再写入 storage.session,改用 IndexedDB 中不可导出的 CryptoKey 句柄恢复解锁,锁定即丢弃 - content script 注入范围只注册已授权域名,并随权限变化即时收敛 自动登录: - 支持 iframe 内的登录表单:脚本注入所有帧,逐帧按自身地址匹配配置 - 手动填充改为逐帧探测,定向发送到真正含密码框的帧 - 限流修正:只在真正触发提交后计数;判定登录成功后立即清零;达到上限时页面给出可见提示;重置判断只看启用中的配置 - SPA 重试改为 DOM 变更门控 + 退避,并单独监听已发现的 Shadow Root 工程化与文档: - 引入 ESLint(扁平配置)与 Prettier,CI 增加 lint 与 format:check - 信息类日志改为 debugLog(默认静默,chrome.storage.local.debugLog 开关) - 测试 123 → 128 用例(新增权限、iframe、限流相关用例) - README / PRIVACY / CHANGELOG 同步 --- .codebuddy/memory/2026-09-17.md | 25 +- .codebuddy/memory/2026-09-18.md | 15 + .codebuddy/memory/MEMORY.md | 33 + .gitea/workflows/release.yml | 5 + .prettierignore | 8 + .prettierrc.json | 9 + CHANGELOG.md | 25 +- PRIVACY.md | 18 +- README.md | 46 +- _locales/en/messages.json | 48 +- _locales/zh_CN/messages.json | 48 +- eslint.config.mjs | 56 ++ manifest.json | 4 +- package-lock.json | 1331 ++++++++++++++++++++++++++++++- package.json | 11 +- popup.css | 17 + popup.html | 1 + scripts/build.mjs | 28 +- scripts/package.mjs | 2 +- scripts/release.mjs | 8 +- src/background.ts | 141 +++- src/content.ts | 407 +++++++--- src/crypto-file.ts | 36 +- src/crypto-store.ts | 351 ++++---- src/env-store.ts | 244 +++--- src/globals.d.ts | 22 +- src/key-store.ts | 6 +- src/messaging.ts | 2 +- src/permissions.ts | 99 +++ src/popup/bookmark-import.ts | 5 +- src/popup/config-edit-modal.ts | 17 +- src/popup/config-form.ts | 29 +- src/popup/config-list.ts | 48 +- src/popup/env-section.ts | 22 +- src/popup/fill-current.ts | 82 +- src/popup/main.ts | 41 +- src/popup/pwd-modal.ts | 8 +- src/popup/transfer.ts | 15 +- src/strength.ts | 23 +- src/types.d.ts | 2 +- src/ui-utils.ts | 22 +- src/utils.ts | 62 +- tests/content-fill.test.js | 305 ++++++- tests/crypto-store.test.js | 14 +- tests/env-store.test.js | 16 +- tests/key-store.test.js | 26 +- tests/permissions.test.js | 149 ++++ tests/utils.test.js | 112 ++- 48 files changed, 3364 insertions(+), 680 deletions(-) create mode 100644 .codebuddy/memory/2026-09-18.md create mode 100644 .prettierignore create mode 100644 .prettierrc.json create mode 100644 eslint.config.mjs create mode 100644 src/permissions.ts create mode 100644 tests/permissions.test.js diff --git a/.codebuddy/memory/2026-09-17.md b/.codebuddy/memory/2026-09-17.md index 6c1890a..91160ea 100644 --- a/.codebuddy/memory/2026-09-17.md +++ b/.codebuddy/memory/2026-09-17.md @@ -23,7 +23,22 @@ - 事实核对:vault PBKDF2 20 万次、导出文件 10 万次、SPA 重试 30s、失败 3 次/冷却 5 分钟、自动锁定 10 分钟、首次提交 2026-09-14、共 30 次提交。 - 扩展 zip 仍只含运行时文件(22 个文件),文档与源码不入包。 -## 段位复评(改造后) +## 采用方案 C:站点权限改为按域名可选授权 +- 新增 `src/permissions.ts`(UMD 全局 `Perms`)+ `tests/permissions.test.js`(13 用例)→ 测试 **112 全绿**。 +- manifest:去掉 `host_permissions: ` 与 `tabs`,改 `optional_host_permissions: ["*://*/*"]`;popup.html 增加 `dist/permissions.js` 引用;build 的 GLOBAL_SCRIPTS 加 `permissions`(现 8 入口、12 处引用校验)。 +- background:只注册已授权 pattern;新增 `chrome.permissions.onAdded/onRemoved` 监听;抽出 `loadEnvironmentsForMatching` / `collectConfigDomains`;新增**兜底注入**(注册失败 → onUpdated 时 executeScript)。 +- popup:新增/编辑保存时先 `Perms.requestForDomain`(紧跟手势);失败提示 toastUnauthorized;配置列表加"未授权"标签可点击授权;`fill-current` 与两个"填入当前域名"按钮改用 activeTab 并区分提示(toastNoTabUrl / toastNeedActiveTab);ui-utils 新增 `resolveAlias` 并修正 `tabs.query({url})` 过滤失效的隐患。 +- i18n:中英各新增 7 个键;popup.css 新增 `.tag-unauthorized`。 +- 文档同步:README 安全设计与权限表、PRIVACY 双语权限段、CHANGELOG(Changed/Security)。 +- 待真机验证(我无法在此环境跑 Chrome):① 安装提示是否已无全站/浏览记录警告 ② 保存配置时是否弹授权框 ③ 授权后自动填充是否生效、`registerContentScripts` 是否接受运行时授权(若失败看 SW 控制台是否出现"降级为按需注入")④ 升级后旧配置需逐个点"未授权"标签授权。 + +## 收尾四项打磨(用户要求,其中第 4 项已过期) +- **第 4 项「activeTab 冗余」已失效**:C 方案移除了 host_permissions 与 tabs 之后,activeTab 变成手动填充/读 tab.url 的**必要**权限,不能删。已向用户说明。 +- lint/formatter:新增 `eslint.config.mjs` + `.prettierrc.json` + `.prettierignore`,devDeps 加 eslint/@eslint/js/typescript-eslint/prettier/globals(安装需 `--cache /tmp/npm-cache-autologin`)。修复了真实问题:main.ts 未使用的 `t` 导入、prefer-const(改用 api 容器 + 非空断言重构初始化顺序)、content.ts 三处"表达式语句"(requestSubmit 三元、两处 `overlay.parentNode && ...` → 抽出 `closeEnvPicker()`)、package.mjs 缺 `cause`、测试未使用参数。CI 增加 lint + format:check。 +- 调试日志:utils.ts 新增 `initDebugLog/isDebugLogEnabled/debugLog`,开关 `chrome.storage.local.debugLog`;content.ts/background.ts 的信息类日志全部改为 debugLog,异常类保留 warn;background 与 content 启动时先 init 再执行主流程。 +- 性能:SPA 重试改为"DOM 变更门控 + 退避"(`domVersion` + `SPA_RETRY_DELAYS_MS` 300→3000ms),30 秒窗口内尝试次数 60 → 14 且多数轮次跳过扫描;**关键修正**:body 上的 MutationObserver 看不到 Shadow DOM 内部变化,若只做版本门控会漏填组件内部后渲染的登录框 → 新增 `trackShadowRoot()`,对扫描中发现的 shadow root 单独挂 observer 递增 domVersion(WeakSet 去重)。 +- 测试 112 → **114 全绿**(新增 Shadow DOM 内部渲染用例、debugLog 开关用例)。lint 0 问题、format:check 通过、打包正常。 + - 结论:**铂金(稳固)**,构建发布与安全设计摸到钻石边,被文档合规与测试深度拖住。 - 量化现状:源码 3531 行(content.ts 612 / background.ts 341 / 最大 popup 模块 config-list 238);测试 920 行 75 用例全绿;压缩后产物合计 59KB(dev 97KB);双语 830 行文案。 - 距钻石的差距(按阻塞程度):① 无 README/CHANGELOG/LICENSE/隐私政策(CWS 处理凭据需隐私政策,唯一硬阻塞)② content.ts 612 行最复杂逻辑无测试 ③ host_permissions 未收敛 ④ 无 lint/formatter 配置 ⑤ deepQuerySelectorAll 每 500ms 全量遍历+扫描 shadowRoot,最长 30s ⑥ 调试日志无开关 ⑦ activeTab 权限已冗余。 @@ -34,6 +49,14 @@ - 已核实 CryptoKey 可 structured clone 入 IndexedDB 且保持 extractable:false,这是替代 __autoPwd 的推荐实现路径。 - 用户尚未开始动手改这两条,等待其决定。 +## iframe 支持(用户反馈"iframe 里的表单填不了") +- 根因:三处注入都没开 `allFrames`(动态注册 / 兜底注入 / popup 探测与注入),默认只作用于顶层帧。 +- 第一步:注册与注入全部加 `allFrames: true`;顺带修掉"兜底注入早于 body 就绪"时 `observer.observe(document.body)` 会抛错的问题(改为等 DOMContentLoaded)。 +- 第二步:`isTopFrame()` 门控浮窗(子帧不弹,避免被 iframe 裁切)→ 子帧用 `pickConfigForFrame(matches, activeEnvId)` 按激活环境选择;`loadAllConfigs` 响应新增 `activeEnvId`;popup 改为逐帧探测 + `frameIds` 精确补注入 + `{frameId}` 定向发消息(修掉"iframe 填成功却收到别帧失败诊断"的错报)。 +- 坑:onMessage 监听器不能改成 async(`return true` 变成微任务会让 Chrome 提前关闭通道),子帧异步填充包在内部 IIFE。 +- 测试:123 全绿(新增 4 个 iframe 用例 + 5 个 `pickFrameWithForm` 用例)。**测试 harness 注意**:jsdom 每个 realm 有自己的 HTMLElement.prototype,iframe 必须单独 `patchLayout(iwin)`,否则 `offsetParent` 为 null 导致用户名框被判为不可见(真实浏览器无此问题)。 +- 文档已同步 README/PRIVACY(披露"已授权域名下注入所有帧,各帧按自身地址匹配")与 CHANGELOG。 + ## popup.ts 1479 行巨石拆分完成 - 拆为 src/popup/ 下 15 个模块:dom/toast/toggle-pwd/context(基础)、pwd-modal/env-modal/config-edit-modal(弹窗)、env-section/config-form/config-list(业务)、transfer/bookmark-import/fill-current/lock(功能)、main(入口)。 - 关键设计:模块间不互相 import,统一通过 context.ts 的 PopupCtx 接口互调(showToast/resetForm/renderEnvSelect/renderConfigList/refreshEnvManage/openEditModal 等),由 main.ts 组装,避免循环依赖。pwd-modal/env-modal 用 Promise resolve 模式供 transfer 等模块 await。 diff --git a/.codebuddy/memory/2026-09-18.md b/.codebuddy/memory/2026-09-18.md new file mode 100644 index 0000000..69150cb --- /dev/null +++ b/.codebuddy/memory/2026-09-18.md @@ -0,0 +1,15 @@ +# 2026-09-18 + +## 自动提交限流逻辑的四项修正(用户要求"四条一起做") +背景:用户核对"连续 3 次失败后不再自动登录"这条逻辑,核查后发现实际语义是"3 次自动提交",且有误伤场景。按认可的四条一并修复: + +1. **计数时机前移错误 → 改为提交后计数**:原实现 `fillSingleConfig` 在探测表单之前就 increment,导致"命中域名但没有登录框"的页面也消耗配额(纯域名配置下,站内浏览 3 个页面就会禁用自动提交)。现改为 `fillForm(config, canSubmit, onSubmitted)`,只在真正 `click()` / `requestSubmit()` 之后累加。 +2. **新增登录成功检测并清零**:提交后 4s(`submitResultCheckMs`)观察,登录框消失或离开匹配地址 → 判定成功 → `resetDomainAttempts`;整页跳转导致脚本销毁的情况由新页面兜底——命中配置但 30s(`fillTimeoutMs`)内始终无登录框 → 清零。修正了"纯域名配置下连续 3 次成功登录后停止自动提交"。 +3. **达到上限时页面可见提示**:新增 `showPageToast(title, body)`(把原 `showCaptchaToast` 泛化,并对文案做了 escapeHtml),`showAttemptsPausedToast()` 显示"已连续自动提交 N 次、M 分钟后恢复"。同时修正文案措辞(原文写"失败次数",实际是提交次数),中英双语文案同步改。 +4. **重置判断过滤 enabled**:URL 变化时的 `stillMatched`/`prevMatched` 只看启用中的配置(此前一条被禁用的配置会阻止重置)。 + +其他细节: +- 新增测试钩子 `window.__autoLoginTiming`(覆盖 `fillTimeoutMs` / `submitResultCheckMs`),否则测超时路径要真等 30 秒。 +- 测试:content-fill 34 用例 + 其余 94 用例 = **128 全绿**;lint 0 问题、format:check 通过。 +- 文档同步:README 功能特性改为"连续自动提交 3 次"并说明成功即清零;CHANGELOG 的 Fixed 段补 5 条。 +- 语义已记入 MEMORY.md("自动提交限流语义"一节)。 diff --git a/.codebuddy/memory/MEMORY.md b/.codebuddy/memory/MEMORY.md index 444df9c..e2f8643 100644 --- a/.codebuddy/memory/MEMORY.md +++ b/.codebuddy/memory/MEMORY.md @@ -1,5 +1,32 @@ # auto-login 项目长期记忆 +## 自动提交限流语义(2026-09-18 修正) +- 计数的是**自动提交次数**(不是"登录失败次数",扩展无法知道凭据对不对),key 为 `envId::domain`,存 `chrome.storage.local.loginAttempts`。上限 3 次,冷却 5 分钟。 +- 计数时机:只在**真正触发提交后**(`fillForm` 的 `onSubmitted` 回调)累加,`fillSingleConfig` 不再提前计数。填写阶段(未提交)不计数。 +- 成功检测(两条路径,命中任一即清零): + 1. 提交后 `submitResultCheckMs`(默认 4s)观察:登录框消失或已离开匹配地址 → `resetDomainAttempts` + 2. 整页跳转导致脚本销毁时,由新页面兜底:命中配置但 `fillTimeoutMs`(默认 30s)内始终没有登录框 → `resetDomainAttempts` +- 达到上限时**页面必须给可见提示**(`showAttemptsPausedToast`,i18n `attemptToastTitle/Body`),仅写日志用户看不懂。 +- 测试钩子:`window.__autoLoginTiming = { fillTimeoutMs, submitResultCheckMs }`,避免测试真等数十秒。 +- 重置路径还有:URL 变化后不再匹配任何**启用中**配置、popup 手动填充。 + +## iframe / 多帧约定(2026-09-17) +- 注入一律带 `allFrames: true`(`registerContentScripts` 与所有 `executeScript`),否则 iframe 内的登录表单填不了。 +- content.ts 每个帧都用自己的 `window.location.href` 匹配配置;`isTopFrame()`(`window.top === window`)决定是否渲染浮窗:**浮窗只在顶层帧**,子帧改用 `pickConfigForFrame(matches, activeEnvId)`(优先激活环境)确定性选择。 +- `background.loadAllConfigs` 的响应包含 `activeEnvId`,供子帧选择凭据。 +- popup 手动填充:先 `executeScript({allFrames:true, func})` 逐帧探测已注入标记(只对未注入的帧用 `frameIds` 补注入,避免重复注入报错),再探测哪帧有 `input[type=password]`(`utils.pickFrameWithForm`),最后带 `{frameId}` 定向 `tabs.sendMessage`。 +- 坑:`chrome.runtime.onMessage` 监听器必须保持**同步**返回 true,子帧里的异步填充要包在内部 IIFE 里,不能把监听器设成 async。 + +## 权限模型(2026-09-17 定为"方案 C") +- `manifest.json`:**不声明 `host_permissions`**,改为 `optional_host_permissions: ["*://*/*"]`;同时移除了 `tabs`(安装提示不再出现"所有网站数据"与"浏览记录")。当前 permissions = storage / activeTab / scripting / sidePanel / bookmarks。 +- 授权流程:保存配置(新增或编辑)时 `Perms.requestForDomain(domain)` 逐域名询问;必须紧跟用户手势,因此放在其它 await 之前。 +- `src/permissions.ts`(UMD 全局 `Perms`,依赖 utils.js 的 `toMatchPatterns`):`patternsForDomain` / `isPatternGranted` / `partitionPatterns` / `partitionDomains` / `requestForDomain`。 +- background 只注册**已授权**的 match pattern(未授权会让整批注册失败);监听 `chrome.permissions.onAdded/onRemoved` 重算注入范围。 +- 手动填充与"填入当前域名"走 `activeTab`(点扩展图标即授权,零弹框);因此切标签页后可能需要重新点图标。 +- 未授权域名在配置列表显示"未授权"标签,点击即可重新申请(i18n: tagUnauthorized 等 7 个新键)。 +- 兜底:若 `registerContentScripts` 失败(文档未明确"运行时授权的主机权限"能否用于动态注册),自动降级为 `chrome.tabs.onUpdated` + `executeScript` 按需注入(`fallbackInjection` 开关)。**待真机验证**。 +- 未声明 `tabs` 的副作用:`chrome.tabs.query({url})` 的 url 过滤被忽略而非报错 → 任何按 url 过滤的结果必须再自行校验 `isDomainMatch`(见 ui-utils.fetchTitleByDomain)。 + ## 已知安全债(2026-09-17 已修复前两条) 1. **已修复**:原实现把主口令明文存 `chrome.storage.session.__autoPwd`。现改为**把 non-extractable CryptoKey 句柄存入 IndexedDB**(`src/key-store.ts`),SW 重启后用 `CryptoStore.unlockWithKey(key)` 恢复解锁,口令不落任何存储;`doLock()` 会删除句柄。旧版 `__autoPwd` 保留一次性迁移分支(读到即转句柄并删除明文)。 - 已核实:`storage.session` 默认**不向 content script 开放**(仅受信任上下文)——早前"任何 content script 都能读到"的说法是错的。 @@ -15,6 +42,12 @@ Chrome MV3「Auto Login Manager」扩展(域名配置自动填充 + 加密存 - npm 全局缓存 `~/.npm` 有权限问题,`npm install` 必须加 `--cache /tmp/npm-cache-autologin`。 - 全局安装不可用(权限),依赖一律装为 devDependency。 +## 代码规范与日志约定(2026-09-17 建立) +- ESLint 扁平配置 `eslint.config.mjs`:js.recommended + typescript-eslint.recommended,但关掉与 tsc 重叠/噪音大的规则(no-undef、no-explicit-any、no-non-null-assertion),重点保留 `no-unused-vars`(`^_` 前缀豁免、caughtErrors: none)、`prefer-const`、`no-var`、`no-empty(allowEmptyCatch)`。测试文件按 CJS 编写,已单独关闭 `no-require-imports`。 +- Prettier:`.prettierrc.json`(printWidth 110 / 双引号 / 分号 / trailingComma all)。格式化范围**仅** `src`、`scripts`、`tests`,不含 popup.html / popup.css / _locales(避免上千行空白 diff)。 +- 命令:`npm run lint` / `lint:fix` / `format` / `format:check`;CI 在测试前执行 lint + format:check。改代码后请跑 `npm run format && npm run lint`。 +- 日志:信息类日志一律用全局 `debugLog(...)`(utils.ts 提供,默认静默),**不要**直接 `console.log`。异常告警用 `console.warn`。开关:`chrome.storage.local.set({ debugLog: true })`。 + ## 构建约定(2026-09-17 建立) - 类型检查与产物生成分离:`tsc --noEmit` 只做类型检查(tsconfig 已设 `noEmit: true`),**esbuild 负责产出 dist/**。 - 产物分三类,不可混为一谈: diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index ccd2d27..8b3f0ef 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -21,6 +21,11 @@ jobs: - name: Install dependencies run: npm ci + - name: Lint & format check + run: | + npm run lint + npm run format:check + - name: Typecheck & test run: npm test diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..36b9b3a --- /dev/null +++ b/.prettierignore @@ -0,0 +1,8 @@ +# 构建产物与依赖 +dist/ +release/ +node_modules/ + +# 版本号由 release 脚本维护,避免格式化产生无意义 diff +package-lock.json +updates.xml diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..d98ce94 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,9 @@ +{ + "printWidth": 110, + "tabWidth": 2, + "semi": true, + "singleQuote": false, + "trailingComma": "all", + "arrowParens": "always", + "endOfLine": "lf" +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c6f0a7..4a989d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ ### Added +- iframe 支持:脚本注入所有帧(`allFrames`),可填充内嵌在 iframe 中的登录表单(如 SSO 登录页) - 构建管线:引入 esbuild 负责打包与压缩,`tsc` 只做类型检查(`scripts/build.mjs`) - 构建自检:校验 `manifest.json` / `popup.html` 引用的文件是否存在、版本号是否一致 - 打包与发布脚本:`npm run package` 产出 zip,`npm run release -- ` 递增版本并同步 `manifest.json` / `package.json` / `updates.xml` @@ -16,19 +17,34 @@ - 密钥句柄存储模块 `src/key-store.ts`:以不可导出的 `CryptoKey` 句柄替代明文口令,实现 Service Worker 重启后自动解锁 - `utils.toMatchPatterns()`:把域名配置转成合法的 Chrome match pattern - 按域名的动态脚本注入:只向用户配置过的域名注册 content script -- 测试:新增 `tests/key-store.test.js`、`tests/content-fill.test.js`(jsdom 真实 DOM),测试总数 54 → 99 +- 测试:新增 `tests/key-store.test.js`、`tests/content-fill.test.js`(jsdom 真实 DOM)、`tests/permissions.test.js`,测试总数 54 → 114 - 文档:`README.md`、`PRIVACY.md`、`CHANGELOG.md`、`LICENSE` +- 代码规范:ESLint(扁平配置)+ Prettier,提供 `npm run lint` / `format` 脚本,CI 中执行检查 +- 调试日志开关:`chrome.storage.local.debugLog`(`utils.initDebugLog` / `debugLog`),默认静默 ### Changed - `popup.ts`(1479 行)拆分为 `src/popup/` 下 15 个模块,模块间通过 `PopupCtx` 上下文互相调用,消除网状耦合 - `manifest.json` 移除静态 `content_scripts`(原为 `` 无条件注入所有页面) -- 手动填充改为"探测已注入标记 → `chrome.scripting.executeScript` 按需注入" +- **站点访问权限改为按域名授权**:移除 `host_permissions: `,改用 `optional_host_permissions`,在保存配置时逐域名询问;未授权域名不再自动填充,但手动填充仍可用 +- 移除 `tabs` 权限(改用 `activeTab`),安装提示不再包含"读取您的浏览记录" +- 配置列表新增「未授权」标记,点击即可重新申请站点访问权限 +- 别名兜底改为优先取当前标签页标题(`activeTab` 即可读),并修正未声明 `tabs` 时 `tabs.query({url})` 过滤被忽略导致可能取到无关标题的问题 +- 信息类日志改为按需输出(默认静默),仅保留异常告警常驻输出;新增 `chrome.storage.local.debugLog` 开关 +- 多环境浮窗的移除逻辑收敛为 `closeEnvPicker()`,去掉几处"表达式语句"写法 + +### Performance + +- SPA 重试改为"DOM 变更门控 + 退避"策略:页面自上次扫描后无任何变更时跳过该轮全量 DOM 扫描,重试间隔从固定 500ms 改为 300ms → 3s 逐步退避。大页面(数万节点)在 30 秒窗口内的全量扫描次数从最多 60 次降到个位数 +- 扫描中发现的 Shadow Root 会被单独监听(body 上的 MutationObserver 感知不到 Shadow DOM 内部变化),在降低扫描频次的同时避免漏填组件内部后渲染的登录框 +- 手动填充改为"探测已注入标记 → `chrome.scripting.executeScript` 按需注入",并逐帧探测、定向发消息到"真正有密码框"的帧 +- 多环境选择浮窗只在顶层帧渲染;子帧(iframe)内命中多个环境时改用当前激活环境的配置,避免浮窗被 iframe 裁切 - `dist/` 不再纳入版本控制,改为构建产物;克隆后需执行 `npm run build` - 类型检查与产物生成分离:`tsconfig.json` 设置 `noEmit` ### Security +- **按最小权限运行**:不再持有"访问所有网站"的永久权限,站点访问权改为用户逐域名授予;配合已移除的 `tabs` 权限,安装提示中不再出现全站数据访问与浏览记录条目 - **移除明文口令存储**:不再把主口令写入 `chrome.storage.session`,改为在 IndexedDB 保存不可导出的密钥句柄;旧版本残留的 `__autoPwd` 在升级后会被自动迁移为句柄并立即删除 - 锁定(手动或 10 分钟无操作)时一并删除密钥句柄,解锁状态不再"自动复活" - 脚本注入范围由"所有网站"收敛为"用户配置过的域名",锁定时自动清空 @@ -36,6 +52,11 @@ ### Fixed +- 尝试次数只在**真正触发提交后**才累加:此前命中域名但页面没有登录框也会消耗配额,导致"登录成功后在站内浏览几个页面,自动提交就被暂停" +- 登录成功后计数不再累积:新增提交结果观察(登录框消失或已离开匹配地址 → 判定成功 → 清零),修正了纯域名配置下"连续 3 次成功登录后停止自动提交"的问题 +- 达到上限时给出**页面可见提示**(此前只有一条默认静默的调试日志,用户只看到"填了但不提交") +- 计数重置判断只看启用中的配置,被禁用的配置不再阻止重置 +- 命中配置但整页始终没有登录框时清掉旧的尝试计数 - `tests/detect-captcha.test.js` 缺少 `chrome.runtime.sendMessage` mock,导致加载 `content.js` 时抛出未处理的 Promise 拒绝,测试结果不干净 ## [1.0.0] - 2026-09-14 diff --git a/PRIVACY.md b/PRIVACY.md index c3de223..961675c 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -45,12 +45,15 @@ 本扩展申请以下权限,用途均限于实现自动填充功能: - `storage`:保存配置与解锁期间的会话数据 -- `tabs`:读取当前标签页地址 / 标题,用于「填入当前域名」与登录页匹配 +- `activeTab`:你点击扩展图标时**临时**访问当前标签页,用于「在当前页面填充」与「填入当前域名」 - `scripting`:在需要时注入填充脚本 -- `activeTab`:你点击扩展时临时访问当前标签页 - `sidePanel`:显示侧边栏界面 - `bookmarks`:仅在你点击「从书签导入」时读取书签,用于批量生成域名配置 -- 访问所有网站的权限(``):因为你可以为任意域名配置自动登录;**实际注入范围严格限定为你配置过的域名**,未配置的网站不会执行任何本扩展脚本 +- 网站访问权限(`optional_host_permissions`):本扩展**不声明**安装时生效的全站访问权限。只有在你保存某个域名配置时,才会询问一次「是否允许访问该网站」;**未授权的网站不会执行任何本扩展脚本**。你可以随时在 `chrome://extensions` 中撤销这些授权,撤销后该网站不再自动填充(手动填充仍可用) + +在你已授权的网站内,脚本会注入到该页面的所有框架(含 iframe),以便填充内嵌在 iframe 中的登录表单(如 SSO 登录页)。每个框架都会用自己的地址去匹配你配置的域名,**不匹配的框架不会做任何填充操作**。 + +本扩展未声明 `tabs` 权限,因此不会读取你的浏览记录。 ### 5. 数据的导出与删除 @@ -104,12 +107,15 @@ If you forget your master password, the data **cannot be recovered**. That is an ### Permissions - `storage` — save configs and unlocked session data -- `tabs` — read the active tab's URL/title for "fill current domain" and login-page matching +- `activeTab` — **temporary** access to the active tab when you click the extension icon, used for "fill current page" and "fill current domain" - `scripting` — inject the fill script on demand -- `activeTab` — temporary access to the active tab when you click the extension - `sidePanel` — render the side panel UI - `bookmarks` — read bookmarks only when you click "import from bookmarks" -- `` — you may configure auto-login for any domain; the **actual injection scope is limited to domains you configured**. No script runs on unconfigured sites. +- Site access (`optional_host_permissions`) — this extension does **not** request install-time access to all websites. You are asked once, when you save a config for a domain, whether to allow access to that site. **No script runs on sites you have not authorized.** You can revoke these grants at any time from `chrome://extensions`; revoked sites simply stop auto-filling (manual fill still works). + +On sites you have authorized, the script is injected into every frame of the page (including iframes) so that login forms embedded in an iframe (such as SSO pages) can be filled. Each frame matches your configured domains against its own URL; **frames that do not match perform no filling at all**. + +The `tabs` permission is not requested, so your browsing history is not read. ### Export and Deletion diff --git a/README.md b/README.md index cffa03f..6d46640 100644 --- a/README.md +++ b/README.md @@ -18,9 +18,10 @@ Chrome Manifest V3 扩展。所有凭据只保存在本机,不上传任何服 - 自动填充用户名与密码,可选择是否自动点击登录 - 三级降级探测字段:显式选择器 → 关键词启发式(`name` / `id` / `placeholder` / `autocomplete`)→ 全量兜底 - 支持 Shadow DOM 穿透(组件库封装的登录框也能识别) -- 针对 SPA 的延迟重试:表单晚于脚本渲染时,最长 30 秒内持续重试 +- 支持 iframe 内的登录表单(如 SSO 内嵌登录页):脚本注入所有帧,各帧按自己的地址匹配配置;iframe 内命中多个环境时不弹浮窗(避免被裁切),改用当前激活环境 +- 针对 SPA 的延迟重试:表单晚于脚本渲染时,最长 30 秒内持续重试。重试按 DOM 变更门控并逐步退避,且会监听已发现的 Shadow Root,因此既不会在大页面空转,也不会漏掉组件内部后渲染的登录框 - 检测到验证码(reCAPTCHA / hCaptcha / 滑块 / 图形码)时只填充、不自动提交,并提示手动完成 -- 同一域名连续登录失败 3 次后暂停自动提交,5 分钟后自动恢复 +- 同一域名连续**自动提交** 3 次后暂停自动提交(仍填充),5 分钟后自动恢复;检测到登录成功(登录框消失或已离开匹配地址)会立即清零计数,达到上限时页面上会给出提示 - 手动填充:在当前页面按需注入并填充,无需预先打开对应页面 **安全与数据** @@ -52,7 +53,9 @@ Chrome Manifest V3 扩展。所有凭据只保存在本机,不上传任何服 - **口令不落盘**:Service Worker 空闲被回收后,靠 IndexedDB 中的密钥句柄恢复解锁,无需重新输入口令,同时避免了口令被读取 - **句柄不可导出**:`crypto.subtle.exportKey()` 对句柄会失败,拿到句柄只能"使用"不能"抄走" - **锁定即失效**:手动锁定或自动锁定会删除会话明文与密钥句柄 -- **注入范围 = 已配置域名**:通过 `chrome.scripting.registerContentScripts` 动态注册,锁定时自动清空 +- **默认零站点权限**:不声明 `host_permissions`,站点访问权走 `optional_host_permissions`,安装时**不申请**任何网站访问权限 +- **按域名授权**:保存配置时才询问一次"允许访问该网站";拒绝也只是该域名不自动填充,**手动填充始终可用**(靠 `activeTab`) +- **注入范围 = 已授权域名**:通过 `chrome.scripting.registerContentScripts` 动态注册,只注册已授权的站点,锁定时自动清空 ## 技术栈 @@ -101,6 +104,10 @@ npm install | 命令 | 说明 | | --- | --- | | `npm run typecheck` | 仅类型检查(`tsc --noEmit`) | +| `npm run lint` | ESLint 检查(未使用变量、常见错误) | +| `npm run lint:fix` | ESLint 自动修复 | +| `npm run format` | Prettier 格式化 `src` / `scripts` / `tests` | +| `npm run format:check` | 仅检查格式(CI 使用) | | `npm run build` | 开发构建,带 sourcemap,输出到 `dist/` | | `npm run build:release` | 发布构建,压缩、无 sourcemap | | `npm run watch` | 监听源码变更自动重建 | @@ -109,6 +116,19 @@ npm install | `npm run package` | 发布构建 + 打出 `release/auto-login-v<版本>.zip` | | `npm run release -- patch` | 版本递增 + 同步版本号 + 构建打包(见下文) | +> 格式化范围暂定为源码与测试(`src` / `scripts` / `tests`),未包含 `popup.html`、`popup.css` 与 `_locales`,以免产生上千行的纯空白 diff;需要时可自行扩展 `package.json` 里的 globs。 + +### 调试日志 + +信息类日志(填充过程、注入范围、解锁恢复等)**默认静默**,避免刷满网页控制台与 Service Worker 控制台。需要排查问题时,在扩展的 Service Worker 控制台执行一次: + +```js +chrome.storage.local.set({ debugLog: true }); // 打开 +chrome.storage.local.remove("debugLog"); // 关闭 +``` + +打开后各项日志会以 `[AutoLogin]` 前缀输出。异常类告警(如注册 content script 失败、填充超时诊断)不受该开关影响,始终输出。 + `dist/` 不纳入版本控制,克隆后必须先执行 `npm run build` 才能加载扩展。 ### 本地加载 @@ -126,10 +146,11 @@ npm test - `tests/crypto-store.test.js` 保险库加解密、解锁 / 锁定、密钥句柄解锁 - `tests/key-store.test.js` 密钥句柄的存取与降级行为 +- `tests/permissions.test.js` 可选主机权限的授权状态判断与申请 - `tests/env-store.test.js` 环境与配置 CRUD -- `tests/utils.test.js` URL 匹配、HTML 转义、match pattern 转换 +- `tests/utils.test.js` URL 匹配、HTML 转义、match pattern 转换、调试日志开关 - `tests/detect-captcha.test.js` 验证码识别 -- `tests/content-fill.test.js` 表单探测与填充引擎(jsdom 真实 DOM) +- `tests/content-fill.test.js` 表单探测与填充引擎(jsdom 真实 DOM,含 Shadow DOM、多环境浮窗、失败次数限制、SPA 重试) ## 构建与发布 @@ -161,12 +182,17 @@ git tag v1.0.1 && git push && git push --tags | 权限 | 用途 | | --- | --- | | `storage` | 保存域名配置(密文存 `local`)与解锁期间的会话数据 | -| `tabs` | 读取当前标签页地址与标题,用于「填入当前域名」和匹配判断 | -| `scripting` | 按需注入填充脚本;注入范围由用户配置的域名动态限定 | -| `activeTab` | 用户点击扩展时临时访问当前标签页 | +| `activeTab` | 你点击扩展图标时临时访问当前标签页,用于「在当前页面填充」与「填入当前域名」 | +| `scripting` | 按需注入填充脚本;注入范围仅限你已授权的域名 | | `sidePanel` | 提供侧边栏界面 | -| `bookmarks` | 从浏览器书签批量导入域名 | -| `host_permissions: ` | 在用户配置过的任意域名上执行填充(实际注入范围由配置动态限定,未配置的站点不会注入) | +| `bookmarks` | 从浏览器书签批量导入域名(仅在你点击「从书签导入」时读取) | +| `optional_host_permissions: *://*/*` | **仅为"可申请"声明,安装时不授予**。你在保存某个域名配置时会被询问一次是否允许访问该网站;未授权的站点不会注入任何脚本 | + +> 注入粒度是"已授权域名下的所有帧"(`allFrames`):iframe 里的登录表单也能被填充,因此同一域名下嵌的第三方 iframe 也会执行脚本;但每个帧都会用自己的地址去匹配配置,不匹配的帧不做任何操作。 + +> 未声明 `host_permissions`,也未声明 `tabs`,因此安装提示中不会出现"读取和更改您在所有网站上的数据"或"读取您的浏览记录"。 +> +> 撤销授权:在 `chrome://extensions` 的扩展详情页中移除对应站点权限即可,扩展会立即同步注销注入范围(该域名退化为只能手动填充)。 ## 隐私 diff --git a/_locales/en/messages.json b/_locales/en/messages.json index fb93bd8..eb1b8a6 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -551,6 +551,46 @@ "message": "disabled", "description": "Config item - disabled tag" }, + "attemptToastTitle": { + "message": "Auto-submit paused", + "description": "Toast title - auto submit paused" + }, + "attemptToastBody": { + "message": "Auto-submit ran $1 times in a row for this domain. To avoid locking your account, credentials are only filled without submitting. It resumes automatically after $2 minutes, or you can click login yourself.", + "description": "Toast body - $1 attempt limit, $2 cooldown minutes" + }, + "logLoginSuccessReset": { + "message": "Login appears successful ($1); attempt counter reset", + "description": "Log - reset attempts after success" + }, + "tagUnauthorized": { + "message": "no access", + "description": "Config item - site access not granted" + }, + "tagUnauthorizedTitle": { + "message": "Site access has not been granted, so auto-fill will not work. Click to grant access.", + "description": "Tooltip for the unauthorized tag" + }, + "toastUnauthorized": { + "message": "Saved, but site access was not granted — auto-fill will not work (click \"no access\" in the list to grant it)", + "description": "Toast - not authorized" + }, + "toastAuthorized": { + "message": "Access granted. Auto-fill will work on this site.", + "description": "Toast - authorized" + }, + "toastAuthFail": { + "message": "Access was not granted; auto-fill stays disabled for this site.", + "description": "Toast - authorization denied or failed" + }, + "toastNoTabUrl": { + "message": "Cannot read the current page URL. Click the extension icon on the target page and retry.", + "description": "Toast - tab URL unavailable (no activeTab grant)" + }, + "toastNeedActiveTab": { + "message": "Cannot access the current page. Click the extension icon on the target page and retry.", + "description": "Toast - missing site access for injection" + }, "tagDelay": { "message": "delay $1ms", "description": "Config item - delay tag, $1 ms" @@ -764,8 +804,8 @@ "description": "Log - captcha filled" }, "logAttemptExceeded": { - "message": "Auto-login attempts reached limit. Fill only, no auto-submit. Confirm and click login manually.", - "description": "Log - attempt exceeded" + "message": "Auto-submit reached its limit. Fill only, no auto-submit. Confirm and click login manually.", + "description": "Log - auto submit limit reached" }, "logNoAutoSubmit": { "message": "Auto-submit is off. Fill only.", @@ -780,8 +820,8 @@ "description": "Log - start fill, $1 env name" }, "logAttemptExceededDetail": { - "message": "Env \"$1\" domain $2 auto-login failed $3 times, reached limit $4. Auto-submit stopped. Confirm and login manually, or retry after $5 minutes.", - "description": "Log - attempt exceeded detail, $1 env, $2 domain, $3 fails, $4 limit, $5 cooldown min" + "message": "Env \"$1\" domain $2 auto-submitted $3 times, reached limit $4. Auto-submit stopped. Confirm and login manually, or it resumes after $5 minutes.", + "description": "Log - limit reached detail, $1 env, $2 domain, $3 submitted, $4 limit, $5 cooldown min" }, "logSpaTimeout": { "message": "SPA dynamic form wait timeout ($1s), stopped", diff --git a/_locales/zh_CN/messages.json b/_locales/zh_CN/messages.json index 3b70ad0..5f6088f 100644 --- a/_locales/zh_CN/messages.json +++ b/_locales/zh_CN/messages.json @@ -551,6 +551,34 @@ "message": "已禁用", "description": "配置项 - 已禁用标签" }, + "tagUnauthorized": { + "message": "未授权", + "description": "配置项 - 未授权访问该网站标签" + }, + "tagUnauthorizedTitle": { + "message": "尚未授权访问该网站,自动填充不会生效。点击此处授权。", + "description": "未授权标签的悬浮提示" + }, + "toastUnauthorized": { + "message": "已保存,但未授权访问该网站,自动填充不会生效(可点击列表中的「未授权」授权)", + "description": "toast - 未授权" + }, + "toastAuthorized": { + "message": "已授权,自动填充将在该网站生效", + "description": "toast - 已授权" + }, + "toastAuthFail": { + "message": "授权未完成,该网站不会自动填充", + "description": "toast - 授权失败或被拒绝" + }, + "toastNoTabUrl": { + "message": "无法读取当前页面地址,请在目标网页点击扩展图标后重试", + "description": "toast - 读不到标签页地址(缺少 activeTab 授权)" + }, + "toastNeedActiveTab": { + "message": "无法访问当前页面,请在目标网页点击扩展图标后重试", + "description": "toast - 缺少站点授权,无法注入" + }, "tagDelay": { "message": "延迟 $1ms", "description": "配置项 - 延迟标签,$1 为毫秒数" @@ -764,8 +792,20 @@ "description": "日志 - 验证码已检测到仅填充" }, "logAttemptExceeded": { - "message": "自动登录失败次数已达上限,仅填充不自动提交,请手动确认后点击登录", - "description": "日志 - 自动登录失败次数已达上限" + "message": "自动提交次数已达上限,仅填充不自动提交,请手动确认后点击登录", + "description": "日志 - 自动提交次数已达上限" + }, + "attemptToastTitle": { + "message": "自动提交已暂停", + "description": "浮窗标题 - 自动提交暂停" + }, + "attemptToastBody": { + "message": "该域名已连续自动提交 $1 次,为避免账号被锁定,暂时只填充不自动提交。$2 分钟后自动恢复,你也可以手动点击登录。", + "description": "浮窗内容 - $1 为上限次数,$2 为冷却分钟数" + }, + "logLoginSuccessReset": { + "message": "已判断登录成功($1),重置自动提交计数", + "description": "日志 - 判定登录成功后重置计数" }, "logNoAutoSubmit": { "message": "自动提交已关闭,仅填充", @@ -780,8 +820,8 @@ "description": "日志 - 开始填充,$1 为环境名" }, "logAttemptExceededDetail": { - "message": "环境「$1」域名 $2 自动登录已失败 $3 次,达到上限 $4 次,停止自动提交。请手动确认账号密码后点击登录,或 $5 分钟后重试。", - "description": "日志 - 失败详情,$1 环境,$2 域名,$3 失败数,$4 上限,$5 冷却分钟" + "message": "环境「$1」域名 $2 已自动提交 $3 次,达到上限 $4 次,停止自动提交。请手动确认账号密码后点击登录,或 $5 分钟后自动恢复。", + "description": "日志 - 达到上限详情,$1 环境,$2 域名,$3 已提交数,$4 上限,$5 冷却分钟" }, "logSpaTimeout": { "message": "SPA 动态表单等待超时($1s),已停止尝试", diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..4260299 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,56 @@ +// ESLint 扁平配置(flat config) +// 目标:抓住真正会出问题的东西(未使用变量、隐式 any 之外的常见错误、可选链缺失等), +// 而不与 tsc --strict 的职责重叠,也不与 Prettier 的格式化职责重叠。 +import js from "@eslint/js"; +import tseslint from "typescript-eslint"; +import globals from "globals"; + +export default [ + { ignores: ["dist/**", "release/**", "node_modules/**"] }, + + js.configs.recommended, + ...tseslint.configs.recommended, + + { + // 扩展源码 + 测试:浏览器 / Worker 与扩展 API 全局量 + files: ["src/**/*.ts", "tests/**/*.js"], + languageOptions: { + globals: { + ...globals.browser, + ...globals.node, + chrome: "readonly", + importScripts: "readonly", + }, + }, + rules: { + // 类型由 tsc 负责:ESLint 侧关掉会与 tsc 重复或产生大量噪音的规则 + "no-undef": "off", + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-non-null-assertion": "off", + // 真正的痛点:未使用的变量 / 参数 / 导入 + "@typescript-eslint/no-unused-vars": [ + "error", + { + argsIgnorePattern: "^_", + varsIgnorePattern: "^_", + caughtErrors: "none", // 故意忽略异常的 catch 很常见(如探测性调用) + }, + ], + "no-empty": ["error", { allowEmptyCatch: true }], + "no-var": "error", + "prefer-const": "error", + "no-console": "off", // 项目统一用 debugLog / console.warn,交由 utils.initDebugLog 控制 + }, + }, + + { + // 测试文件按 CommonJS 编写(需 require dist/ 下的产物),允许 require + files: ["tests/**/*.js"], + rules: { "@typescript-eslint/no-require-imports": "off" }, + }, + + { + files: ["scripts/**/*.mjs"], + languageOptions: { globals: { ...globals.node } }, + }, +]; diff --git a/manifest.json b/manifest.json index 025c067..bb086ea 100644 --- a/manifest.json +++ b/manifest.json @@ -4,8 +4,8 @@ "version": "1.0.0", "description": "__MSG_extDescription__", "default_locale": "zh_CN", - "permissions": ["storage", "activeTab", "scripting", "tabs", "sidePanel", "bookmarks"], - "host_permissions": [""], + "permissions": ["storage", "activeTab", "scripting", "sidePanel", "bookmarks"], + "optional_host_permissions": ["*://*/*"], "action": { "default_icon": { "16": "icons/icon16.png", diff --git a/package-lock.json b/package-lock.json index abd559c..9f3d925 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,10 +8,15 @@ "name": "auto-login", "version": "1.0.0", "devDependencies": { + "@eslint/js": "^10.0.1", "@types/chrome": "^0.0.287", "esbuild": "^0.28.2", + "eslint": "^10.10.0", + "globals": "^17.12.0", "jsdom": "^29.1.1", - "typescript": "^5.6.0" + "prettier": "^3.9.7", + "typescript": "^5.6.0", + "typescript-eslint": "^8.70.0" } }, "node_modules/@asamuzakjp/css-color": { @@ -78,6 +83,30 @@ "specificity": "bin/cli.js" } }, + "node_modules/@cacheable/memory": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/@cacheable/memory/-/memory-2.2.0.tgz", + "integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/utils": "^2.5.0", + "@keyv/bigmap": "^1.3.1", + "hookified": "^1.15.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/utils": { + "version": "2.5.0", + "resolved": "https://registry.npmmirror.com/@cacheable/utils/-/utils-2.5.0.tgz", + "integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.5.1", + "keyv": "^5.6.0" + } + }, "node_modules/@csstools/color-helpers": { "version": "6.1.1", "resolved": "https://registry.npmmirror.com/@csstools/color-helpers/-/color-helpers-6.1.1.tgz", @@ -660,6 +689,134 @@ "node": ">=18" } }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmmirror.com/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmmirror.com/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmmirror.com/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.7.0", + "resolved": "https://registry.npmmirror.com/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmmirror.com/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmmirror.com/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.3", + "resolved": "https://registry.npmmirror.com/@eslint/plugin-kit/-/plugin-kit-0.7.3.tgz", + "integrity": "sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, "node_modules/@exodus/bytes": { "version": "1.15.1", "resolved": "https://registry.npmmirror.com/@exodus/bytes/-/bytes-1.15.1.tgz", @@ -678,6 +835,96 @@ } } }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmmirror.com/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmmirror.com/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmmirror.com/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmmirror.com/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@keyv/bigmap": { + "version": "1.3.1", + "resolved": "https://registry.npmmirror.com/@keyv/bigmap/-/bigmap-1.3.1.tgz", + "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.4.0", + "hookified": "^1.15.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "keyv": "^5.6.0" + } + }, + "node_modules/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/chrome": { "version": "0.0.287", "resolved": "https://registry.npmmirror.com/@types/chrome/-/chrome-0.0.287.tgz", @@ -689,6 +936,20 @@ "@types/har-format": "*" } }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmmirror.com/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/filesystem": { "version": "0.0.36", "resolved": "https://registry.npmmirror.com/@types/filesystem/-/filesystem-0.0.36.tgz", @@ -713,6 +974,293 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmmirror.com/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.70.0", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.70.0.tgz", + "integrity": "sha512-/v8HZt6RlyIZxB3ntehELOcUcfxKPVGWXnQdJuHRmzrqgF8nQypcC/oxGW+Ot4VGKDq81XugPKxx0n5PBtf9PA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.70.0", + "@typescript-eslint/type-utils": "8.70.0", + "@typescript-eslint/utils": "8.70.0", + "@typescript-eslint/visitor-keys": "8.70.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.70.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.9", + "resolved": "https://registry.npmmirror.com/ignore/-/ignore-7.0.9.tgz", + "integrity": "sha512-brTTsvFRt5C1gGHtPst/281UjPD5t9fBqbgoMPlVWy11ZLTPfu7HxK4ZYqO9H7o/yC9rSTCI85EaQ4OoY12qYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.70.0", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/parser/-/parser-8.70.0.tgz", + "integrity": "sha512-zYvrmj9Yxd63UGaXw+kdt6A0F0s0qveJyuatIM77bYC2DE4pgmg7a50u8LR7PRtXd0x+h+Tl3eXabGm06SWd3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.70.0", + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/typescript-estree": "8.70.0", + "@typescript-eslint/visitor-keys": "8.70.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.70.0", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/project-service/-/project-service-8.70.0.tgz", + "integrity": "sha512-hFHbTNqhU9G+2eKFXCBVb1tjFT/LceiJ4+HfLO4pTpDI0KHi6iajpcFFkaSQ9gXmCh7n82A0PthaayEdN6mspQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.70.0", + "@typescript-eslint/types": "^8.70.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.70.0", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/scope-manager/-/scope-manager-8.70.0.tgz", + "integrity": "sha512-8nP3Kwh5hlgZ4FicGvmznAmJe8UL4sdU8tLukrPaMuQmDuk4Y8xYfzu/aYZW4xT2JCgc7H/TpDI5cGlxcWJSqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/visitor-keys": "8.70.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.70.0", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.70.0.tgz", + "integrity": "sha512-adnkeeNq9Sq1sUf4+FRVc0KdgYghzsgFpZSQVZVvY0LCuUuN0FnQgyGzCJeC4fW1cdXseBAjU2EOqUIjbNcZUw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.70.0", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/type-utils/-/type-utils-8.70.0.tgz", + "integrity": "sha512-NUMKIhYVaVIVLnRL9CRt+VVcuLgSHUCpXn4/+K8wql+vdInUzvx8BjUO1oJ7cG9shjFJKtF8F8Hh2kCh3/KBVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/typescript-estree": "8.70.0", + "@typescript-eslint/utils": "8.70.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.70.0", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/types/-/types-8.70.0.tgz", + "integrity": "sha512-asTOIYhDg4zdzOScCyaytrsV3cR6B4ecPQlXw/dJIm7J/MZTtCtfVII9JD8Geh4jTCrK/Xe6cg5UevoleMcoJQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.70.0", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.70.0.tgz", + "integrity": "sha512-d9NmHMPEKQ7QCLLm1jI3zmoQBwT5KwFYjXBJ9ymZfKCUU+5rmTRykKAFvH5Qn/ZCds3CEAFS9OC9M/jkl0X2bA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.70.0", + "@typescript-eslint/tsconfig-utils": "8.70.0", + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/visitor-keys": "8.70.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.70.0", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/utils/-/utils-8.70.0.tgz", + "integrity": "sha512-oZmtKJz/4fufZ2p3+Cn3ijEojcdfR+1zYDH2xKYrEly0dR/Q/1xUPRCOlKGxod78nWlU2UnDe09GZ3TaknBFGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.70.0", + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/typescript-estree": "8.70.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.70.0", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.70.0.tgz", + "integrity": "sha512-BoC8PiO4Hkdo0TVJh9Ntxr5MxPDI7/oFsrygN5ADelFSeXG/qgNuucIGA+L5Z6JpPTE/uRfcTWtscjbUaufepQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.70.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmmirror.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmmirror.com/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/bidi-js": { "version": "1.1.0", "resolved": "https://registry.npmmirror.com/bidi-js/-/bidi-js-1.1.0.tgz", @@ -723,6 +1271,48 @@ "require-from-string": "^2.0.2" } }, + "node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/cacheable": { + "version": "2.5.0", + "resolved": "https://registry.npmmirror.com/cacheable/-/cacheable-2.5.0.tgz", + "integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/memory": "^2.2.0", + "@cacheable/utils": "^2.5.0", + "hookified": "^1.15.0", + "keyv": "^5.6.0", + "qified": "^0.10.1" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/css-tree": { "version": "3.2.1", "resolved": "https://registry.npmmirror.com/css-tree/-/css-tree-3.2.1.tgz", @@ -751,6 +1341,24 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, "node_modules/decimal.js": { "version": "10.6.0", "resolved": "https://registry.npmmirror.com/decimal.js/-/decimal.js-10.6.0.tgz", @@ -758,6 +1366,13 @@ "dev": true, "license": "MIT" }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmmirror.com/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, "node_modules/entities": { "version": "8.1.0", "resolved": "https://registry.npmmirror.com/entities/-/entities-8.1.0.tgz", @@ -813,6 +1428,305 @@ "@esbuild/win32-x64": "0.28.2" } }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.10.0", + "resolved": "https://registry.npmmirror.com/eslint/-/eslint-10.10.0.tgz", + "integrity": "sha512-NPXn6r5zl4uET1DAVPaOwzX3rut4c0wcmw3dWJAfOsTM5+TogXo0DDjz8pwm/hL8cyVNpHqeK4JpN0NjnyFFNw==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.3", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "11.1.5 || >11.1.6 <12", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmmirror.com/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmmirror.com/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmmirror.com/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmmirror.com/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmmirror.com/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmmirror.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "11.1.5", + "resolved": "https://registry.npmmirror.com/file-entry-cache/-/file-entry-cache-11.1.5.tgz", + "integrity": "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^6.1.23" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "6.1.23", + "resolved": "https://registry.npmmirror.com/flat-cache/-/flat-cache-6.1.23.tgz", + "integrity": "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cacheable": "^2.5.0", + "flatted": "^3.4.2", + "hookified": "^1.15.0" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmmirror.com/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmmirror.com/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.12.0", + "resolved": "https://registry.npmmirror.com/globals/-/globals-17.12.0.tgz", + "integrity": "sha512-cezEd/DTyyht9cvSSURyygXPfy04GtWO/5e6ZPvH7fCtjKz9PYOmuawphw1Ctd1f6C+5JypXfGD7ahNMXvevBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/hashery": { + "version": "1.5.1", + "resolved": "https://registry.npmmirror.com/hashery/-/hashery-1.5.1.tgz", + "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^1.15.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/hookified": { + "version": "1.15.1", + "resolved": "https://registry.npmmirror.com/hookified/-/hookified-1.15.1.tgz", + "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", + "dev": true, + "license": "MIT" + }, "node_modules/html-encoding-sniffer": { "version": "6.0.0", "resolved": "https://registry.npmmirror.com/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", @@ -826,6 +1740,49 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmmirror.com/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmmirror.com/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmmirror.com/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", @@ -833,6 +1790,13 @@ "dev": true, "license": "MIT" }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, "node_modules/jsdom": { "version": "29.1.1", "resolved": "https://registry.npmmirror.com/jsdom/-/jsdom-29.1.1.tgz", @@ -874,6 +1838,60 @@ } } }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmmirror.com/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmmirror.com/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/lru-cache": { "version": "11.5.2", "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-11.5.2.tgz", @@ -891,6 +1909,86 @@ "dev": true, "license": "CC0-1.0" }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmmirror.com/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/parse5": { "version": "8.0.1", "resolved": "https://registry.npmmirror.com/parse5/-/parse5-8.0.1.tgz", @@ -904,6 +2002,65 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.9.7", + "resolved": "https://registry.npmmirror.com/prettier/-/prettier-3.9.7.tgz", + "integrity": "sha512-T3mF5P7HFzZVLDQuCUNtJj6WK1pcpLMweMNUVwGb01bLNkH9AkKxtZvmInw8K6bnn2O3d6/5RHl6zSHiBAD0Og==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmmirror.com/punycode/-/punycode-2.3.1.tgz", @@ -914,6 +2071,26 @@ "node": ">=6" } }, + "node_modules/qified": { + "version": "0.10.1", + "resolved": "https://registry.npmmirror.com/qified/-/qified-0.10.1.tgz", + "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^2.1.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/qified/node_modules/hookified": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/hookified/-/hookified-2.2.0.tgz", + "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==", + "dev": true, + "license": "MIT" + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmmirror.com/require-from-string/-/require-from-string-2.0.2.tgz", @@ -937,6 +2114,42 @@ "node": ">=v12.22.7" } }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", @@ -954,6 +2167,23 @@ "dev": true, "license": "MIT" }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, "node_modules/tldts": { "version": "7.4.13", "resolved": "https://registry.npmmirror.com/tldts/-/tldts-7.4.13.tgz", @@ -1000,6 +2230,32 @@ "node": ">=20" } }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmmirror.com/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmmirror.com/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", @@ -1014,6 +2270,30 @@ "node": ">=14.17" } }, + "node_modules/typescript-eslint": { + "version": "8.70.0", + "resolved": "https://registry.npmmirror.com/typescript-eslint/-/typescript-eslint-8.70.0.tgz", + "integrity": "sha512-P/W5cz70/cQAuKfY3xwQMWWTV7BvJ0mAQmi+9mBcsVPaBUpd6Ohpa+fECv9rBFrQcig86jAiNBFNWUqnTjr4pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.70.0", + "@typescript-eslint/parser": "8.70.0", + "@typescript-eslint/typescript-estree": "8.70.0", + "@typescript-eslint/utils": "8.70.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, "node_modules/undici": { "version": "7.29.1", "resolved": "https://registry.npmmirror.com/undici/-/undici-7.29.1.tgz", @@ -1024,6 +2304,16 @@ "node": ">=20.18.1" } }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmmirror.com/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", "resolved": "https://registry.npmmirror.com/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", @@ -1072,6 +2362,32 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmmirror.com/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/xml-name-validator": { "version": "5.0.0", "resolved": "https://registry.npmmirror.com/xml-name-validator/-/xml-name-validator-5.0.0.tgz", @@ -1088,6 +2404,19 @@ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", "dev": true, "license": "MIT" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmmirror.com/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } } } diff --git a/package.json b/package.json index c318cc9..bdf05fd 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,10 @@ "private": true, "scripts": { "typecheck": "tsc --noEmit", + "lint": "eslint src tests scripts", + "lint:fix": "eslint src tests scripts --fix", + "format": "prettier --write \"src/**/*.ts\" \"scripts/**/*.mjs\" \"tests/**/*.js\"", + "format:check": "prettier --check \"src/**/*.ts\" \"scripts/**/*.mjs\" \"tests/**/*.js\"", "build": "node scripts/build.mjs", "build:release": "node scripts/build.mjs --release", "watch": "node scripts/build.mjs --watch", @@ -14,9 +18,14 @@ "test:only": "node --test tests/*.test.js" }, "devDependencies": { + "@eslint/js": "^10.0.1", "@types/chrome": "^0.0.287", "esbuild": "^0.28.2", + "eslint": "^10.10.0", + "globals": "^17.12.0", "jsdom": "^29.1.1", - "typescript": "^5.6.0" + "prettier": "^3.9.7", + "typescript": "^5.6.0", + "typescript-eslint": "^8.70.0" } } diff --git a/popup.css b/popup.css index d619169..06d0608 100644 --- a/popup.css +++ b/popup.css @@ -336,6 +336,23 @@ h2 { background: #1a4a8a; } +/* 未授权访问该网站:点击即可重新申请 */ +.tag-unauthorized { + display: inline-block; + padding: 1px 6px; + background: #3a3222; + color: #d9a441; + border-radius: 8px; + font-size: 10px; + vertical-align: middle; + cursor: pointer; + user-select: none; +} + +.tag-unauthorized:hover { + background: #4a4028; +} + /* 列表中标签 */ .tag-disabled { display: inline-block; diff --git a/popup.html b/popup.html index 2d77631..43a5fe9 100644 --- a/popup.html +++ b/popup.html @@ -335,6 +335,7 @@ + diff --git a/scripts/build.mjs b/scripts/build.mjs index a253f82..10313a3 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -28,7 +28,7 @@ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const DIST = path.join(ROOT, "dist"); /** 全局脚本:必须独立成文件,iife 格式,彼此通过 globalThis 通信 */ -const GLOBAL_SCRIPTS = ["utils", "env-store", "crypto-store", "key-store"]; +const GLOBAL_SCRIPTS = ["utils", "permissions", "env-store", "crypto-store", "key-store"]; /** 独立入口:各自成文件,不做跨文件打包 */ const STANDALONE_SCRIPTS = ["background", "content"]; /** 由 background 通过 chrome.scripting 按需注入的脚本(manifest 里不再静态声明) */ @@ -73,7 +73,9 @@ async function verifyReferences() { const manifest = JSON.parse(await readFile(path.join(ROOT, "manifest.json"), "utf8")); const refs = new Set(); - const add = (p) => { if (typeof p === "string") refs.add(p); }; + const add = (p) => { + if (typeof p === "string") refs.add(p); + }; add(manifest.background?.service_worker); add(manifest.side_panel?.default_path); Object.values(manifest.icons || {}).forEach(add); @@ -101,7 +103,9 @@ async function checkVersionConsistency() { const manifest = JSON.parse(await readFile(path.join(ROOT, "manifest.json"), "utf8")); const pkg = JSON.parse(await readFile(path.join(ROOT, "package.json"), "utf8")); if (manifest.version !== pkg.version) { - console.warn(`[build] 版本号不一致:manifest.json=${manifest.version} package.json=${pkg.version}(执行 npm run release 可同步)`); + console.warn( + `[build] 版本号不一致:manifest.json=${manifest.version} package.json=${pkg.version}(执行 npm run release 可同步)`, + ); } return manifest.version; } @@ -112,7 +116,13 @@ export async function buildAll({ release = false, watch = false } = {}) { if (watch) { const contexts = await Promise.all( - entryPoints().map((ep) => esbuild.context({ ...esbuildOptions({ release, format: ep.format, bundle: ep.bundle }), entryPoints: [ep.in], outfile: ep.out })) + entryPoints().map((ep) => + esbuild.context({ + ...esbuildOptions({ release, format: ep.format, bundle: ep.bundle }), + entryPoints: [ep.in], + outfile: ep.out, + }), + ), ); await Promise.all(contexts.map((c) => c.watch())); console.log(`[build] 监听中… v${version}(Ctrl+C 退出)`); @@ -121,11 +131,17 @@ export async function buildAll({ release = false, watch = false } = {}) { await rm(DIST, { recursive: true, force: true }); // 清掉历史产物,避免残留旧文件被打进包 for (const ep of entryPoints()) { - await esbuild.build({ ...esbuildOptions({ release, format: ep.format, bundle: ep.bundle }), entryPoints: [ep.in], outfile: ep.out }); + await esbuild.build({ + ...esbuildOptions({ release, format: ep.format, bundle: ep.bundle }), + entryPoints: [ep.in], + outfile: ep.out, + }); } const refs = await verifyReferences(); - console.log(`[build] v${version} ${release ? "release" : "dev"} 构建完成:${entryPoints().length} 个入口,校验 ${refs.length} 处引用`); + console.log( + `[build] v${version} ${release ? "release" : "dev"} 构建完成:${entryPoints().length} 个入口,校验 ${refs.length} 处引用`, + ); return { version, refs }; } diff --git a/scripts/package.mjs b/scripts/package.mjs index 7a2d16b..40f888e 100644 --- a/scripts/package.mjs +++ b/scripts/package.mjs @@ -32,7 +32,7 @@ export async function packageExtension({ skipBuild = false } = {}) { try { execFileSync("zip", ["-r", "-q", zipPath, ...PACKAGE_FILES], { cwd: ROOT, stdio: "inherit" }); } catch (err) { - throw new Error(`打包失败(需要系统 zip 命令):${err.message}`); + throw new Error(`打包失败(需要系统 zip 命令):${err.message}`, { cause: err }); } console.log(`[package] v${manifest.version} → ${path.relative(ROOT, zipPath)}`); diff --git a/scripts/release.mjs b/scripts/release.mjs index 0f13f54..9dcc431 100644 --- a/scripts/release.mjs +++ b/scripts/release.mjs @@ -75,11 +75,15 @@ async function main() { const { zipPath } = await packageExtension(); console.log("\n[release] 完成。后续操作:"); - console.log(` git add manifest.json package.json${updatesSynced ? " updates.xml" : ""} && git commit -m "release: v${nextVersion}"`); + console.log( + ` git add manifest.json package.json${updatesSynced ? " updates.xml" : ""} && git commit -m "release: v${nextVersion}"`, + ); console.log(` git tag v${nextVersion}`); console.log(" git push && git push --tags"); console.log(`\n产物:${path.relative(ROOT, zipPath)}`); - console.log("上传 Chrome Web Store 用该 zip;自托管自更新需用同一份内容打成 .crx 并放到 updates.xml 的 codebase 地址。"); + console.log( + "上传 Chrome Web Store 用该 zip;自托管自更新需用同一份内容打成 .crx 并放到 updates.xml 的 codebase 地址。", + ); } await main(); diff --git a/src/background.ts b/src/background.ts index ae378d5..f3f06d6 100644 --- a/src/background.ts +++ b/src/background.ts @@ -1,17 +1,17 @@ -importScripts("utils.js", "crypto-store.js", "key-store.js"); - +importScripts("utils.js", "permissions.js", "crypto-store.js", "key-store.js"); let masterKey: CryptoKey | null = null; // 解锁后持有,锁定或 SW 重启后清空 let autoLockTimer: ReturnType | null = null; const AUTO_LOCK_MS = 10 * 60 * 1000; // 10 分钟无操作自动锁定 chrome.runtime.onInstalled.addListener(() => { - chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }) + chrome.sidePanel + .setPanelBehavior({ openPanelOnActionClick: true }) .catch((err) => console.warn("[AutoLogin] 设置 sidePanel 行为失败:", err)); migrateLegacyVault(); syncContentScripts().catch((err) => console.warn("[AutoLogin] 同步注入范围失败:", err)); - console.log("[AutoLogin] 扩展已安装"); + debugLog("扩展已安装"); }); chrome.runtime.onStartup.addListener(() => { @@ -33,7 +33,7 @@ async function migrateLegacyVault() { const envs = [{ id: "env_legacy", name: "默认", configs: data.loginConfigs }]; await chrome.storage.local.set({ environments: envs }); await chrome.storage.local.remove("loginConfigs"); - console.log("[AutoLogin] 旧版 loginConfigs 已迁移为明文 environments"); + debugLog("旧版 loginConfigs 已迁移为明文 environments"); } } @@ -60,11 +60,13 @@ async function ensurePlainEnvironments() { } } -// SW 启动即初始化明文环境到 session,供 content script 自动登录读取 -ensurePlainEnvironments().catch((err) => console.warn("[AutoLogin] 启动初始化失败:", err)); -autoRestoreUnlock().catch((err) => console.warn("[AutoLogin] 自动恢复解锁失败:", err)); -syncContentScripts().catch((err) => console.warn("[AutoLogin] 同步注入范围失败:", err)); - +// SW 启动即初始化明文环境到 session,供 content script 自动登录读取。 +// 先读调试开关,保证开启调试时首批日志不会丢。 +initDebugLog().finally(() => { + ensurePlainEnvironments().catch((err) => console.warn("[AutoLogin] 启动初始化失败:", err)); + autoRestoreUnlock().catch((err) => console.warn("[AutoLogin] 自动恢复解锁失败:", err)); + syncContentScripts().catch((err) => console.warn("[AutoLogin] 同步注入范围失败:", err)); +}); // --------------------------------------------------------------------------- // content script 注入范围:只注册用户配置过的域名 @@ -74,17 +76,22 @@ syncContentScripts().catch((err) => console.warn("[AutoLogin] 同步注入范围 const CONTENT_SCRIPT_ID = "auto-login-fill"; let appliedPatternsKey: string | null = null; -/** 汇总所有启用配置对应的 match pattern(加密模式下只有解锁后 session 里才有明文域名) */ -async function collectMatchPatterns(): Promise { - let environments: Environment[] = []; +/** + * 取当前可用于匹配的环境数据。 + * 加密模式下只有解锁后 session 里才有明文域名;锁定时为空(锁着也没有凭据可填)。 + */ +async function loadEnvironmentsForMatching(): Promise { if (await CryptoStore.hasVault()) { const sess = await chrome.storage.session.get("environments"); - environments = sess.environments || []; - } else { - const local = await chrome.storage.local.get("environments"); - environments = local.environments || []; + return sess.environments || []; } + const local = await chrome.storage.local.get("environments"); + return local.environments || []; +} +/** 汇总所有启用配置对应的 match pattern */ +async function collectMatchPatterns(): Promise { + const environments = await loadEnvironmentsForMatching(); const patterns = new Set(); for (const env of environments) { for (const cfg of env.configs || []) { @@ -95,9 +102,24 @@ async function collectMatchPatterns(): Promise { return [...patterns].slice(0, 1000); } +/** 汇总所有启用配置的域名规则(兜底注入时用 isDomainMatch 精确判断) */ +async function collectConfigDomains(): Promise { + const environments = await loadEnvironmentsForMatching(); + const domains: string[] = []; + for (const env of environments) { + for (const cfg of env.configs || []) { + if (cfg.enabled === false || !cfg.domain) continue; + domains.push(cfg.domain); + } + } + return domains.slice(0, 1000); +} + async function syncContentScripts(): Promise { - const patterns = await collectMatchPatterns(); - const key = patterns.join("|"); + const allPatterns = await collectMatchPatterns(); + // 只有用户已授权的域名才能注册(未授权的 pattern 会让整批注册失败) + const { granted, missing } = await Perms.partitionPatterns(allPatterns); + const key = granted.join("|"); if (key === appliedPatternsKey) return; // 无变化,跳过 try { @@ -105,30 +127,64 @@ async function syncContentScripts(): Promise { if (registered.some((s) => s.id === CONTENT_SCRIPT_ID)) { await chrome.scripting.unregisterContentScripts({ ids: [CONTENT_SCRIPT_ID] }); } - if (patterns.length) { + if (granted.length) { await chrome.scripting.registerContentScripts([ { id: CONTENT_SCRIPT_ID, - matches: patterns, + matches: granted, js: ["dist/utils.js", "dist/content.js"], runAt: "document_idle", + allFrames: true, // 登录表单常嵌在 iframe(如 SSO)里,必须注入所有帧 }, ]); } appliedPatternsKey = key; - console.log("[AutoLogin] 注入范围已更新:", patterns.length, "条 match pattern"); + fallbackInjection = false; + debugLog("注入范围已更新: 已授权", granted.length, "条 / 待授权", missing.length, "条"); } catch (err) { - console.warn("[AutoLogin] 注册 content script 失败:", err); + console.warn("[AutoLogin] 注册 content script 失败,降级为按需注入:", err); + fallbackInjection = true; appliedPatternsKey = null; // 下次再试 } } +// 用户在弹窗里授权、或在 chrome://extensions 里撤销授权后,立即重算注入范围 +chrome.permissions.onAdded.addListener(() => { + syncContentScripts().catch((err) => console.warn("[AutoLogin] 同步注入范围失败:", err)); +}); +chrome.permissions.onRemoved.addListener(() => { + appliedPatternsKey = null; + syncContentScripts().catch((err) => console.warn("[AutoLogin] 同步注入范围失败:", err)); +}); + +// 兜底路径:若动态注册不可用(例如某版本对"运行时授权的主机权限"校验更严格), +// 退化为"页面开始加载时按需注入"。仅在注册失败时启用,避免与动态注册重复注入。 +let fallbackInjection = false; + +chrome.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => { + if (!fallbackInjection || changeInfo.status !== "loading") return; + const url = tab.url || changeInfo.url; + if (!url) return; // 未授权的标签页读不到地址,正是我们不该注入的情况 + try { + const domains = await collectConfigDomains(); + const matched = domains.filter((d) => isDomainMatch(url, d)); + if (!matched.length) return; + const { granted } = await Perms.partitionDomains(matched); + if (!granted.length) return; + await chrome.scripting.executeScript({ + target: { tabId, allFrames: true }, + files: ["dist/utils.js", "dist/content.js"], + }); + } catch (err) { + console.warn("[AutoLogin] 兜底注入失败:", err); + } +}); function resetAutoLockTimer() { if (autoLockTimer) clearTimeout(autoLockTimer); if (masterKey) { autoLockTimer = setTimeout(() => { - console.log("[AutoLogin] 闲置超时,自动锁定"); + debugLog("闲置超时,自动锁定"); doLock(); }, AUTO_LOCK_MS); } @@ -136,7 +192,10 @@ function resetAutoLockTimer() { async function doLock() { masterKey = null; - if (autoLockTimer) { clearTimeout(autoLockTimer); autoLockTimer = null; } + if (autoLockTimer) { + clearTimeout(autoLockTimer); + autoLockTimer = null; + } await CryptoStore.lock(); // 锁定即丢弃密钥句柄:解锁状态不再"自动复活" await KeyStore.deleteKey().catch((err) => console.warn("[AutoLogin] 删除密钥句柄失败:", err)); @@ -159,7 +218,7 @@ async function autoRestoreUnlock() { masterKey = key; await KeyStore.saveKey(key).catch((err) => console.warn("[AutoLogin] 保存密钥句柄失败:", err)); resetAutoLockTimer(); - console.log("[AutoLogin] 已将明文口令迁移为密钥句柄"); + debugLog("已将明文口令迁移为密钥句柄"); return; } @@ -174,7 +233,7 @@ async function autoRestoreUnlock() { } masterKey = key; resetAutoLockTimer(); - console.log("[AutoLogin] 已用密钥句柄恢复解锁"); + debugLog("已用密钥句柄恢复解锁"); } catch (err) { console.warn("[AutoLogin] 自动恢复解锁失败:", err); } @@ -201,7 +260,6 @@ chrome.storage.onChanged.addListener(async (changes, areaName) => { } }); - chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { (async () => { try { @@ -212,11 +270,16 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { await ensurePlainEnvironments(); const local = await chrome.storage.local.get("environments"); const sess = await chrome.storage.session.get("environments"); - const cfgCount = (arr: Environment[] | undefined) => (arr || []).reduce((s: number, e: Environment) => s + (e.configs?.length || 0), 0); + const cfgCount = (arr: Environment[] | undefined) => + (arr || []).reduce((s: number, e: Environment) => s + (e.configs?.length || 0), 0); sendResponse({ - hasVault: false, unlocked: true, pendingImport: false, - localEnvs: (local.environments || []).length, localCfgs: cfgCount(local.environments), - sessEnvs: (sess.environments || []).length, sessCfgs: cfgCount(sess.environments), + hasVault: false, + unlocked: true, + pendingImport: false, + localEnvs: (local.environments || []).length, + localCfgs: cfgCount(local.environments), + sessEnvs: (sess.environments || []).length, + sessCfgs: cfgCount(sess.environments), }); break; } @@ -227,13 +290,16 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { case "loadAllConfigs": { if (!(await CryptoStore.hasVault())) await ensurePlainEnvironments(); - const data = await chrome.storage.session.get("environments"); + const data = await chrome.storage.session.get(["environments", "activeEnvId"]); const environments = data.environments || []; const all: (DomainConfig & { envId: string; envName: string })[] = []; environments.forEach((env: Environment) => { - (env.configs || []).forEach((cfg: DomainConfig) => all.push({ ...cfg, envId: env.id, envName: env.name })); + (env.configs || []).forEach((cfg: DomainConfig) => + all.push({ ...cfg, envId: env.id, envName: env.name }), + ); }); - sendResponse({ configs: all }); + // activeEnvId 供子帧在"多环境命中但不弹浮窗"时决定用哪套凭据 + sendResponse({ configs: all, activeEnvId: data.activeEnvId || environments[0]?.id || null }); break; } @@ -286,7 +352,9 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { } masterKey = await CryptoStore.setupVault(message.password, initialEnvs); // 保存不可导出的密钥句柄供 SW 重启后恢复;不保存口令本身 - await KeyStore.saveKey(masterKey).catch((err) => console.warn("[AutoLogin] 保存密钥句柄失败:", err)); + await KeyStore.saveKey(masterKey).catch((err) => + console.warn("[AutoLogin] 保存密钥句柄失败:", err), + ); await chrome.storage.local.remove("environments"); // 已加密,明文不再需要 resetAutoLockTimer(); await syncContentScripts(); @@ -315,7 +383,6 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { } case "getActivity": { - sendResponse({ unlocked: !!masterKey, autoLockMs: AUTO_LOCK_MS, diff --git a/src/content.ts b/src/content.ts index 8f07969..13ae497 100644 --- a/src/content.ts +++ b/src/content.ts @@ -1,4 +1,3 @@ - // 幂等标记:popup 手动填充前会先探测它,避免同一页面被重复注入 // (重复执行本脚本会因顶层 const 重复声明而报错,还会叠加多个 MutationObserver) (window as any).__autoLoginInjected = true; @@ -8,11 +7,14 @@ type Config = DomainConfig & { envId: string; envName: string }; const t = (key: string, ...args: string[]): string => chrome.i18n.getMessage(key, args) || key; const _sendMessage = chrome.runtime.sendMessage as any; - // 2FA / 验证码检测:检测到则不自动提交,仅填充 function detectCaptcha() { // reCAPTCHA - if (document.querySelector(".g-recaptcha, [data-sitekey], iframe[src*='recaptcha'], iframe[src*='google.com/recaptcha']")) { + if ( + document.querySelector( + ".g-recaptcha, [data-sitekey], iframe[src*='recaptcha'], iframe[src*='google.com/recaptcha']", + ) + ) { return { type: "reCAPTCHA", reason: t("captchaTitleRe") }; } // hCaptcha @@ -21,10 +23,18 @@ function detectCaptcha() { } // 国内常见滑块/验证码容器 const sliderSelectors = [ - "#slider", ".slider", ".nc_wrapper", ".nc_iconfont", - "#captcha", ".captcha", ".geetest", "#geetest", - "[id*='captcha']", "[class*='captcha']", - "canvas[id*='captcha']", "canvas[id*='verify']" + "#slider", + ".slider", + ".nc_wrapper", + ".nc_iconfont", + "#captcha", + ".captcha", + ".geetest", + "#geetest", + "[id*='captcha']", + "[class*='captcha']", + "canvas[id*='captcha']", + "canvas[id*='verify']", ]; for (const sel of sliderSelectors) { try { @@ -32,11 +42,13 @@ function detectCaptcha() { if (el && (el as HTMLElement).offsetParent !== null) { return { type: "slider/captcha", reason: t("captchaReasonSlider", sel) }; } - } catch { /* selector 可能无效 */ } + } catch { + /* selector 可能无效 */ + } } // 明显的"验证码"文字输入框(带图形码) const codeInputs = document.querySelectorAll( - "input[name*='captcha' i], input[name*='verify' i], input[name*='code' i], input[id*='captcha' i], input[id*='verify' i], input[placeholder*='验证码' i], input[placeholder*='验证' i]" + "input[name*='captcha' i], input[name*='verify' i], input[name*='code' i], input[id*='captcha' i], input[id*='verify' i], input[placeholder*='验证码' i], input[placeholder*='验证' i]", ); for (const input of Array.from(codeInputs) as HTMLElement[]) { if (input.offsetParent !== null && (input as HTMLInputElement).type !== "hidden") { @@ -47,13 +59,9 @@ function detectCaptcha() { } function setNativeValue(el: HTMLInputElement | HTMLTextAreaElement, value: string): void { - const nativeInputValueSetter = Object.getOwnPropertyDescriptor( - window.HTMLInputElement.prototype, - "value" - )?.set || Object.getOwnPropertyDescriptor( - window.HTMLTextAreaElement.prototype, - "value" - )?.set; + const nativeInputValueSetter = + Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set || + Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, "value")?.set; if (nativeInputValueSetter) { nativeInputValueSetter.call(el, value); @@ -72,12 +80,33 @@ function deepQuerySelectorAll(selector: string, root?: ParentNode): any[] { const r = stack.pop()!; result.push(...r.querySelectorAll(selector)); for (const el of r.querySelectorAll("*")) { - if (el.shadowRoot) stack.push(el.shadowRoot); + if (el.shadowRoot) { + stack.push(el.shadowRoot); + trackShadowRoot(el.shadowRoot); + } } } return result; } +/** + * 监听扫描中发现的 Shadow Root。 + * body 上的 MutationObserver 看不到 Shadow DOM 内部的变化,若不单独监听, + * 就会出现"组件内部的登录框后渲染出来、但重试逻辑以为页面没变而跳过"的漏填。 + */ +const observedShadowRoots = new WeakSet(); +function trackShadowRoot(root: ShadowRoot): void { + if (observedShadowRoots.has(root)) return; + observedShadowRoots.add(root); + try { + new MutationObserver(() => { + domVersion++; + }).observe(root, { childList: true, subtree: true }); + } catch { + /* 某些环境(如测试沙箱)不支持,忽略即可 */ + } +} + function deepQuerySelector(selector: string, root?: ParentNode): any { return deepQuerySelectorAll(selector, root)[0] || null; } @@ -143,15 +172,27 @@ function findUsernameField(config: Config): any { const autocomplete = (input.getAttribute("autocomplete") || "").toLowerCase(); if ( - name.includes("user") || name.includes("name") || name.includes("account") || - name.includes("email") || name.includes("login") || name.includes("mail") || - id.includes("user") || id.includes("name") || id.includes("account") || - id.includes("email") || id.includes("login") || - placeholder.includes("user") || placeholder.includes("name") || - placeholder.includes("email") || placeholder.includes("account") || - placeholder.includes("登录") || placeholder.includes("账号") || - placeholder.includes("用户") || placeholder.includes("邮箱") || - autocomplete.includes("username") || autocomplete.includes("email") + name.includes("user") || + name.includes("name") || + name.includes("account") || + name.includes("email") || + name.includes("login") || + name.includes("mail") || + id.includes("user") || + id.includes("name") || + id.includes("account") || + id.includes("email") || + id.includes("login") || + placeholder.includes("user") || + placeholder.includes("name") || + placeholder.includes("email") || + placeholder.includes("account") || + placeholder.includes("登录") || + placeholder.includes("账号") || + placeholder.includes("用户") || + placeholder.includes("邮箱") || + autocomplete.includes("username") || + autocomplete.includes("email") ) { return input; } @@ -161,7 +202,7 @@ function findUsernameField(config: Config): any { const allInputs = deepQuerySelectorAll( "input[type='text'], input[type='email'], input:not([type='password']):not([type='hidden']):not([type='submit']):not([type='button']):not([type='checkbox']):not([type='radio'])", - searchScope + searchScope, ); for (const input of allInputs) { if (input.offsetParent !== null || input.offsetWidth > 0) { @@ -190,8 +231,13 @@ function findPasswordField(config: Config): any { for (const input of allInputs) { if (input.type === "password") return input; const sig = ( - (input.name || "") + " " + (input.id || "") + " " + - (input.placeholder || "") + " " + (input.getAttribute("autocomplete") || "") + (input.name || "") + + " " + + (input.id || "") + + " " + + (input.placeholder || "") + + " " + + (input.getAttribute("autocomplete") || "") ).toLowerCase(); if (sig.includes("password") || sig.includes("passwd") || sig.includes("pwd")) { if (input.offsetParent !== null || input.offsetWidth > 0) return input; @@ -235,9 +281,15 @@ function findSubmitButton(config: Config, passwordField: any): any { const text = (btn.textContent || btn.value || "").toLowerCase().trim(); const compact = text.replace(/\s/g, ""); if ( - compact.includes("登录") || compact.includes("login") || compact.includes("login") || - compact.includes("signin") || compact.includes("提交") || compact.includes("submit") || - compact.includes("确定") || compact.includes("ok") || compact.includes("go") + compact.includes("登录") || + compact.includes("login") || + compact.includes("login") || + compact.includes("signin") || + compact.includes("提交") || + compact.includes("submit") || + compact.includes("确定") || + compact.includes("ok") || + compact.includes("go") ) { return btn; } @@ -257,9 +309,7 @@ function findSubmitButton(config: Config, passwordField: any): any { if (btn.offsetParent !== null || btn.offsetWidth > 0) { const text = (btn.textContent || btn.value || "").toLowerCase().trim(); const compact = text.replace(/\s/g, ""); - if ( - compact.includes("登录") || compact.includes("login") || compact.includes("signin") - ) { + if (compact.includes("登录") || compact.includes("login") || compact.includes("signin")) { return btn; } } @@ -268,10 +318,14 @@ function findSubmitButton(config: Config, passwordField: any): any { return null; } -function fillForm(config: Config, canSubmit = true): boolean { +/** + * @param onSubmitted 真正触发提交(点击按钮 / 提交表单)后回调,用于累加尝试次数并安排成功检测。 + * 填写阶段不会被调用 —— 避免"匹配了域名但没有登录框"的页面消耗配额。 + */ +function fillForm(config: Config, canSubmit = true, onSubmitted?: () => void): boolean { const passwordField = findPasswordField(config); if (!passwordField) { - console.log("[AutoLogin]", t("logNoPasswordField")); + debugLog(t("logNoPasswordField")); return false; } @@ -279,20 +333,20 @@ function fillForm(config: Config, canSubmit = true): boolean { if (usernameField) { setNativeValue(usernameField, config.username); - console.log("[AutoLogin]", t("logFilledUsername")); + debugLog(t("logFilledUsername")); } else { - console.log("[AutoLogin]", t("logNoUsernameField")); + debugLog(t("logNoUsernameField")); } setNativeValue(passwordField, config.password); - console.log("[AutoLogin]", t("logFilledPassword")); + debugLog(t("logFilledPassword")); // 检测到验证码则强制不自动提交,提示用户手动操作 const captcha = detectCaptcha(); let actualCanSubmit = canSubmit; if (captcha && config.autoSubmit) { actualCanSubmit = false; - console.warn("[AutoLogin]", t("logCaptchaSkip", captcha.reason)); + debugLog(t("logCaptchaSkip", captcha.reason)); showCaptchaToast(captcha.reason); } @@ -302,42 +356,45 @@ function fillForm(config: Config, canSubmit = true): boolean { // delayMs 给需要 JS 初始化的页面留出时间 const delay = Math.max(300, Number(config.delayMs) || 300); setTimeout(() => { - console.log("[AutoLogin]", t("logAutoClick", String(delay))); + debugLog(t("logAutoClick", String(delay))); submitBtn.click(); + onSubmitted?.(); }, delay); return true; } if (passwordField.form) { const delay = Math.max(300, Number(config.delayMs) || 300); - console.log("[AutoLogin]", t("logTryFormSubmit")); + debugLog(t("logTryFormSubmit")); setTimeout(() => { - passwordField.form.requestSubmit - ? passwordField.form.requestSubmit() - : passwordField.form.submit(); + const form = passwordField.form as HTMLFormElement; + if (typeof form.requestSubmit === "function") form.requestSubmit(); + else form.submit(); + onSubmitted?.(); }, delay); return true; } - console.log("[AutoLogin]", t("logNoSubmitAndForm")); + debugLog(t("logNoSubmitAndForm")); } else if (config.autoSubmit && !actualCanSubmit && captcha) { - console.warn("[AutoLogin]", t("logCaptchaFilled")); + debugLog(t("logCaptchaFilled")); } else if (config.autoSubmit && !canSubmit) { - console.warn("[AutoLogin]", t("logAttemptExceeded")); + debugLog(t("logAttemptExceeded")); } else { - console.log("[AutoLogin]", t("logNoAutoSubmit")); + debugLog(t("logNoAutoSubmit")); } return true; } -// 验证码提示浮窗,8 秒后自动消失 -let captchaToastEl: HTMLElement | null = null; -function showCaptchaToast(reason: string): void { - if (captchaToastEl && captchaToastEl.parentNode) { - captchaToastEl.parentNode.removeChild(captchaToastEl); +// 页面内提示浮窗(验证码 / 自动提交暂停),8 秒后自动消失 +let pageToastEl: HTMLElement | null = null; +function showPageToast(title: string, body: string): void { + if (pageToastEl && pageToastEl.parentNode) { + pageToastEl.parentNode.removeChild(pageToastEl); } const el = document.createElement("div"); + el.id = "auto-login-toast"; el.style.cssText = ` position: fixed; top: 16px; @@ -353,19 +410,52 @@ function showCaptchaToast(reason: string): void { box-shadow: 0 4px 16px rgba(0,0,0,0.4); max-width: 300px; `; - el.innerHTML = `
${t("captchaToastTitle")}
` + - `
${t("captchaToastBody", reason)}
`; + el.innerHTML = + `
${escapeHtml(title)}
` + + `
${escapeHtml(body)}
`; document.body.appendChild(el); - captchaToastEl = el; + pageToastEl = el; setTimeout(() => { if (el && el.parentNode) el.parentNode.removeChild(el); - captchaToastEl = null; + pageToastEl = null; }, 8000); } +function showCaptchaToast(reason: string): void { + showPageToast(t("captchaToastTitle"), t("captchaToastBody", reason)); +} + +/** + * 连续自动提交达到上限时的可见提示。 + * 只写日志的话用户完全不知道"为什么只填不提交"(调试日志默认还是静默的)。 + */ +function showAttemptsPausedToast(): void { + showPageToast( + t("attemptToastTitle"), + t("attemptToastBody", String(MAX_LOGIN_ATTEMPTS), String(Math.ceil(ATTEMPT_COOLDOWN / 60000))), + ); +} + const MAX_LOGIN_ATTEMPTS = 3; const ATTEMPT_COOLDOWN = 5 * 60 * 1000; +/** 提交后观察登录结果的窗口:窗口内登录框消失 / 离开匹配地址,视为登录成功 */ +const SUBMIT_RESULT_CHECK_MS = 4000; + +/** + * 时间常量允许被测试覆盖(测试钩子:window.__autoLoginTiming = { fillTimeoutMs: 100 }), + * 否则验证"30 秒超时"这类行为要真等半分钟。 + */ +function timingOverride(name: string, fallback: number): number { + const overrides = (window as unknown as { __autoLoginTiming?: Record }).__autoLoginTiming; + const value = overrides ? overrides[name] : undefined; + return typeof value === "number" ? value : fallback; +} + +/** 页面上是否还有登录框(用于判断登录是否成功) */ +function hasLoginForm(): boolean { + return deepQuerySelectorAll("input[type='password']").length > 0; +} async function getDomainAttempts(envId: string, domain: string): Promise { const resp = await _sendMessage({ action: "getDomainAttempts", envId, domain }).catch(() => null); @@ -381,28 +471,53 @@ async function resetDomainAttempts(envId: string, domain: string): Promise await _sendMessage({ action: "resetDomainAttempts", envId, domain }).catch(() => {}); } +interface LoadedConfigs { + configs: Config[]; + activeEnvId: string | null; +} -async function loadAllConfigs(): Promise { +async function loadAllConfigs(): Promise { const resp = await _sendMessage({ action: "loadAllConfigs" }).catch(() => null); - return resp?.configs || []; + return { configs: resp?.configs || [], activeEnvId: resp?.activeEnvId || null }; +} + +/** + * 当前是否顶层帧。 + * 脚本会注入到所有帧(allFrames),因此每个帧都要区分自己的身份: + * 浮窗类 UI 只在顶层帧渲染,避免贴在被裁切的 iframe 里看不见。 + */ +function isTopFrame(): boolean { + try { + return window.top === window; + } catch { + return false; + } +} + +/** + * 子帧里命中多个环境时的选择策略:优先当前激活环境,其次第一条。 + * 子帧不弹浮窗(避免被 iframe 裁切),因此需要一个确定性的兜底顺序。 + */ +function pickConfigForFrame(matches: Config[], activeEnvId: string | null): Config { + return matches.find((c) => c.envId === activeEnvId) || matches[0]; } async function autoFillOnLoad(): Promise { - let allConfigs = await loadAllConfigs(); - if (allConfigs.length === 0) { + let loaded = await loadAllConfigs(); + if (loaded.configs.length === 0) { // session 可能为空(明文模式 SW 重启后未初始化),触发 background 加载后重试 try { await _sendMessage({ action: "getVaultStatus" }); - allConfigs = await loadAllConfigs(); + loaded = await loadAllConfigs(); } catch (e) { console.warn("[AutoLogin] 初始化失败:", (e as Error).message || e); return; } - if (allConfigs.length === 0) return; + if (loaded.configs.length === 0) return; } - // 每个配置有独立开关 enabled - const enabledConfigs = allConfigs.filter((c) => c.enabled !== false); + // 每个配置有独立开关 enabled;域名按"当前帧自己的 URL"匹配(iframe 用 iframe 的地址) + const enabledConfigs = loaded.configs.filter((c) => c.enabled !== false); if (enabledConfigs.length === 0) return; const matched = enabledConfigs.filter((c) => isDomainMatch(window.location.href, c.domain)); @@ -413,57 +528,110 @@ async function autoFillOnLoad(): Promise { return; } - // 多环境命中:弹浮窗让用户选择 - console.log("[AutoLogin]", t("logMatchedMulti", String(matched.length))); - showEnvPicker(matched); + debugLog(t("logMatchedMulti", String(matched.length))); + if (isTopFrame()) { + // 多环境命中:顶层帧弹浮窗让用户选择 + showEnvPicker(matched); + return; + } + await fillSingleConfig(pickConfigForFrame(matched, loaded.activeEnvId)); } -// SPA 表单可能晚于脚本加载,填不到时每 500ms 重试,30 秒超时 +// SPA 表单可能晚于脚本加载,填不到时重试,30 秒超时。 +// 退避间隔:从 300ms 逐步拉长,避免在大页面上做几十轮无效的全量 DOM 扫描。 const SPA_FILL_TIMEOUT_MS = 30000; -const SPA_RETRY_INTERVAL_MS = 500; +const SPA_RETRY_DELAYS_MS = [300, 600, 1000, 1500, 2000, 3000]; +const SPA_RETRY_MAX_DELAY_MS = 3000; + +// DOM 变更版本号:由文件末尾的 MutationObserver 递增。 +// 页面自上次扫描后没有任何变更 → 不可能凭空出现登录框,直接跳过这一轮扫描。 +let domVersion = 0; + +/** + * 真正提交之后才累加次数,并安排"登录结果"观察: + * 窗口内登录框消失(SPA 登录成功)或地址离开匹配范围(整页跳转成功)→ 判定成功并清零。 + * 整页跳转会让本脚本被销毁、定时器失效,这条路径由"新页面命中配置但没有登录框"兜底。 + */ +function onSubmitted(config: Config): void { + void incrementDomainAttempts(config.envId, config.domain); + setTimeout( + () => { + const leftDomain = !isDomainMatch(window.location.href, config.domain); + if (!hasLoginForm() || leftDomain) { + void resetDomainAttempts(config.envId, config.domain); + debugLog(t("logLoginSuccessReset", config.domain)); + } + }, + timingOverride("submitResultCheckMs", SUBMIT_RESULT_CHECK_MS), + ); +} async function fillSingleConfig(config: Config): Promise { - console.log("[AutoLogin]", t("logStartFillEnv", config.envName || t("defaultEnvName"))); + debugLog(t("logStartFillEnv", config.envName || t("defaultEnvName"))); // 超限后仅填充不自动提交 let canSubmit = config.autoSubmit; if (config.autoSubmit) { const attempts = await getDomainAttempts(config.envId, config.domain); - const now = Date.now(); - if (now - attempts.lastAttempt > ATTEMPT_COOLDOWN) { - attempts.count = 0; - } - if (attempts.count >= MAX_LOGIN_ATTEMPTS) { + // 距离上次提交已超过冷却时间 → 视为重新开始计数 + const count = Date.now() - attempts.lastAttempt > ATTEMPT_COOLDOWN ? 0 : attempts.count; + if (count >= MAX_LOGIN_ATTEMPTS) { canSubmit = false; - console.warn( - "[AutoLogin] " + t( + debugLog( + t( "logAttemptExceededDetail", config.envName, config.domain, - String(attempts.count), + String(count), String(MAX_LOGIN_ATTEMPTS), - String(Math.ceil(ATTEMPT_COOLDOWN / 60000)) - ) + String(Math.ceil(ATTEMPT_COOLDOWN / 60000)), + ), ); - } else { - await incrementDomainAttempts(config.envId, config.domain); + showAttemptsPausedToast(); } } // SPA 重试填充,30 秒超时 const startTime = Date.now(); + let attempt = 0; + let scannedVersion = -1; // 尚未扫描过 + + const scheduleNext = () => { + const delay = SPA_RETRY_DELAYS_MS[attempt]; + if (delay === undefined) return; // 已超出重试档位,靠超时兜底 + attempt++; + setTimeout(tryFill, Math.min(delay, SPA_RETRY_MAX_DELAY_MS)); + }; + const tryFill = () => { - const elapsed = Date.now() - startTime; - if (elapsed > SPA_FILL_TIMEOUT_MS) { + if (Date.now() - startTime > timingOverride("fillTimeoutMs", SPA_FILL_TIMEOUT_MS)) { const inputs = deepQuerySelectorAll("input"); const pwd = deepQuerySelectorAll("input[type='password']"); - console.warn("[AutoLogin] 填充超时: input=" + inputs.length + " pwd=" + pwd.length + " form=" + document.querySelectorAll("form").length + " | " + inputs.slice(0, 6).map((i) => "type=" + (i.type || "?") + "/name=" + (i.name || i.id || "?")).join(", ")); + console.warn( + "[AutoLogin] 填充超时: input=" + + inputs.length + + " pwd=" + + pwd.length + + " form=" + + document.querySelectorAll("form").length + + " | " + + inputs + .slice(0, 6) + .map((i) => "type=" + (i.type || "?") + "/name=" + (i.name || i.id || "?")) + .join(", "), + ); + // 命中配置却始终没有登录框(典型情况:登录成功后的站内页面)→ 清掉旧的尝试计数, + // 避免"在站内随便浏览"把配额消耗光。此时并没有真的提交过,清零是安全的。 + void resetDomainAttempts(config.envId, config.domain); return; } - const filled = fillForm(config, canSubmit); - if (!filled) { - setTimeout(tryFill, SPA_RETRY_INTERVAL_MS); + // 页面没变过 → 跳过本轮扫描(唯一例外是首轮,必须扫一次) + if (scannedVersion === domVersion) { + scheduleNext(); + return; } + scannedVersion = domVersion; + if (!fillForm(config, canSubmit, () => onSubmitted(config))) scheduleNext(); }; tryFill(); @@ -471,11 +639,16 @@ async function fillSingleConfig(config: Config): Promise { // 多环境命中时在页面右上角插入选择浮窗 let envPickerEl: HTMLElement | null = null; -function showEnvPicker(configs: Config[]): void { + +function closeEnvPicker(): void { if (envPickerEl && envPickerEl.parentNode) { envPickerEl.parentNode.removeChild(envPickerEl); - envPickerEl = null; } + envPickerEl = null; +} + +function showEnvPicker(configs: Config[]): void { + closeEnvPicker(); const overlay = document.createElement("div"); overlay.id = "auto-login-env-picker"; @@ -531,8 +704,7 @@ function showEnvPicker(configs: Config[]): void { btn.style.background = "#16213e"; }); btn.addEventListener("click", async () => { - overlay.parentNode && overlay.parentNode.removeChild(overlay); - envPickerEl = null; + closeEnvPicker(); await fillSingleConfig(cfg); }); list.appendChild(btn); @@ -553,17 +725,13 @@ function showEnvPicker(configs: Config[]): void { font-size:11px; font-family:inherit; `; - closeBtn.addEventListener("click", () => { - overlay.parentNode && overlay.parentNode.removeChild(overlay); - envPickerEl = null; - }); + closeBtn.addEventListener("click", closeEnvPicker); overlay.appendChild(closeBtn); document.body.appendChild(overlay); envPickerEl = overlay; } - chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { if (message.action === "manualFill" && message.config) { resetDomainAttempts(message.config.envId, message.config.domain); @@ -579,34 +747,59 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { total: inputs.length, pwd: pwdInputs.length, forms: document.querySelectorAll("form").length, - sample: inputs.slice(0, 10).map((i) => `type=${i.type || "?"}|name=${(i.name || i.id || i.placeholder || "?").slice(0, 20)}`), + sample: inputs + .slice(0, 10) + .map( + (i) => `type=${i.type || "?"}|name=${(i.name || i.id || i.placeholder || "?").slice(0, 20)}`, + ), }, }); } } else if (message.action === "manualFillMulti" && Array.isArray(message.configs)) { - showEnvPicker(message.configs); + if (isTopFrame()) { + showEnvPicker(message.configs); + } else { + // 子帧不弹浮窗:按当前激活环境选择后直接填充。 + // 注意:监听器保持同步、sendResponse 立即返回,否则 Chrome 会认为通道已关闭。 + const configs = message.configs; + void (async () => { + const { activeEnvId } = await loadAllConfigs(); + await fillSingleConfig(pickConfigForFrame(configs, activeEnvId)); + })(); + } sendResponse({ success: true }); } return true; }); -autoFillOnLoad(); +// 先读取调试开关,再开始填充流程(保证开启调试时不丢首批日志) +initDebugLog().finally(autoFillOnLoad); // URL 变化后若新页面不再匹配任何配置,重置之前匹配环境的失败计数 let lastUrl = window.location.href; const observer = new MutationObserver(() => { + domVersion++; // 页面有 DOM 变化,允许下一轮重试重新扫描 if (window.location.href !== lastUrl) { const prevUrl = lastUrl; lastUrl = window.location.href; - loadAllConfigs().then((allConfigs) => { - const stillMatched = allConfigs.some((c) => isDomainMatch(window.location.href, c.domain)); + loadAllConfigs().then(({ configs }) => { + // 只看启用中的配置:被禁用的配置不该阻止计数重置 + const enabled = configs.filter((c) => c.enabled !== false); + const stillMatched = enabled.some((c) => isDomainMatch(window.location.href, c.domain)); if (!stillMatched) { - const prevMatched = allConfigs.filter((c) => isDomainMatch(prevUrl, c.domain)); + const prevMatched = enabled.filter((c) => isDomainMatch(prevUrl, c.domain)); prevMatched.forEach((c) => resetDomainAttempts(c.envId, c.domain)); } }); setTimeout(autoFillOnLoad, 1000); } }); -observer.observe(document.body, { childList: true, subtree: true }); +// 兜底注入可能早于 body 就绪,此时先不挂观察器(填充流程自身有重试) +if (document.body) { + observer.observe(document.body, { childList: true, subtree: true }); +} else { + document.addEventListener("DOMContentLoaded", () => { + if (document.body) observer.observe(document.body, { childList: true, subtree: true }); + }); +} diff --git a/src/crypto-file.ts b/src/crypto-file.ts index 576e0da..26de8a9 100644 --- a/src/crypto-file.ts +++ b/src/crypto-file.ts @@ -17,20 +17,20 @@ export function base64ToBuf(b64: string): ArrayBuffer { return bytes.buffer; } -async function deriveKey(password: string, salt: Uint8Array, iterations: number): Promise { - const keyMaterial = await crypto.subtle.importKey( - "raw", - enc.encode(password), - { name: "PBKDF2" }, - false, - ["deriveKey"] - ); +async function deriveKey( + password: string, + salt: Uint8Array, + iterations: number, +): Promise { + const keyMaterial = await crypto.subtle.importKey("raw", enc.encode(password), { name: "PBKDF2" }, false, [ + "deriveKey", + ]); return crypto.subtle.deriveKey( { name: "PBKDF2", salt, iterations, hash: "SHA-256" }, keyMaterial, { name: "AES-GCM", length: 256 }, false, - ["encrypt", "decrypt"] + ["encrypt", "decrypt"], ); } @@ -40,11 +40,7 @@ export async function encryptConfigs(configs: unknown, password: string): Promis const iterations = 100000; const key = await deriveKey(password, salt, iterations); const plaintext = enc.encode(JSON.stringify(configs)); - const ciphertext = await crypto.subtle.encrypt( - { name: "AES-GCM", iv }, - key, - plaintext - ); + const ciphertext = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, plaintext); return { algorithm: "AES-GCM", iv: bufToBase64(iv), @@ -53,8 +49,8 @@ export async function encryptConfigs(configs: unknown, password: string): Promis algorithm: "PBKDF2", iterations, hash: "SHA-256", - salt: bufToBase64(salt) - } + salt: bufToBase64(salt), + }, }; } @@ -63,10 +59,6 @@ export async function decryptConfigs(cipher: CipherPayload, password: string): P const iv = new Uint8Array(base64ToBuf(cipher.iv)); const iterations = cipher.kdf.iterations || 100000; const key = await deriveKey(password, salt, iterations); - const plainBuf = await crypto.subtle.decrypt( - { name: "AES-GCM", iv }, - key, - base64ToBuf(cipher.ciphertext) - ); + const plainBuf = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, base64ToBuf(cipher.ciphertext)); return JSON.parse(dec.decode(plainBuf)); -} \ No newline at end of file +} diff --git a/src/crypto-store.ts b/src/crypto-store.ts index 6bfc996..ec9c6b2 100644 --- a/src/crypto-store.ts +++ b/src/crypto-store.ts @@ -4,7 +4,6 @@ * 派生密钥仅由 background 在内存中持有。 */ - interface CryptoStoreDeps { getCrypto: () => Crypto; getStorage: (area: "local" | "session") => any; @@ -26,179 +25,191 @@ interface CryptoStoreDeps { }); root.CryptoStore = api; } -})(typeof globalThis !== "undefined" ? globalThis : this, function ({ getCrypto, getStorage }: CryptoStoreDeps) { - const VAULT_META_KEY = "vaultMeta"; - const ENCRYPTED_VAULT_KEY = "encryptedVault"; - const SESSION_ENV_KEY = "environments"; - const SESSION_ACTIVE_KEY = "activeEnvId"; - const PBKDF2_ITERATIONS = 200000; - const VAULT_VERSION = 1; +})( + typeof globalThis !== "undefined" ? globalThis : this, + function ({ getCrypto, getStorage }: CryptoStoreDeps) { + const VAULT_META_KEY = "vaultMeta"; + const ENCRYPTED_VAULT_KEY = "encryptedVault"; + const SESSION_ENV_KEY = "environments"; + const SESSION_ACTIVE_KEY = "activeEnvId"; + const PBKDF2_ITERATIONS = 200000; + const VAULT_VERSION = 1; - function b64encode(buf: ArrayBuffer | Uint8Array): string { - const bytes = new Uint8Array(buf); - let s = ""; - for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]); - return btoa(s); - } - - function b64decode(str: string): Uint8Array { - const s = atob(str); - const bytes = new Uint8Array(s.length); - for (let i = 0; i < s.length; i++) bytes[i] = s.charCodeAt(i); - return bytes; - } - - function randomBytes(n: number): Uint8Array { - const arr = new Uint8Array(n); - getCrypto().getRandomValues(arr); - return arr; - } - - - async function deriveKey(password: string, salt: Uint8Array, iterations: number): Promise { - const enc = new TextEncoder(); - const keyMaterial = await getCrypto().subtle.importKey( - "raw", enc.encode(password), "PBKDF2", false, ["deriveKey"] - ); - return getCrypto().subtle.deriveKey( - { name: "PBKDF2", salt, iterations, hash: "SHA-256" }, - keyMaterial, - { name: "AES-GCM", length: 256 }, - false, - ["encrypt", "decrypt"] - ); - } - - - async function encryptJson(obj: unknown, key: CryptoKey): Promise<{ iv: string; ciphertext: string }> { - const enc = new TextEncoder(); - const iv = randomBytes(12); - const ciphertext = await getCrypto().subtle.encrypt( - { name: "AES-GCM", iv }, key, enc.encode(JSON.stringify(obj)) - ); - return { iv: b64encode(iv), ciphertext: b64encode(ciphertext) }; - } - - /** 解密为 JSON 对象,口令错误或数据损坏时抛错 */ - async function decryptJson({ iv, ciphertext }: { iv: string; ciphertext: string }, key: CryptoKey): Promise { - const dec = new TextDecoder(); - const plain = await getCrypto().subtle.decrypt( - { name: "AES-GCM", iv: b64decode(iv) }, key, b64decode(ciphertext) - ); - return JSON.parse(dec.decode(plain)); - } - - async function getLocal(key: string): Promise { - return (await getStorage("local").get(key))[key]; - } - - - async function hasVault(): Promise { - const meta = await getLocal(VAULT_META_KEY); - const cipher = await getLocal(ENCRYPTED_VAULT_KEY); - return !!(meta && cipher); - } - - - async function isUnlocked(): Promise { - const data = await getStorage("session").get(SESSION_ENV_KEY); - return !!data[SESSION_ENV_KEY]; - } - - /** 首次设置口令:生成盐、加密初始环境写入 local,并把明文写入 session,返回派生密钥 */ - async function setupVault(password: string, environments: Environment[]): Promise { - if (!password) throw new Error("口令不能为空"); - const salt = randomBytes(16); - const key = await deriveKey(password, salt, PBKDF2_ITERATIONS); - const cipher = await encryptJson(environments || [], key); - const meta = { - version: VAULT_VERSION, - iterations: PBKDF2_ITERATIONS, - salt: b64encode(salt), - }; - await getStorage("local").set({ - [VAULT_META_KEY]: meta, - [ENCRYPTED_VAULT_KEY]: cipher, - }); - const envs = environments || []; - await getStorage("session").set({ - [SESSION_ENV_KEY]: envs, - [SESSION_ACTIVE_KEY]: envs[0]?.id || null, - }); - return key; - } - - /** - * 用已有密钥解锁:解密 local 密文并写回 session,返回明文。 - * 供"从 IndexedDB 取回不可导出密钥句柄"的场景复用,全程不需要口令参与。 - * 密钥与 vault 不匹配(例如重设过口令)时抛错。 - */ - async function unlockWithKey(key: CryptoKey): Promise { - const cipher = await getLocal(ENCRYPTED_VAULT_KEY); - if (!cipher) throw new Error("尚未设置口令"); - const environments: Environment[] = await decryptJson(cipher, key); - const active = (await getStorage("session").get(SESSION_ACTIVE_KEY))[SESSION_ACTIVE_KEY] - || environments[0]?.id || null; - await getStorage("session").set({ - [SESSION_ENV_KEY]: environments, - [SESSION_ACTIVE_KEY]: active, - }); - return environments; - } - - /** 解锁:解密 local 密文写入 session,口令错误抛错,返回明文与密钥 */ - async function unlock(password: string): Promise<{ environments: Environment[]; key: CryptoKey }> { - const meta = await getLocal(VAULT_META_KEY); - const cipher = await getLocal(ENCRYPTED_VAULT_KEY); - if (!meta || !cipher) throw new Error("尚未设置口令"); - const key = await deriveKey(password, b64decode(meta.salt), meta.iterations); - let environments: Environment[]; - try { - environments = await unlockWithKey(key); - } catch { - throw new Error("口令错误"); + function b64encode(buf: ArrayBuffer | Uint8Array): string { + const bytes = new Uint8Array(buf); + let s = ""; + for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]); + return btoa(s); } - return { environments, key }; - } - async function lock(): Promise { - await getStorage("session").remove([SESSION_ENV_KEY, SESSION_ACTIVE_KEY]); - } + function b64decode(str: string): Uint8Array { + const s = atob(str); + const bytes = new Uint8Array(s.length); + for (let i = 0; i < s.length; i++) bytes[i] = s.charCodeAt(i); + return bytes; + } - async function getVaultMeta(): Promise { - return getLocal(VAULT_META_KEY); - } + function randomBytes(n: number): Uint8Array { + const arr = new Uint8Array(n); + getCrypto().getRandomValues(arr); + return arr; + } - async function getEncryptedVault(): Promise { - return getLocal(ENCRYPTED_VAULT_KEY); - } + async function deriveKey( + password: string, + salt: Uint8Array, + iterations: number, + ): Promise { + const enc = new TextEncoder(); + const keyMaterial = await getCrypto().subtle.importKey("raw", enc.encode(password), "PBKDF2", false, [ + "deriveKey", + ]); + return getCrypto().subtle.deriveKey( + { name: "PBKDF2", salt, iterations, hash: "SHA-256" }, + keyMaterial, + { name: "AES-GCM", length: 256 }, + false, + ["encrypt", "decrypt"], + ); + } - /** 用给定密钥加密 environments 并写回 local */ - async function persistEncrypted(environments: Environment[], key: CryptoKey): Promise { - const cipher = await encryptJson(environments, key); - await getStorage("local").set({ [ENCRYPTED_VAULT_KEY]: cipher }); - } + async function encryptJson(obj: unknown, key: CryptoKey): Promise<{ iv: string; ciphertext: string }> { + const enc = new TextEncoder(); + const iv = randomBytes(12); + const ciphertext = await getCrypto().subtle.encrypt( + { name: "AES-GCM", iv }, + key, + enc.encode(JSON.stringify(obj)), + ); + return { iv: b64encode(iv), ciphertext: b64encode(ciphertext) }; + } - return { - VAULT_META_KEY, - ENCRYPTED_VAULT_KEY, - SESSION_ENV_KEY, - SESSION_ACTIVE_KEY, - PBKDF2_ITERATIONS, - hasVault, - isUnlocked, - setupVault, - unlock, - unlockWithKey, - lock, - deriveKey, - encryptJson, - decryptJson, - getVaultMeta, - getEncryptedVault, - persistEncrypted, - b64encode, - b64decode, - randomBytes, - }; -}); \ No newline at end of file + /** 解密为 JSON 对象,口令错误或数据损坏时抛错 */ + async function decryptJson( + { iv, ciphertext }: { iv: string; ciphertext: string }, + key: CryptoKey, + ): Promise { + const dec = new TextDecoder(); + const plain = await getCrypto().subtle.decrypt( + { name: "AES-GCM", iv: b64decode(iv) }, + key, + b64decode(ciphertext), + ); + return JSON.parse(dec.decode(plain)); + } + + async function getLocal(key: string): Promise { + return (await getStorage("local").get(key))[key]; + } + + async function hasVault(): Promise { + const meta = await getLocal(VAULT_META_KEY); + const cipher = await getLocal(ENCRYPTED_VAULT_KEY); + return !!(meta && cipher); + } + + async function isUnlocked(): Promise { + const data = await getStorage("session").get(SESSION_ENV_KEY); + return !!data[SESSION_ENV_KEY]; + } + + /** 首次设置口令:生成盐、加密初始环境写入 local,并把明文写入 session,返回派生密钥 */ + async function setupVault(password: string, environments: Environment[]): Promise { + if (!password) throw new Error("口令不能为空"); + const salt = randomBytes(16); + const key = await deriveKey(password, salt, PBKDF2_ITERATIONS); + const cipher = await encryptJson(environments || [], key); + const meta = { + version: VAULT_VERSION, + iterations: PBKDF2_ITERATIONS, + salt: b64encode(salt), + }; + await getStorage("local").set({ + [VAULT_META_KEY]: meta, + [ENCRYPTED_VAULT_KEY]: cipher, + }); + const envs = environments || []; + await getStorage("session").set({ + [SESSION_ENV_KEY]: envs, + [SESSION_ACTIVE_KEY]: envs[0]?.id || null, + }); + return key; + } + + /** + * 用已有密钥解锁:解密 local 密文并写回 session,返回明文。 + * 供"从 IndexedDB 取回不可导出密钥句柄"的场景复用,全程不需要口令参与。 + * 密钥与 vault 不匹配(例如重设过口令)时抛错。 + */ + async function unlockWithKey(key: CryptoKey): Promise { + const cipher = await getLocal(ENCRYPTED_VAULT_KEY); + if (!cipher) throw new Error("尚未设置口令"); + const environments: Environment[] = await decryptJson(cipher, key); + const active = + (await getStorage("session").get(SESSION_ACTIVE_KEY))[SESSION_ACTIVE_KEY] || + environments[0]?.id || + null; + await getStorage("session").set({ + [SESSION_ENV_KEY]: environments, + [SESSION_ACTIVE_KEY]: active, + }); + return environments; + } + + /** 解锁:解密 local 密文写入 session,口令错误抛错,返回明文与密钥 */ + async function unlock(password: string): Promise<{ environments: Environment[]; key: CryptoKey }> { + const meta = await getLocal(VAULT_META_KEY); + const cipher = await getLocal(ENCRYPTED_VAULT_KEY); + if (!meta || !cipher) throw new Error("尚未设置口令"); + const key = await deriveKey(password, b64decode(meta.salt), meta.iterations); + let environments: Environment[]; + try { + environments = await unlockWithKey(key); + } catch { + throw new Error("口令错误"); + } + return { environments, key }; + } + + async function lock(): Promise { + await getStorage("session").remove([SESSION_ENV_KEY, SESSION_ACTIVE_KEY]); + } + + async function getVaultMeta(): Promise { + return getLocal(VAULT_META_KEY); + } + + async function getEncryptedVault(): Promise { + return getLocal(ENCRYPTED_VAULT_KEY); + } + + /** 用给定密钥加密 environments 并写回 local */ + async function persistEncrypted(environments: Environment[], key: CryptoKey): Promise { + const cipher = await encryptJson(environments, key); + await getStorage("local").set({ [ENCRYPTED_VAULT_KEY]: cipher }); + } + + return { + VAULT_META_KEY, + ENCRYPTED_VAULT_KEY, + SESSION_ENV_KEY, + SESSION_ACTIVE_KEY, + PBKDF2_ITERATIONS, + hasVault, + isUnlocked, + setupVault, + unlock, + unlockWithKey, + lock, + deriveKey, + encryptJson, + decryptJson, + getVaultMeta, + getEncryptedVault, + persistEncrypted, + b64encode, + b64decode, + randomBytes, + }; + }, +); diff --git a/src/env-store.ts b/src/env-store.ts index 9ece4c9..308832d 100644 --- a/src/env-store.ts +++ b/src/env-store.ts @@ -3,7 +3,6 @@ * 首次建口令由 crypto-store.setupVault 完成;加密回写由 background 监听完成。 */ - interface EnvStoreDeps { getStorage: () => any; getLocalStorage: () => any; @@ -24,134 +23,133 @@ type ConfigWithEnv = DomainConfig & { envId: string; envName: string }; }); root.EnvStore = api; } -})(typeof globalThis !== "undefined" ? globalThis : this, function ({ getStorage, getLocalStorage }: EnvStoreDeps) { - const ENV_KEY = "environments"; - const ACTIVE_ENV_KEY = "activeEnvId"; +})( + typeof globalThis !== "undefined" ? globalThis : this, + function ({ getStorage, getLocalStorage }: EnvStoreDeps) { + const ENV_KEY = "environments"; + const ACTIVE_ENV_KEY = "activeEnvId"; - function genId(): string { - return "env_" + Date.now().toString(36) + Math.random().toString(36).slice(2, 8); - } - - function genCfgId(): string { - return Date.now().toString(36) + Math.random().toString(36).slice(2, 8); - } - - function storage(): any { - const s = getStorage(); - if (!s) throw new Error("chrome.storage.session 不可用"); - return s; - } - - - async function loadEnvironments(): Promise<{ environments: Environment[]; activeEnvId: string | null }> { - const data = await storage().get([ENV_KEY, ACTIVE_ENV_KEY]); - const environments = data[ENV_KEY] || []; - const activeEnvId = data[ACTIVE_ENV_KEY] || environments[0]?.id || null; - return { environments, activeEnvId }; - } - - async function saveEnvironments(environments: Environment[], activeEnvId?: string): Promise { - const patch: Record = { [ENV_KEY]: environments }; - if (activeEnvId !== undefined) patch[ACTIVE_ENV_KEY] = activeEnvId; - await storage().set(patch); - } - - async function getActiveEnv(): Promise { - const { environments, activeEnvId } = await loadEnvironments(); - return environments.find((e: Environment) => e.id === activeEnvId) || environments[0] || null; - } - - async function setActiveEnv(envId: string): Promise { - await storage().set({ [ACTIVE_ENV_KEY]: envId }); - } - - const MAX_ENVIRONMENTS = 5; - - async function createEnvironment(name: string): Promise { - const { environments } = await loadEnvironments(); - if (environments.length >= MAX_ENVIRONMENTS) { - throw new Error(`最多 ${MAX_ENVIRONMENTS} 个环境`); + function genId(): string { + return "env_" + Date.now().toString(36) + Math.random().toString(36).slice(2, 8); } - const env = { id: genId(), name, configs: [] }; - environments.push(env); - await saveEnvironments(environments, env.id); - return env; - } - async function renameEnvironment(envId: string, newName: string): Promise { - const { environments } = await loadEnvironments(); - const env = environments.find((e: Environment) => e.id === envId); - if (env) { - env.name = newName; + function genCfgId(): string { + return Date.now().toString(36) + Math.random().toString(36).slice(2, 8); + } + + function storage(): any { + const s = getStorage(); + if (!s) throw new Error("chrome.storage.session 不可用"); + return s; + } + + async function loadEnvironments(): Promise<{ environments: Environment[]; activeEnvId: string | null }> { + const data = await storage().get([ENV_KEY, ACTIVE_ENV_KEY]); + const environments = data[ENV_KEY] || []; + const activeEnvId = data[ACTIVE_ENV_KEY] || environments[0]?.id || null; + return { environments, activeEnvId }; + } + + async function saveEnvironments(environments: Environment[], activeEnvId?: string): Promise { + const patch: Record = { [ENV_KEY]: environments }; + if (activeEnvId !== undefined) patch[ACTIVE_ENV_KEY] = activeEnvId; + await storage().set(patch); + } + + async function getActiveEnv(): Promise { + const { environments, activeEnvId } = await loadEnvironments(); + return environments.find((e: Environment) => e.id === activeEnvId) || environments[0] || null; + } + + async function setActiveEnv(envId: string): Promise { + await storage().set({ [ACTIVE_ENV_KEY]: envId }); + } + + const MAX_ENVIRONMENTS = 5; + + async function createEnvironment(name: string): Promise { + const { environments } = await loadEnvironments(); + if (environments.length >= MAX_ENVIRONMENTS) { + throw new Error(`最多 ${MAX_ENVIRONMENTS} 个环境`); + } + const env = { id: genId(), name, configs: [] }; + environments.push(env); + await saveEnvironments(environments, env.id); + return env; + } + + async function renameEnvironment(envId: string, newName: string): Promise { + const { environments } = await loadEnvironments(); + const env = environments.find((e: Environment) => e.id === envId); + if (env) { + env.name = newName; + await saveEnvironments(environments); + } + return env; + } + + async function deleteEnvironment(envId: string): Promise { + const { environments, activeEnvId } = await loadEnvironments(); + if (environments.length <= 1) { + throw new Error("至少保留一个环境"); + } + const idx = environments.findIndex((e: Environment) => e.id === envId); + if (idx === -1) return; + environments.splice(idx, 1); + const newActive = activeEnvId === envId ? environments[0].id : activeEnvId; + await saveEnvironments(environments, newActive ?? undefined); + // 失败计数存于 local(非敏感),删除环境时一并清理 + const local = getLocalStorage(); + if (local) { + const attemptsData = await local.get("loginAttempts"); + const attempts = attemptsData.loginAttempts || {}; + Object.keys(attempts).forEach((k) => { + if (k.startsWith(envId + "::")) delete attempts[k]; + }); + await local.set({ loginAttempts: attempts }); + } + } + + async function loadConfigs(): Promise { + const env = await getActiveEnv(); + return env?.configs || []; + } + + async function saveConfigs(configs: DomainConfig[]): Promise { + const { environments, activeEnvId } = await loadEnvironments(); + const env = environments.find((e: Environment) => e.id === activeEnvId); + if (!env) throw new Error("未找到当前环境"); + env.configs = configs; await saveEnvironments(environments); } - return env; - } - async function deleteEnvironment(envId: string): Promise { - const { environments, activeEnvId } = await loadEnvironments(); - if (environments.length <= 1) { - throw new Error("至少保留一个环境"); - } - const idx = environments.findIndex((e: Environment) => e.id === envId); - if (idx === -1) return; - environments.splice(idx, 1); - const newActive = activeEnvId === envId ? environments[0].id : activeEnvId; - await saveEnvironments(environments, newActive ?? undefined); - // 失败计数存于 local(非敏感),删除环境时一并清理 - const local = getLocalStorage(); - if (local) { - const attemptsData = await local.get("loginAttempts"); - const attempts = attemptsData.loginAttempts || {}; - Object.keys(attempts).forEach((k) => { - if (k.startsWith(envId + "::")) delete attempts[k]; + async function loadAllConfigs(): Promise { + const { environments } = await loadEnvironments(); + const all: ConfigWithEnv[] = []; + environments.forEach((env: Environment) => { + (env.configs || []).forEach((cfg: DomainConfig) => { + all.push({ ...cfg, envId: env.id, envName: env.name }); + }); }); - await local.set({ loginAttempts: attempts }); + return all; } - } - - async function loadConfigs(): Promise { - const env = await getActiveEnv(); - return env?.configs || []; - } - - - async function saveConfigs(configs: DomainConfig[]): Promise { - const { environments, activeEnvId } = await loadEnvironments(); - const env = environments.find((e: Environment) => e.id === activeEnvId); - if (!env) throw new Error("未找到当前环境"); - env.configs = configs; - await saveEnvironments(environments); - } - - - async function loadAllConfigs(): Promise { - const { environments } = await loadEnvironments(); - const all: ConfigWithEnv[] = []; - environments.forEach((env: Environment) => { - (env.configs || []).forEach((cfg: DomainConfig) => { - all.push({ ...cfg, envId: env.id, envName: env.name }); - }); - }); - return all; - } - - return { - ENV_KEY, - ACTIVE_ENV_KEY, - MAX_ENVIRONMENTS, - loadEnvironments, - saveEnvironments, - getActiveEnv, - setActiveEnv, - createEnvironment, - renameEnvironment, - deleteEnvironment, - loadConfigs, - saveConfigs, - loadAllConfigs, - genId, - genCfgId, - }; -}); \ No newline at end of file + return { + ENV_KEY, + ACTIVE_ENV_KEY, + MAX_ENVIRONMENTS, + loadEnvironments, + saveEnvironments, + getActiveEnv, + setActiveEnv, + createEnvironment, + renameEnvironment, + deleteEnvironment, + loadConfigs, + saveConfigs, + loadAllConfigs, + genId, + genCfgId, + }; + }, +); diff --git a/src/globals.d.ts b/src/globals.d.ts index 0d7a411..40d1b96 100644 --- a/src/globals.d.ts +++ b/src/globals.d.ts @@ -26,6 +26,18 @@ interface CryptoStoreApi { randomBytes(n: number): Uint8Array; } +interface PermsApi { + /** 域名对应的 match pattern(含 http / https 两条) */ + patternsForDomain(domain: string | null | undefined): string[]; + isPatternGranted(pattern: string): Promise; + /** 逐条 match pattern 拆分授权状态 */ + partitionPatterns(patterns: string[]): Promise<{ granted: string[]; missing: string[] }>; + /** 逐域名拆分授权状态(UI 标记用) */ + partitionDomains(domains: string[]): Promise<{ granted: string[]; missing: string[] }>; + /** 申请某个域名的主机权限,必须在用户手势中调用 */ + requestForDomain(domain: string): Promise; +} + interface KeyStoreApi { DB_NAME: string; DB_VERSION: number; @@ -66,8 +78,16 @@ declare global { var isDomainMatch: (url: string | null | undefined, pattern: string) => boolean; var escapeHtml: (str: string | null | undefined) => string; var toMatchPatterns: (pattern: string | null | undefined) => string[]; + /** 读取调试开关(chrome.storage.local.debugLog),各入口启动时调用一次 */ + var initDebugLog: () => Promise; + var isDebugLogEnabled: () => boolean; + /** 信息类日志,默认静默 */ + var debugLog: (...args: unknown[]) => void; + /** 从多帧 executeScript 结果中挑出有目标元素的帧 */ + var pickFrameWithForm: (results: Array<{ frameId?: number; result?: unknown }>) => number | null; var EnvStore: EnvStoreApi; var CryptoStore: CryptoStoreApi; var KeyStore: KeyStoreApi; + var Perms: PermsApi; var module: { exports: any } | undefined; -} \ No newline at end of file +} diff --git a/src/key-store.ts b/src/key-store.ts index fcbab87..3cb49a4 100644 --- a/src/key-store.ts +++ b/src/key-store.ts @@ -50,7 +50,9 @@ interface KeyStoreDeps { req.onblocked = () => reject(new Error("IndexedDB 被其它连接阻塞")); }); // 打开失败后允许下次重试,否则一次失败会永久锁死 - dbPromise.catch(() => { dbPromise = null; }); + dbPromise.catch(() => { + dbPromise = null; + }); return dbPromise; } @@ -63,7 +65,7 @@ interface KeyStoreDeps { req.onsuccess = () => resolve(req.result as T); req.onerror = () => reject(req.error || new Error("IndexedDB 操作失败")); tx.onabort = () => reject(tx.error || new Error("IndexedDB 事务中断")); - }) + }), ); } diff --git a/src/messaging.ts b/src/messaging.ts index f54cbde..cb25b99 100644 --- a/src/messaging.ts +++ b/src/messaging.ts @@ -25,4 +25,4 @@ export function sendMessage(message: Record, retries = 3): Prom }; trySend(); }); -} \ No newline at end of file +} diff --git a/src/permissions.ts b/src/permissions.ts new file mode 100644 index 0000000..5d644b5 --- /dev/null +++ b/src/permissions.ts @@ -0,0 +1,99 @@ +/** + * 可选主机权限(optional host permissions):把"能访问哪些网站"的授权交给用户逐域名决定。 + * + * 安全模型: + * - manifest 里只有 optional_host_permissions 声明(安装时不授予任何站点访问权限), + * 因此安装提示不会出现"读取和更改您在所有网站上的数据" + * - 用户在保存域名配置时被询问一次,授权结果由 Chrome 持久化 + * - 拒绝授权只是该域名不会自动填充,**手动填充仍然可用**(靠 activeTab) + * - 注册 content script 前必须过滤出已授权的 match pattern,否则整体注册会失败 + */ + +interface PermsDeps { + getApi: () => any; + getMatchPatterns: (domain: string) => string[]; +} + +(function (root: any, factory: (deps: PermsDeps) => any): void { + const deps: PermsDeps = { + getApi: () => (typeof chrome !== "undefined" && chrome.permissions ? chrome.permissions : null), + // 延迟读取 utils.js 提供的 toMatchPatterns,避免模块加载顺序耦合 + getMatchPatterns: (domain: string) => + typeof root.toMatchPatterns === "function" ? root.toMatchPatterns(domain) : [], + }; + if (typeof module === "object" && module.exports) { + module.exports = factory(deps); + } else { + root.Perms = factory(deps); + } +})(typeof globalThis !== "undefined" ? globalThis : this, function ({ getApi, getMatchPatterns }: PermsDeps) { + /** 某域名对应的 match pattern(同时含 http / https 两条) */ + function patternsForDomain(domain: string | null | undefined): string[] { + return getMatchPatterns(domain || ""); + } + + async function isPatternGranted(pattern: string): Promise { + const api = getApi(); + if (!api) return true; // 环境不支持(如 Node 测试)时按已授权处理,避免误伤功能 + try { + return await api.contains({ origins: [pattern] }); + } catch { + return false; + } + } + + /** 逐条 match pattern 拆分授权状态(注册 content script 时只用 granted) */ + async function partitionPatterns(patterns: string[]): Promise<{ granted: string[]; missing: string[] }> { + const granted: string[] = []; + const missing: string[] = []; + for (const pattern of patterns) { + if (await isPatternGranted(pattern)) granted.push(pattern); + else missing.push(pattern); + } + return { granted, missing }; + } + + /** 逐域名拆分授权状态(UI 标记用):任一 scheme 已授权即算已授权 */ + async function partitionDomains(domains: string[]): Promise<{ granted: string[]; missing: string[] }> { + const granted: string[] = []; + const missing: string[] = []; + for (const domain of domains) { + const patterns = patternsForDomain(domain); + let ok = false; + for (const pattern of patterns) { + if (await isPatternGranted(pattern)) { + ok = true; + break; + } + } + (ok ? granted : missing).push(domain); + } + return { granted, missing }; + } + + /** + * 申请某个域名的主机权限。必须在用户手势(click 回调)中调用,否则会被 Chrome 拒绝。 + * 返回最终是否已授权;同一域名二次调用会直接命中 contains,不再弹框。 + */ + async function requestForDomain(domain: string): Promise { + const api = getApi(); + if (!api) return false; + const patterns = patternsForDomain(domain); + if (!patterns.length) return false; + try { + if (await api.contains({ origins: patterns })) return true; + return await api.request({ origins: patterns }); + } catch (err) { + console.warn("[AutoLogin] 申请网站访问权限失败:", err); + return false; + } + } + + return { + patternsForDomain, + isPatternGranted, + partitionPatterns, + partitionDomains, + requestForDomain, + }; +}); diff --git a/src/popup/bookmark-import.ts b/src/popup/bookmark-import.ts index 9e1f74b..fd71ec5 100644 --- a/src/popup/bookmark-import.ts +++ b/src/popup/bookmark-import.ts @@ -11,7 +11,10 @@ const bookmarkCancel = $("bookmarkCancel"); const bookmarkConfirm = $("bookmarkConfirm"); const importBookmarkBtn = $("importBookmarkBtn"); -function flattenBookmarks(nodes: chrome.bookmarks.BookmarkTreeNode[], out: { title: string; url: string }[]): void { +function flattenBookmarks( + nodes: chrome.bookmarks.BookmarkTreeNode[], + out: { title: string; url: string }[], +): void { for (const node of nodes) { if (node.url) { out.push({ title: node.title || node.url, url: node.url }); diff --git a/src/popup/config-edit-modal.ts b/src/popup/config-edit-modal.ts index a1173cb..495ce18 100644 --- a/src/popup/config-edit-modal.ts +++ b/src/popup/config-edit-modal.ts @@ -3,7 +3,7 @@ * 通过 ctx.openEditModal(cfg | null) 打开:cfg 为 null 表示新增。 */ import { t } from "../i18n.js"; -import { fetchTitleByDomain } from "../ui-utils.js"; +import { resolveAlias } from "../ui-utils.js"; import type { DomainConfig } from "../types.js"; import { $ } from "./dom"; import type { PopupCtx } from "./context"; @@ -77,10 +77,13 @@ export function initEditConfigModal(ctx: PopupCtx) { return; } + // 申请站点访问权限必须紧跟用户手势,因此放在其它 await 之前 + const authorized = await Perms.requestForDomain(domain); + const config: DomainConfig = { id: editModalEditingId || EnvStore.genCfgId(), domain, - alias: editAliasInput.value.trim() || await fetchTitleByDomain(domain) || null, + alias: editAliasInput.value.trim() || (await resolveAlias(domain)) || null, username, password, autoSubmit: editAutoSubmitSelect.value === "true", @@ -98,10 +101,10 @@ export function initEditConfigModal(ctx: PopupCtx) { if (idx !== -1) { configs[idx] = config; } - ctx.showToast(t("toastUpdated")); + ctx.showToast(authorized ? t("toastUpdated") : t("toastUnauthorized")); } else { configs.push(config); - ctx.showToast(t("toastAdded")); + ctx.showToast(authorized ? t("toastAdded") : t("toastUnauthorized")); } await EnvStore.saveConfigs(configs); @@ -126,10 +129,14 @@ export function initEditConfigModal(ctx: PopupCtx) { // 填入当前标签页域名 editFillDomainBtn.addEventListener("click", async () => { const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); - if (!tab || !tab.url) { + if (!tab) { ctx.showToast(t("toastNoTab")); return; } + if (!tab.url) { + ctx.showToast(t("toastNoTabUrl")); + return; + } try { const hostname = new URL(tab.url).hostname; if (!hostname) { diff --git a/src/popup/config-form.ts b/src/popup/config-form.ts index 19413ae..e7be06c 100644 --- a/src/popup/config-form.ts +++ b/src/popup/config-form.ts @@ -3,7 +3,7 @@ * 添加按钮提交、"填入当前域名"按钮。 */ import { t } from "../i18n.js"; -import { fetchTitleByDomain } from "../ui-utils.js"; +import { resolveAlias } from "../ui-utils.js"; import type { DomainConfig } from "../types.js"; import { $ } from "./dom"; import type { PopupCtx } from "./context"; @@ -24,9 +24,16 @@ const fillDomainBtn = $("fillDomain"); const DRAFT_KEY = "formDraft"; const draftFields = [ - domainInput, aliasInput, usernameInput, passwordInput, autoSubmitSelect, - usernameSelectorInput, passwordSelectorInput, submitSelectorInput, - enabledCheckbox, delayMsInput, + domainInput, + aliasInput, + usernameInput, + passwordInput, + autoSubmitSelect, + usernameSelectorInput, + passwordSelectorInput, + submitSelectorInput, + enabledCheckbox, + delayMsInput, ]; export function initConfigForm(ctx: PopupCtx) { @@ -98,10 +105,13 @@ export function initConfigForm(ctx: PopupCtx) { return; } + // 申请站点访问权限必须紧跟用户手势,因此放在其它 await 之前 + const authorized = await Perms.requestForDomain(domain); + const config: DomainConfig = { id: EnvStore.genCfgId(), domain, - alias: aliasInput.value.trim() || await fetchTitleByDomain(domain) || null, + alias: aliasInput.value.trim() || (await resolveAlias(domain)) || null, username, password, autoSubmit: autoSubmitSelect.value === "true", @@ -115,7 +125,7 @@ export function initConfigForm(ctx: PopupCtx) { const configs = await EnvStore.loadConfigs(); configs.push(config); - ctx.showToast(t("toastAdded")); + ctx.showToast(authorized ? t("toastAdded") : t("toastUnauthorized")); await EnvStore.saveConfigs(configs); resetForm(); @@ -127,10 +137,15 @@ export function initConfigForm(ctx: PopupCtx) { // 填入当前标签页域名 fillDomainBtn.addEventListener("click", async () => { const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); - if (!tab || !tab.url) { + if (!tab) { ctx.showToast(t("toastNoTab")); return; } + if (!tab.url) { + // 未授权时读不到地址:提示用户点扩展图标重新授予 activeTab + ctx.showToast(t("toastNoTabUrl")); + return; + } try { const u = new URL(tab.url); const host = u.host; diff --git a/src/popup/config-list.ts b/src/popup/config-list.ts index 6f9906d..312a351 100644 --- a/src/popup/config-list.ts +++ b/src/popup/config-list.ts @@ -58,13 +58,19 @@ export function initConfigList(ctx: PopupCtx) { return; } + // 未授权的域名不会自动填充,列表里标出来并提供"点击授权"的入口 + const { missing } = await Perms.partitionDomains(allConfigs.map((c) => c.domain)); + const unauthorized = new Set(missing); + // 搜索过滤:支持域名、用户名、别名模糊检索 const kw = searchKeyword.trim().toLowerCase(); const configs = kw - ? allConfigs.filter((c) => - (c.domain || "").toLowerCase().includes(kw) || - (c.username || "").toLowerCase().includes(kw) || - (c.alias || "").toLowerCase().includes(kw)) + ? allConfigs.filter( + (c) => + (c.domain || "").toLowerCase().includes(kw) || + (c.username || "").toLowerCase().includes(kw) || + (c.alias || "").toLowerCase().includes(kw), + ) : allConfigs; if (configs.length === 0) { @@ -91,7 +97,9 @@ export function initConfigList(ctx: PopupCtx) { item.addEventListener("dragend", () => { draggedConfigId = null; item.classList.remove("dragging"); - configList.querySelectorAll(".config-item").forEach((el: Element) => el.classList.remove("drag-over")); + configList + .querySelectorAll(".config-item") + .forEach((el: Element) => el.classList.remove("drag-over")); }); item.addEventListener("dragover", (e: DragEvent) => { e.preventDefault(); @@ -120,13 +128,27 @@ export function initConfigList(ctx: PopupCtx) { const info = document.createElement("div"); info.className = "config-info"; const displayName = escapeHtml(cfg.alias || cfg.domain); - const enabledTag = cfg.enabled === false - ? `${t("tagDisabled")}` + const enabledTag = cfg.enabled === false ? `${t("tagDisabled")}` : ""; + const needsAuth = unauthorized.has(cfg.domain); + const authTag = needsAuth + ? `${t("tagUnauthorized")}` : ""; info.innerHTML = ` -
${displayName} ${enabledTag}
+
${displayName} ${enabledTag} ${authTag}
`; + if (needsAuth) { + const tag = info.querySelector(".tag-unauthorized"); + if (tag) { + tag.addEventListener("click", async (e: Event) => { + e.stopPropagation(); // 不要触发"点击整项打开网址" + const ok = await Perms.requestForDomain(cfg.domain); + ctx.showToast(ok ? t("toastAuthorized") : t("toastAuthFail")); + renderConfigList(); + }); + } + } + const actions = document.createElement("div"); actions.className = "config-actions"; const isDisabled = cfg.enabled === false; @@ -135,7 +157,8 @@ export function initConfigList(ctx: PopupCtx) { const editBtn = document.createElement("button"); editBtn.className = "btn-icon"; editBtn.title = t("editBtn"); - editBtn.innerHTML = ''; + editBtn.innerHTML = + ''; editBtn.addEventListener("click", (e) => { e.stopPropagation(); ctx.openEditModal(cfg); @@ -145,7 +168,8 @@ export function initConfigList(ctx: PopupCtx) { const moreBtn = document.createElement("button"); moreBtn.className = "btn-icon"; moreBtn.title = t("moreActionsTitle"); - moreBtn.innerHTML = ''; + moreBtn.innerHTML = + ''; moreBtn.addEventListener("click", (e) => { e.stopPropagation(); const open = menu.classList.contains("open"); @@ -162,9 +186,7 @@ export function initConfigList(ctx: PopupCtx) { toggleItem.addEventListener("click", async (e) => { e.stopPropagation(); closeAllMenus(); - const updated = configs.map((c) => - c.id === cfg.id ? { ...c, enabled: c.enabled === false } : c - ); + const updated = configs.map((c) => (c.id === cfg.id ? { ...c, enabled: c.enabled === false } : c)); await saveConfigs(updated); renderConfigList(); ctx.renderEnvSelect(); diff --git a/src/popup/env-section.ts b/src/popup/env-section.ts index 397901d..00256f3 100644 --- a/src/popup/env-section.ts +++ b/src/popup/env-section.ts @@ -8,13 +8,7 @@ import { openEnvModal } from "./env-modal.js"; import { $ } from "./dom"; import type { PopupCtx } from "./context"; -const { - loadEnvironments, - setActiveEnv, - createEnvironment, - renameEnvironment, - deleteEnvironment, -} = EnvStore; +const { loadEnvironments, setActiveEnv, createEnvironment, renameEnvironment, deleteEnvironment } = EnvStore; const envSelect = $("envSelect"); const envManageBtn = $("envManageBtn"); @@ -72,7 +66,8 @@ export function initEnvSection(ctx: PopupCtx) { const nameSpan = document.createElement("span"); nameSpan.className = "env-manage-name"; const cnt = env.configs?.length || 0; - const activeMark = env.id === activeEnvId ? `${t("envCurrentMark")}` : ""; + const activeMark = + env.id === activeEnvId ? `${t("envCurrentMark")}` : ""; nameSpan.innerHTML = `${escapeHtml(env.name)} (${cnt}) ${activeMark}`; const actions = document.createElement("div"); @@ -81,7 +76,8 @@ export function initEnvSection(ctx: PopupCtx) { const editBtn = document.createElement("button"); editBtn.className = "btn-icon"; editBtn.title = t("renameBtn"); - editBtn.innerHTML = ''; + editBtn.innerHTML = + ''; editBtn.addEventListener("click", async () => { const newName = await openEnvModal("rename", env.name); if (!newName) return; @@ -98,16 +94,16 @@ export function initEnvSection(ctx: PopupCtx) { const delBtn = document.createElement("button"); delBtn.className = "btn-icon"; delBtn.title = t("deleteBtn"); - delBtn.innerHTML = ''; + delBtn.innerHTML = + ''; delBtn.addEventListener("click", async () => { if (environments.length <= 1) { ctx.showToast(t("toastKeepOneEnv")); return; } const cnt2 = env.configs?.length || 0; - const msg = cnt2 > 0 - ? t("confirmDeleteEnvWithCfgs", env.name, String(cnt2)) - : t("confirmDeleteEnv", env.name); + const msg = + cnt2 > 0 ? t("confirmDeleteEnvWithCfgs", env.name, String(cnt2)) : t("confirmDeleteEnv", env.name); if (!confirm(msg)) return; try { await deleteEnvironment(env.id); diff --git a/src/popup/fill-current.ts b/src/popup/fill-current.ts index 8ac5047..66c339e 100644 --- a/src/popup/fill-current.ts +++ b/src/popup/fill-current.ts @@ -2,9 +2,12 @@ * "在当前页面填充"按钮:跨所有环境匹配当前标签页域名, * 按需用 chrome.scripting 注入 content script 后再发起手动填充。 * - * 为什么不再依赖静态注入:manifest 里已移除 content_scripts(原来 无条件 - * 注入所有页面)。点击扩展图标本身会授予 activeTab,因此这条路径无需广域 host 权限, - * 未配置过的站点也不会被执行任何脚本。 + * 两点关键设计: + * 1. manifest 里已移除 content_scripts(原来 无条件注入所有页面), + * 点击扩展图标本身会授予 activeTab,因此这条路径无需广域 host 权限。 + * 2. 登录表单可能嵌在 iframe 里,所以探测与注入都要覆盖所有帧(allFrames), + * 发消息时再定向到"真正有密码框"的那个帧 —— 否则会出现 + * "iframe 里填成功了,却收到别的帧回传的失败诊断"。 */ import { t } from "../i18n.js"; import { $ } from "./dom"; @@ -15,29 +18,52 @@ interface InjectedWindow extends Window { __autoLoginInjected?: boolean; } -/** 页面是否已注入过 content script(重复注入会因顶层 const 重复声明报错) */ -async function isInjected(tabId: number): Promise { - const results = await chrome.scripting.executeScript({ - target: { tabId }, - func: () => Boolean((window as unknown as InjectedWindow).__autoLoginInjected), - }); - return Boolean(results && results[0] && results[0].result); +interface FrameProbe { + frameId?: number; + result?: unknown; } -/** 需要时才注入 utils.js + content.js */ -async function ensureContentScript(tabId: number): Promise { - if (await isInjected(tabId)) return; - await chrome.scripting.executeScript({ - target: { tabId }, - files: ["dist/utils.js", "dist/content.js"], - }); +/** + * 确保所有帧都已注入,并返回"有登录表单"的帧 id(无则 null)。 + * 逐帧注入而不是整体重注:重复执行 content.js 会因顶层 const 重复声明报错。 + */ +async function ensureContentScript(tabId: number): Promise { + const probe = (await chrome.scripting.executeScript({ + target: { tabId, allFrames: true }, + func: () => Boolean((window as unknown as InjectedWindow).__autoLoginInjected), + })) as FrameProbe[]; + + const missing = probe.filter((r) => !r.result).map((r) => (typeof r.frameId === "number" ? r.frameId : 0)); + if (missing.length) { + await chrome.scripting.executeScript({ + target: { tabId, frameIds: missing }, + files: ["dist/utils.js", "dist/content.js"], + }); + } + + const forms = (await chrome.scripting.executeScript({ + target: { tabId, allFrames: true }, + func: () => document.querySelectorAll("input[type=password]").length, + })) as FrameProbe[]; + + return pickFrameWithForm(forms); +} + +/** 需要时向指定帧(或顶层帧兜底)发消息 */ +function sendToTab(tabId: number, message: Record, frameId: number | null): Promise { + return frameId === null + ? chrome.tabs.sendMessage(tabId, message) + : chrome.tabs.sendMessage(tabId, message, { frameId }); } /** content script 无法注入/无法通信时统一给出提示 */ function toastSendError(ctx: PopupCtx, err: unknown): void { const msg = (err as Error)?.message || String(err); - if (/Could not establish connection|Receiving end does not exist|Cannot access|scripted|must request permission|No tab with id/i.test(msg)) { + if (/Could not establish connection|Receiving end does not exist/i.test(msg)) { ctx.showToast(t("toastScriptNotInjected")); + } else if (/Cannot access|must request permission|scripted|No tab with id/i.test(msg)) { + // 当前标签页没有 activeTab / 站点授权:提示用户在该页面点一下扩展图标 + ctx.showToast(t("toastNeedActiveTab")); } else { ctx.showToast(t("toastSendFail", msg)); } @@ -52,6 +78,11 @@ export function initFillCurrent(ctx: PopupCtx) { ctx.showToast(t("toastNoTab")); return; } + if (!tab.url) { + // 该标签页没有 activeTab 授权(例如切换标签页后侧边栏仍开着) + ctx.showToast(t("toastNoTabUrl")); + return; + } // 跨所有环境查找匹配当前标签页的配置 const allConfigs = await EnvStore.loadAllConfigs(); @@ -64,11 +95,8 @@ export function initFillCurrent(ctx: PopupCtx) { if (matched.length === 1) { try { - await ensureContentScript(tab.id); - const resp: any = await chrome.tabs.sendMessage(tab.id, { - action: "manualFill", - config: matched[0], - }); + const frameId = await ensureContentScript(tab.id); + const resp: any = await sendToTab(tab.id, { action: "manualFill", config: matched[0] }, frameId); if (resp && (resp as any).success) { ctx.showToast(t("toastFilled")); } else if (resp && resp.diag) { @@ -84,12 +112,8 @@ export function initFillCurrent(ctx: PopupCtx) { } try { - await ensureContentScript(tab.id); - await chrome.tabs.sendMessage(tab.id, { - action: "manualFillMulti", - configs: matched, - tabUrl: tab.url, - }); + const frameId = await ensureContentScript(tab.id); + await sendToTab(tab.id, { action: "manualFillMulti", configs: matched, tabUrl: tab.url }, frameId); ctx.showToast(t("toastMultiMatch", String(matched.length))); } catch (err) { toastSendError(ctx, err); diff --git a/src/popup/main.ts b/src/popup/main.ts index 10cf998..df2162e 100644 --- a/src/popup/main.ts +++ b/src/popup/main.ts @@ -2,7 +2,7 @@ * popup 入口:组装各模块并建立共享上下文(PopupCtx)。 * 模块间不互相 import,统一通过 ctx 互调,避免循环依赖。 */ -import { t, applyI18n } from "../i18n.js"; +import { applyI18n } from "../i18n.js"; import type { DomainConfig } from "../types.js"; import type { PopupCtx } from "./context"; import { showToast } from "./toast"; @@ -16,6 +16,7 @@ import { initBookmarkImport } from "./bookmark-import"; import { initFillCurrent } from "./fill-current"; import { initLock } from "./lock"; +initDebugLog(); applyI18n(); const versionLabel = document.getElementById("versionLabel"); @@ -23,31 +24,31 @@ if (versionLabel) versionLabel.textContent = "v" + chrome.runtime.getManifest(). initTogglePwd(); -// 各模块初始化后返回的 API -let formApi: ReturnType; -let envApi: ReturnType; -let listApi: ReturnType; -let editApi: ReturnType; +// 各模块初始化后返回的 API。ctx 的方法是事件触发时才读取,因此可以先建 ctx、再回填这里。 +const api: { + form?: ReturnType; + env?: ReturnType; + list?: ReturnType; + edit?: ReturnType; +} = {}; -// 共享上下文:方法体内引用上面的 API,实际调用发生在初始化完成之后 const ctx: PopupCtx = { showToast, - resetForm: () => formApi.resetForm(), - saveDraft: () => formApi.saveDraft(), - loadDraft: () => formApi.loadDraft(), - renderEnvSelect: () => envApi.renderEnvSelect(), - renderConfigList: () => listApi.renderConfigList(), - refreshEnvManage: () => envApi.refreshEnvManage(), - openEditModal: (cfg: DomainConfig | null) => editApi.openEditModal(cfg), + resetForm: () => api.form!.resetForm(), + saveDraft: () => api.form!.saveDraft(), + loadDraft: () => api.form!.loadDraft(), + renderEnvSelect: () => api.env!.renderEnvSelect(), + renderConfigList: () => api.list!.renderConfigList(), + refreshEnvManage: () => api.env!.refreshEnvManage(), + openEditModal: (cfg: DomainConfig | null) => api.edit!.openEditModal(cfg), }; -formApi = initConfigForm(ctx); -editApi = initEditConfigModal(ctx); -envApi = initEnvSection(ctx); -listApi = initConfigList(ctx); +api.form = initConfigForm(ctx); +api.edit = initEditConfigModal(ctx); +api.env = initEnvSection(ctx); +api.list = initConfigList(ctx); initTransfer(ctx); initBookmarkImport(ctx); initFillCurrent(ctx); -const lockApi = initLock(ctx); -lockApi.initLockState(); +initLock(ctx).initLockState(); diff --git a/src/popup/pwd-modal.ts b/src/popup/pwd-modal.ts index 52efb74..7227160 100644 --- a/src/popup/pwd-modal.ts +++ b/src/popup/pwd-modal.ts @@ -29,11 +29,11 @@ function updateStrength(pwd: string): void { const { entropy, seconds, level, label } = estimateStrength(pwd); // 5 档进度条宽度,对数缩放避免弱口令条太短看不见 const widthMap = { - "empty": 0, + empty: 0, "very-weak": 15, - "weak": 30, - "medium": 55, - "strong": 80, + weak: 30, + medium: 55, + strong: 80, "very-strong": 100, }; strengthBar.style.width = widthMap[level] + "%"; diff --git a/src/popup/transfer.ts b/src/popup/transfer.ts index e7f4a60..ddbd0f7 100644 --- a/src/popup/transfer.ts +++ b/src/popup/transfer.ts @@ -62,7 +62,14 @@ export function initTransfer(ctx: PopupCtx) { a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); - ctx.showToast(t("toastExported", String(environments.length), String(totalConfigs), (choice as any).encrypt ? t("encryptedMark") : "")); + ctx.showToast( + t( + "toastExported", + String(environments.length), + String(totalConfigs), + (choice as any).encrypt ? t("encryptedMark") : "", + ), + ); }); importBtn.addEventListener("click", () => importFile.click()); @@ -136,12 +143,12 @@ export function initTransfer(ctx: PopupCtx) { let skippedCfgs = 0; for (const env of incomingEnvironments) { if (!env || !env.name) { - skippedCfgs += (env?.configs?.length) || 0; + skippedCfgs += env?.configs?.length || 0; continue; } if (existingNames.has(env.name)) { // 同名环境:跳过整个环境 - skippedCfgs += (env.configs?.length) || 0; + skippedCfgs += env.configs?.length || 0; continue; } existingNames.add(env.name); @@ -150,7 +157,7 @@ export function initTransfer(ctx: PopupCtx) { name: env.name, configs: [], }; - for (const cfg of (env.configs || [])) { + for (const cfg of env.configs || []) { if (!cfg || !cfg.domain || !cfg.username || !cfg.password) { skippedCfgs++; continue; diff --git a/src/strength.ts b/src/strength.ts index 49b6a34..fa470b9 100644 --- a/src/strength.ts +++ b/src/strength.ts @@ -16,11 +16,22 @@ export function estimateStrength(pwd: string): StrengthResult { const seconds = combinations / 2 / ATTACK_RATE; let level: StrengthResult["level"]; let label: string; - if (seconds < 1) { level = "very-weak"; label = t("strengthVeryWeak"); } - else if (seconds < 3600) { level = "weak"; label = t("strengthWeak"); } - else if (seconds < 86400 * 30) { level = "medium"; label = t("strengthMedium"); } - else if (seconds < 86400 * 365 * 1000) { level = "strong"; label = t("strengthStrong"); } - else { level = "very-strong"; label = t("strengthVeryStrong"); } + if (seconds < 1) { + level = "very-weak"; + label = t("strengthVeryWeak"); + } else if (seconds < 3600) { + level = "weak"; + label = t("strengthWeak"); + } else if (seconds < 86400 * 30) { + level = "medium"; + label = t("strengthMedium"); + } else if (seconds < 86400 * 365 * 1000) { + level = "strong"; + label = t("strengthStrong"); + } else { + level = "very-strong"; + label = t("strengthVeryStrong"); + } return { entropy, seconds, level, label }; } @@ -37,4 +48,4 @@ export function formatTime(seconds: number): string { if (years < 1e9) return t("crackMillionYears", String(Math.round(years / 1e6))); if (years < 1e12) return t("crackBillionYears", String(Math.round(years / 1e9))); return t("crackTrillionYears", (years / 1e12).toExponential(2)); -} \ No newline at end of file +} diff --git a/src/types.d.ts b/src/types.d.ts index acb3fff..4168b08 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -53,4 +53,4 @@ export type PwdModalMode = "export" | "import"; export interface PwdModalResult { encrypt: boolean; password: string; -} \ No newline at end of file +} diff --git a/src/ui-utils.ts b/src/ui-utils.ts index 0d66795..28b94c9 100644 --- a/src/ui-utils.ts +++ b/src/ui-utils.ts @@ -4,6 +4,12 @@ export function openConfigUrl(domain: string): void { window.open(url, "_blank", "noopener"); } +/** + * 在已打开的标签页里找一个匹配该域名的页面,借用它的标题作为别名。 + * + * 注意:未声明 `tabs` 权限时,`chrome.tabs.query({url})` 的 url 过滤会被 Chrome **忽略** + * 而不是报错,因此必须自己再校验一次 tab.url 是否真的匹配,否则会拿到无关页面的标题。 + */ export async function fetchTitleByDomain(domain: string): Promise { try { const base = /^https?:\/\//i.test(domain) ? domain : "*://" + domain; @@ -11,12 +17,24 @@ export async function fetchTitleByDomain(domain: string): Promise if (!base.endsWith("*")) patterns.push(base + "/*"); const tabs = await chrome.tabs.query({ url: patterns }); for (const tab of tabs) { - if (tab.title) return tab.title; + if (tab.url && tab.title && isDomainMatch(tab.url, domain)) return tab.title; } } catch (e) {} return null; } +/** + * 别名兜底:优先用当前标签页标题(点击扩展图标即授予 activeTab,可直接读), + * 其次在已授权域名里找已打开的标签页。 + */ +export async function resolveAlias(domain: string): Promise { + try { + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + if (tab && tab.url && tab.title && isDomainMatch(tab.url, domain)) return tab.title; + } catch (e) {} + return fetchTitleByDomain(domain); +} + export function closeAllMenus() { document.querySelectorAll(".config-menu.open").forEach((m) => m.classList.remove("open")); -} \ No newline at end of file +} diff --git a/src/utils.ts b/src/utils.ts index ff790cb..cb470ff 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -7,6 +7,10 @@ root.isDomainMatch = api.isDomainMatch; root.escapeHtml = api.escapeHtml; root.toMatchPatterns = api.toMatchPatterns; + root.initDebugLog = api.initDebugLog; + root.isDebugLogEnabled = api.isDebugLogEnabled; + root.debugLog = api.debugLog; + root.pickFrameWithForm = api.pickFrameWithForm; } })(typeof globalThis !== "undefined" ? globalThis : this, function () { "use strict"; @@ -21,7 +25,9 @@ try { const u = new URL(url); - const rawPattern = String(pattern).trim().replace(/^https?:\/\//i, ""); + const rawPattern = String(pattern) + .trim() + .replace(/^https?:\/\//i, ""); if (rawPattern.includes("/")) { const slashIdx = rawPattern.indexOf("/"); @@ -118,5 +124,57 @@ return ["http://" + host + path, "https://" + host + path]; } - return { isDomainMatch, escapeHtml, toMatchPatterns }; + // --------------------------------------------------------------------------- + // 调试日志 + // 默认静默:信息类日志只在用户显式打开开关后才输出,避免刷满网页控制台与 + // Service Worker 控制台。打开方式(在扩展的 SW 控制台执行一次): + // chrome.storage.local.set({ debugLog: true }) + // --------------------------------------------------------------------------- + let debugEnabled = false; + + /** 读取调试开关,各入口(background / content / popup)启动时调用一次 */ + async function initDebugLog(): Promise { + try { + const data = await chrome.storage.local.get("debugLog"); + debugEnabled = data.debugLog === true; + if (debugEnabled) { + console.log("[AutoLogin] 调试日志已开启(chrome.storage.local.debugLog = true)"); + } + } catch { + debugEnabled = false; + } + return debugEnabled; + } + + function isDebugLogEnabled(): boolean { + return debugEnabled; + } + + /** 信息类日志:默认不输出 */ + function debugLog(...args: unknown[]): void { + if (debugEnabled) console.log("[AutoLogin]", ...args); + } + + /** + * 从 chrome.scripting.executeScript({ allFrames: true }) 的多帧结果里, + * 挑出真正含有目标元素(如密码框)的帧,用于后续定向发消息。 + * 优先顶层帧(frameId 0),否则取 frameId 最小的一个;都没有则返回 null。 + */ + function pickFrameWithForm(results: Array<{ frameId?: number; result?: unknown }>): number | null { + const hits = (results || []) + .filter((r) => Number(r && r.result) > 0) + .map((r) => (typeof r.frameId === "number" ? r.frameId : 0)) + .sort((a, b) => a - b); + return hits.length ? hits[0] : null; + } + + return { + isDomainMatch, + escapeHtml, + toMatchPatterns, + initDebugLog, + isDebugLogEnabled, + debugLog, + pickFrameWithForm, + }; }); diff --git a/tests/content-fill.test.js b/tests/content-fill.test.js index de60bc7..56c3177 100644 --- a/tests/content-fill.test.js +++ b/tests/content-fill.test.js @@ -20,23 +20,31 @@ const tick = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); // autoSubmit 的最小延迟是 300ms(content.js 里 Math.max(300, delayMs)) const AFTER_SUBMIT = 400; -function createChromeMock({ configs = [], attempts = {} } = {}) { +function createChromeMock({ configs = [], attempts = {}, activeEnvId = null } = {}) { const state = { configs, + activeEnvId, attempts: new Map(Object.entries(attempts)), calls: [], messageListener: null, + // 每个帧注入 content.js 都会注册一个监听器;allFrames 场景下用最后一个(最内层帧) + messageListeners: [], }; const api = { runtime: { id: "mock-extension-id", - onMessage: { addListener: (fn) => { state.messageListener = fn; } }, + onMessage: { + addListener: (fn) => { + state.messageListener = fn; + state.messageListeners.push(fn); + }, + }, sendMessage: (message) => { state.calls.push(message.action); switch (message.action) { case "loadAllConfigs": - return Promise.resolve({ configs: state.configs }); + return Promise.resolve({ configs: state.configs, activeEnvId: state.activeEnvId }); case "getVaultStatus": return Promise.resolve({ hasVault: false, unlocked: true, pendingImport: false }); case "getDomainAttempts": { @@ -68,28 +76,64 @@ function createChromeMock({ configs = [], attempts = {} } = {}) { return { api, state }; } -/** 在 jsdom 页面里执行 utils.js + content.js(与浏览器一致:按普通脚本顺序执行) */ -function loadContentPage({ html = "", url = "https://example.com/login", configs = [], attempts = {} } = {}) { +/** + * jsdom 没有布局引擎(offsetParent 恒为 null、offsetWidth 恒为 0), + * 而 content.js 会用它们判断元素是否可见。这里按 realm 逐个打补丁: + * 主窗口与每个 iframe 都各有自己的 HTMLElement.prototype,必须分别处理。 + */ +function patchLayout(win) { + Object.defineProperty(win.HTMLElement.prototype, "offsetWidth", { + get() { + return 100; + }, + configurable: true, + }); + Object.defineProperty(win.HTMLElement.prototype, "offsetHeight", { + get() { + return 20; + }, + configurable: true, + }); + Object.defineProperty(win.HTMLElement.prototype, "offsetParent", { + get() { + return this.parentElement || this.ownerDocument.body; + }, + configurable: true, + }); + // jsdom 未实现 CSS.escape(真实浏览器有) + if (!win.CSS) win.CSS = {}; + if (!win.CSS.escape) + win.CSS.escape = (s) => String(s).replace(/[^a-zA-Z0-9_\u00a0-\uffff-]/g, (c) => "\\" + c); +} + +/** + * 在 jsdom 页面里执行 utils.js + content.js(与浏览器一致:按普通脚本顺序执行)。 + * setup 回调在注入脚本之前执行,用于准备"脚本加载前就必须存在"的结构(例如 Shadow Root)。 + */ +function loadContentPage({ + html = "", + url = "https://example.com/login", + configs = [], + attempts = {}, + activeEnvId = null, + timing, + setup, +} = {}) { const dom = new JSDOM(`${html}`, { url, runScripts: "dangerously", }); const win = dom.window; - // 让所有元素默认"可见"(jsdom 无布局引擎) - Object.defineProperty(win.HTMLElement.prototype, "offsetWidth", { get() { return 100; }, configurable: true }); - Object.defineProperty(win.HTMLElement.prototype, "offsetHeight", { get() { return 20; }, configurable: true }); - Object.defineProperty(win.HTMLElement.prototype, "offsetParent", { - get() { return this.parentElement || this.ownerDocument.body; }, - configurable: true, - }); - // jsdom 未实现 CSS.escape(真实浏览器有) - if (!win.CSS) win.CSS = {}; - if (!win.CSS.escape) win.CSS.escape = (s) => String(s).replace(/[^a-zA-Z0-9_\u00a0-\uffff-]/g, (c) => "\\" + c); + patchLayout(win); + // 缩短时间常量(30 秒超时 / 4 秒结果观察),否则这些用例要真等半分钟 + if (timing) win.__autoLoginTiming = timing; - const chrome = createChromeMock({ configs, attempts }); + const chrome = createChromeMock({ configs, attempts, activeEnvId }); win.chrome = chrome.api; + if (setup) setup(win, win.document); + const inject = (code) => { const el = win.document.createElement("script"); el.textContent = code; @@ -139,7 +183,10 @@ describe("content - 字段探测", () => { assert.equal(ctx.win.findPasswordField({}).getAttribute("name"), "pass"); assert.equal(ctx.win.findUsernameField({}).getAttribute("name"), "username"); - assert.equal(ctx.win.findSubmitButton({}, ctx.doc.querySelector("input[type=password]")).textContent, "登录"); + assert.equal( + ctx.win.findSubmitButton({}, ctx.doc.querySelector("input[type=password]")).textContent, + "登录", + ); }); it("显式选择器优先于自动探测", () => { @@ -279,7 +326,12 @@ describe("content - 填充与提交", () => { }); let submitted = 0; const form = ctx.doc.querySelector("form"); - Object.defineProperty(form, "requestSubmit", { value: () => { submitted++; }, configurable: true }); + Object.defineProperty(form, "requestSubmit", { + value: () => { + submitted++; + }, + configurable: true, + }); ctx.win.fillForm({ username: "u", password: "p", autoSubmit: true }, true); await tick(AFTER_SUBMIT); @@ -295,7 +347,11 @@ describe("content - 自动填充主流程", () => { `; it("域名命中配置时自动填充", async () => { - const ctx = loadContentPage({ html: LOGIN_HTML, url: "https://example.com/login", configs: [makeConfig()] }); + const ctx = loadContentPage({ + html: LOGIN_HTML, + url: "https://example.com/login", + configs: [makeConfig()], + }); await tick(60); assert.equal(ctx.doc.getElementById("u").value, "auto-user"); @@ -303,7 +359,11 @@ describe("content - 自动填充主流程", () => { }); it("域名不匹配时什么都不做", async () => { - const ctx = loadContentPage({ html: LOGIN_HTML, url: "https://other.com/login", configs: [makeConfig()] }); + const ctx = loadContentPage({ + html: LOGIN_HTML, + url: "https://other.com/login", + configs: [makeConfig()], + }); await tick(60); assert.equal(ctx.doc.getElementById("u").value, ""); @@ -327,7 +387,7 @@ describe("content - 自动填充主流程", () => { assert.ok(ctx.chrome.state.calls.includes("incrementDomainAttempts")); }); - it("失败次数达到上限后只填充、不自动提交", async () => { + it("达到上限后只填充、不自动提交,并给出可见提示", async () => { const ctx = loadContentPage({ html: LOGIN_HTML, configs: [makeConfig({ autoSubmit: true })], @@ -340,6 +400,80 @@ describe("content - 自动填充主流程", () => { assert.equal(ctx.doc.getElementById("u").value, "auto-user", "仍然填充"); assert.equal(clicks, 0, "不应自动提交"); assert.ok(!ctx.chrome.state.calls.includes("incrementDomainAttempts")); + // 用户必须能看懂"为什么只填不提交",否则会以为扩展坏了 + assert.ok(ctx.doc.body.innerHTML.includes("attemptToastTitle"), "应显示暂停提示浮窗"); + }); + + it("命中配置但页面上没有登录框时不消耗计数(浏览站内页面不会被误扣)", async () => { + const ctx = loadContentPage({ + html: `
登录成功后的站内页面
`, + configs: [makeConfig({ autoSubmit: true })], + timing: { fillTimeoutMs: 120 }, + }); + await tick(500); + + assert.ok(!ctx.chrome.state.calls.includes("incrementDomainAttempts"), "没有提交就不应计数"); + assert.ok(ctx.chrome.state.calls.includes("resetDomainAttempts"), "始终没有登录框 → 清掉旧计数"); + }); + + it("只有真正触发提交之后才累加计数", async () => { + const ctx = loadContentPage({ html: LOGIN_HTML, configs: [makeConfig({ autoSubmit: true })] }); + ctx.doc.getElementById("btn").addEventListener("click", () => {}); + + await tick(150); + assert.ok(!ctx.chrome.state.calls.includes("incrementDomainAttempts"), "提交前不应计数"); + + await tick(300); // 提交延迟 300ms + assert.ok(ctx.chrome.state.calls.includes("incrementDomainAttempts"), "提交后才计数"); + }); + + it("提交后登录框消失视为登录成功并重置计数(SPA 场景)", async () => { + const ctx = loadContentPage({ + html: LOGIN_HTML, + configs: [makeConfig({ autoSubmit: true })], + timing: { submitResultCheckMs: 120 }, + }); + ctx.doc.getElementById("btn").addEventListener("click", () => {}); + + await tick(330); // 300ms 时已提交 + assert.ok(ctx.chrome.state.calls.includes("incrementDomainAttempts"), "提交后先计数"); + + // 模拟 SPA 登录成功:登录表单从页面上消失 + ctx.doc.querySelector("form").remove(); + + await tick(160); + assert.ok(ctx.chrome.state.calls.includes("resetDomainAttempts"), "登录成功应重置计数"); + }); + + it("提交后登录框仍在(登录失败)不重置计数", async () => { + const ctx = loadContentPage({ + html: LOGIN_HTML, + configs: [makeConfig({ autoSubmit: true })], + timing: { submitResultCheckMs: 120 }, + }); + ctx.doc.getElementById("btn").addEventListener("click", () => {}); + + await tick(560); // 覆盖 300ms 提交 + 120ms 结果观察 + assert.ok(ctx.chrome.state.calls.includes("incrementDomainAttempts")); + assert.ok(!ctx.chrome.state.calls.includes("resetDomainAttempts"), "登录框仍在,视为失败,不应重置"); + }); + + it("被禁用的配置不再阻止计数重置", async () => { + const ctx = loadContentPage({ + url: "https://example.com/login/page", + html: `
`, + configs: [ + makeConfig({ id: "a", domain: "example.com/login/*" }), + makeConfig({ id: "b", domain: "example.com", enabled: false }), + ], + }); + + // 从 /login/page 跳到 /dashboard:只有被禁用的那条还匹配旧地址 + ctx.dom.reconfigure({ url: "https://example.com/dashboard" }); + ctx.doc.getElementById("app").textContent = "route changed"; // 触发 MutationObserver + await tick(80); + + assert.ok(ctx.chrome.state.calls.includes("resetDomainAttempts"), "启用中的配置离开匹配范围应重置"); }); it("失败计数超过冷却时间后重新计数并允许提交", async () => { @@ -416,9 +550,115 @@ describe("content - SPA 延迟渲染", () => { `; - await tick(700); // 重试间隔 500ms + await tick(700); assert.equal(ctx.doc.getElementById("u").value, "auto-user"); }); + + it("Shadow DOM 内部后渲染的登录框也能填充(light DOM 完全没变)", async () => { + let shadow; + loadContentPage({ + html: `
`, + configs: [makeConfig()], + setup: (win, doc) => { + shadow = doc.getElementById("host").attachShadow({ mode: "open" }); + }, + }); + + // 首轮扫描:发现空的 shadow root 并开始监听它 + await tick(80); + assert.equal(shadow.querySelectorAll("input").length, 0); + + // 只改 shadow 内部,body 上的 MutationObserver 感知不到这种变化 + shadow.innerHTML = `
+ + +
`; + + await tick(700); + assert.equal(shadow.getElementById("u").value, "auto-user"); + }); +}); + +describe("content - iframe 内的登录表单(allFrames)", () => { + const FORM_HTML = `
+ + +
`; + + /** 在页面里创建同源 iframe,并按 allFrames 的方式往帧内注入脚本 */ + function injectIntoFrame(ctx, html) { + const iframe = ctx.doc.createElement("iframe"); + ctx.doc.body.appendChild(iframe); + const iwin = iframe.contentWindow; + const idoc = iframe.contentDocument; + idoc.body.innerHTML = html; + patchLayout(iwin); // iframe 是独立的 realm,需要单独打布局补丁 + iwin.chrome = ctx.chrome.api; + for (const code of [UTILS_CODE, CONTENT_CODE]) { + const el = idoc.createElement("script"); + el.textContent = code; + idoc.head.appendChild(el); + } + return { iwin, idoc }; + } + + it("iframe 内的表单能被填充(按 iframe 自己的地址匹配)", async () => { + // iframe 在 jsdom 里是 about:blank,用通配规则让它命中;顶层帧没有表单所以不会误填 + const ctx = loadContentPage({ html: `
`, configs: [makeConfig({ domain: "*" })] }); + const { idoc } = injectIntoFrame(ctx, FORM_HTML); + + await tick(150); + assert.equal(idoc.getElementById("u").value, "auto-user"); + assert.equal(idoc.getElementById("p").value, "auto-pass"); + }); + + it("子帧命中多个环境时不弹浮窗,按当前激活环境填充(顶层帧照旧弹窗)", async () => { + const ctx = loadContentPage({ + html: `
`, + configs: [ + makeConfig({ id: "c1", envId: "e1", envName: "个人", domain: "*", username: "personal@x.com" }), + makeConfig({ id: "c2", envId: "e2", envName: "生产", domain: "*", username: "prod@x.com" }), + ], + activeEnvId: "e2", + }); + const { idoc } = injectIntoFrame(ctx, FORM_HTML); + + await tick(150); + assert.equal(idoc.getElementById("u").value, "prod@x.com", "子帧应按激活环境选用凭据"); + assert.equal(idoc.getElementById("auto-login-env-picker"), null, "子帧不弹浮窗"); + assert.ok(ctx.doc.getElementById("auto-login-env-picker"), "顶层帧仍然弹浮窗"); + }); + + it("子帧收到 manualFillMulti 时同样按激活环境填充", async () => { + const ctx = loadContentPage({ + html: `
`, + configs: [ + makeConfig({ id: "c1", envId: "e1", domain: "*", username: "personal@x.com" }), + makeConfig({ id: "c2", envId: "e2", domain: "*", username: "prod@x.com" }), + ], + activeEnvId: "e2", + }); + const { idoc } = injectIntoFrame(ctx, FORM_HTML); + // 最后注册的监听器来自最内层帧 + const listeners = ctx.chrome.state.messageListeners; + const frameListener = listeners[listeners.length - 1]; + + frameListener({ action: "manualFillMulti", configs: [...ctx.chrome.state.configs] }, {}, () => {}); + await tick(150); + + assert.equal(idoc.getElementById("u").value, "prod@x.com"); + assert.equal(idoc.getElementById("auto-login-env-picker"), null); + }); + + it("pickConfigForFrame:优先当前激活环境,其次第一条", () => { + const ctx = loadContentPage({ html: "" }); + const a = makeConfig({ id: "a", envId: "e1" }); + const b = makeConfig({ id: "b", envId: "e2" }); + + assert.equal(ctx.win.pickConfigForFrame([a, b], "e2").id, "b"); + assert.equal(ctx.win.pickConfigForFrame([a, b], "e9").id, "a"); + assert.equal(ctx.win.pickConfigForFrame([a, b], null).id, "a"); + }); }); describe("content - 手动填充消息", () => { @@ -434,7 +674,9 @@ describe("content - 手动填充消息", () => { ctx.chrome.state.messageListener( { action: "manualFill", config: makeConfig({ username: "manual@x.com" }) }, {}, - (r) => { response = r; } + (r) => { + response = r; + }, ); await tick(30); @@ -447,11 +689,9 @@ describe("content - 手动填充消息", () => { const ctx = loadContentPage({ html: `
没有表单
` }); let response = null; - ctx.chrome.state.messageListener( - { action: "manualFill", config: makeConfig() }, - {}, - (r) => { response = r; } - ); + ctx.chrome.state.messageListener({ action: "manualFill", config: makeConfig() }, {}, (r) => { + response = r; + }); await tick(30); assert.equal(response.success, false); @@ -464,9 +704,14 @@ describe("content - 手动填充消息", () => { let response = null; ctx.chrome.state.messageListener( - { action: "manualFillMulti", configs: [makeConfig({ id: "c1" }), makeConfig({ id: "c2", envId: "e2", envName: "生产" })] }, + { + action: "manualFillMulti", + configs: [makeConfig({ id: "c1" }), makeConfig({ id: "c2", envId: "e2", envName: "生产" })], + }, {}, - (r) => { response = r; } + (r) => { + response = r; + }, ); await tick(30); diff --git a/tests/crypto-store.test.js b/tests/crypto-store.test.js index 14f8fef..f006c5c 100644 --- a/tests/crypto-store.test.js +++ b/tests/crypto-store.test.js @@ -11,12 +11,18 @@ function createMemoryStorage() { const makeArea = (store) => ({ async get(keys) { const result = {}; - if (Array.isArray(keys)) keys.forEach((k) => { if (k in store) result[k] = store[k]; }); - else if (typeof keys === "string") { if (keys in store) result[keys] = store[keys]; } - else Object.assign(result, store); + if (Array.isArray(keys)) + keys.forEach((k) => { + if (k in store) result[k] = store[k]; + }); + else if (typeof keys === "string") { + if (keys in store) result[keys] = store[keys]; + } else Object.assign(result, store); return result; }, - async set(items) { Object.assign(store, items); }, + async set(items) { + Object.assign(store, items); + }, async remove(keys) { if (Array.isArray(keys)) keys.forEach((k) => delete store[k]); else delete store[keys]; diff --git a/tests/env-store.test.js b/tests/env-store.test.js index 62390c3..2ff5ef1 100644 --- a/tests/env-store.test.js +++ b/tests/env-store.test.js @@ -13,7 +13,9 @@ function createMemoryStorage() { async get(keys) { const result = {}; if (Array.isArray(keys)) { - keys.forEach((k) => { if (k in store) result[k] = store[k]; }); + keys.forEach((k) => { + if (k in store) result[k] = store[k]; + }); } else if (typeof keys === "string") { if (keys in store) result[keys] = store[keys]; } else { @@ -21,12 +23,16 @@ function createMemoryStorage() { } return result; }, - async set(items) { Object.assign(store, items); }, + async set(items) { + Object.assign(store, items); + }, async remove(keys) { if (Array.isArray(keys)) keys.forEach((k) => delete store[k]); else delete store[keys]; }, - _dump() { return store; }, + _dump() { + return store; + }, }); return { local: makeArea(local), session: makeArea(session), localStore: local, sessionStore: session }; } @@ -73,7 +79,7 @@ describe("env-store - 环境 CRUD", () => { { id: "e2", name: "测试", configs: [] }, { id: "e3", name: "生产", configs: [] }, ], - "e1" + "e1", ); }); @@ -143,7 +149,7 @@ describe("env-store - 配置 CRUD", () => { { id: "e1", name: "个人", configs: [] }, { id: "e2", name: "测试", configs: [] }, ], - "e1" + "e1", ); }); diff --git a/tests/key-store.test.js b/tests/key-store.test.js index 7b7206c..20b9469 100644 --- a/tests/key-store.test.js +++ b/tests/key-store.test.js @@ -25,7 +25,7 @@ function createFakeIndexedDB() { return req; } - function makeDb(name) { + function makeDb() { const stores = new Map(); return { objectStoreNames: { contains: (n) => stores.has(n) }, @@ -37,9 +37,17 @@ function createFakeIndexedDB() { const data = stores.get(storeName); const tx = { error: null, onabort: null }; tx.objectStore = () => ({ - put: (value, key) => makeRequest(() => { data.set(key, value); return key; }), + put: (value, key) => + makeRequest(() => { + data.set(key, value); + return key; + }), get: (key) => makeRequest(() => data.get(key)), - delete: (key) => makeRequest(() => { data.delete(key); return undefined; }), + delete: (key) => + makeRequest(() => { + data.delete(key); + return undefined; + }), }); return tx; }, @@ -49,14 +57,18 @@ function createFakeIndexedDB() { return { open(name) { const req = { - result: undefined, error: null, - onsuccess: null, onerror: null, onupgradeneeded: null, onblocked: null, + result: undefined, + error: null, + onsuccess: null, + onerror: null, + onupgradeneeded: null, + onblocked: null, }; queueMicrotask(() => { let db = databases.get(name); const isNew = !db; if (isNew) { - db = makeDb(name); + db = makeDb(); databases.set(name, db); } req.result = db; @@ -112,7 +124,7 @@ describe("key-store - 密钥句柄存取", () => { const ciphertext = await crypto.subtle.encrypt( { name: "AES-GCM", iv }, key, - new TextEncoder().encode("secret") + new TextEncoder().encode("secret"), ); await K.saveKey(key); diff --git a/tests/permissions.test.js b/tests/permissions.test.js new file mode 100644 index 0000000..532454c --- /dev/null +++ b/tests/permissions.test.js @@ -0,0 +1,149 @@ +// tests/permissions.test.js +// 测试 permissions.js:可选主机权限的授权状态判断与申请逻辑 +// 运行:node --test tests/permissions.test.js + +const { describe, it, beforeEach } = require("node:test"); +const assert = require("node:assert/strict"); + +const { toMatchPatterns } = require("../dist/utils.js"); + +/** 构造一个假的 chrome.permissions,granted 集合可观察 */ +function createFakePermissions(granted = []) { + const state = { + granted: new Set(granted), + requestCalls: [], + containsCalls: 0, + throwOnContains: false, + }; + return { + state, + api: { + contains: async ({ origins }) => { + state.containsCalls++; + if (state.throwOnContains) throw new Error("boom"); + return origins.every((o) => state.granted.has(o)); + }, + request: async ({ origins }) => { + state.requestCalls.push(origins); + origins.forEach((o) => state.granted.add(o)); + return true; + }, + }, + }; +} + +function loadPerms(permissionsApi) { + global.toMatchPatterns = toMatchPatterns; // permissions.js 会延迟读取该全局函数 + global.chrome = permissionsApi === null ? {} : { permissions: permissionsApi }; + delete require.cache[require.resolve("../dist/permissions.js")]; + return require("../dist/permissions.js"); +} + +describe("permissions - patternsForDomain", () => { + beforeEach(() => { + global.toMatchPatterns = toMatchPatterns; + }); + + it("返回 http / https 两条模式", () => { + const P = loadPerms(createFakePermissions().api); + assert.deepEqual(P.patternsForDomain("example.com"), ["http://example.com/*", "https://example.com/*"]); + }); + + it("通配符域名同样可转成可申请的 origin", () => { + const P = loadPerms(createFakePermissions().api); + assert.deepEqual(P.patternsForDomain("*.example.com"), [ + "http://*.example.com/*", + "https://*.example.com/*", + ]); + }); + + it("无法表达的规则返回空数组", () => { + const P = loadPerms(createFakePermissions().api); + assert.deepEqual(P.patternsForDomain("*"), []); + assert.deepEqual(P.patternsForDomain(""), []); + }); +}); + +describe("permissions - partitionPatterns", () => { + it("按已授权拆分成 granted / missing", async () => { + const fake = createFakePermissions(["https://a.com/*"]); + const P = loadPerms(fake.api); + + const result = await P.partitionPatterns(["https://a.com/*", "http://b.com/*", "https://b.com/*"]); + + assert.deepEqual(result.granted, ["https://a.com/*"]); + assert.deepEqual(result.missing, ["http://b.com/*", "https://b.com/*"]); + }); + + it("contains 抛错时按未授权处理", async () => { + const fake = createFakePermissions(["https://a.com/*"]); + fake.state.throwOnContains = true; + const P = loadPerms(fake.api); + + const result = await P.partitionPatterns(["https://a.com/*"]); + assert.deepEqual(result.granted, []); + assert.deepEqual(result.missing, ["https://a.com/*"]); + }); +}); + +describe("permissions - partitionDomains", () => { + it("任一 scheme 已授权即算该域名已授权", async () => { + const fake = createFakePermissions(["https://a.com/*"]); + const P = loadPerms(fake.api); + + const result = await P.partitionDomains(["a.com", "b.com"]); + assert.deepEqual(result.granted, ["a.com"]); + assert.deepEqual(result.missing, ["b.com"]); + }); + + it("完全未授权的域名进入 missing", async () => { + const P = loadPerms(createFakePermissions().api); + const result = await P.partitionDomains(["a.com"]); + assert.deepEqual(result.missing, ["a.com"]); + }); + + it("无法转成模式的域名视为未授权", async () => { + const P = loadPerms(createFakePermissions().api); + const result = await P.partitionDomains(["*"]); + assert.deepEqual(result.missing, ["*"]); + }); +}); + +describe("permissions - requestForDomain", () => { + it("已授权时直接返回 true 且不重复申请", async () => { + const fake = createFakePermissions(["http://a.com/*", "https://a.com/*"]); + const P = loadPerms(fake.api); + + assert.equal(await P.requestForDomain("a.com"), true); + assert.equal(fake.state.requestCalls.length, 0, "已授权不应再次弹框"); + }); + + it("未授权时申请全部模式", async () => { + const fake = createFakePermissions(); + const P = loadPerms(fake.api); + + assert.equal(await P.requestForDomain("a.com"), true); + assert.deepEqual(fake.state.requestCalls, [["http://a.com/*", "https://a.com/*"]]); + }); + + it("无法转换的域名不会发起申请", async () => { + const fake = createFakePermissions(); + const P = loadPerms(fake.api); + + assert.equal(await P.requestForDomain("*"), false); + assert.equal(fake.state.requestCalls.length, 0); + }); + + it("环境没有 chrome.permissions 时返回 false", async () => { + const P = loadPerms(null); + assert.equal(await P.requestForDomain("a.com"), false); + }); + + it("申请被拒绝时返回 false(不抛错)", async () => { + const fake = createFakePermissions(); + fake.api.request = async () => false; + const P = loadPerms(fake.api); + + assert.equal(await P.requestForDomain("a.com"), false); + }); +}); diff --git a/tests/utils.test.js b/tests/utils.test.js index fe89028..54c3953 100644 --- a/tests/utils.test.js +++ b/tests/utils.test.js @@ -4,7 +4,7 @@ const { describe, it } = require("node:test"); const assert = require("node:assert/strict"); -const { isDomainMatch, escapeHtml, toMatchPatterns } = require("../dist/utils.js"); +const { isDomainMatch, escapeHtml, toMatchPatterns, pickFrameWithForm } = require("../dist/utils.js"); describe("isDomainMatch - 纯 hostname 匹配", () => { it("精确域名匹配", () => { @@ -83,10 +83,7 @@ describe("isDomainMatch - hostname + path 匹配", () => { describe("toMatchPatterns - 配置域名转 match pattern", () => { it("纯域名同时生成 http / https 两条", () => { - assert.deepEqual(toMatchPatterns("example.com"), [ - "http://example.com/*", - "https://example.com/*", - ]); + assert.deepEqual(toMatchPatterns("example.com"), ["http://example.com/*", "https://example.com/*"]); }); it("带协议前缀的配置会被剥离", () => { @@ -97,29 +94,17 @@ describe("toMatchPatterns - 配置域名转 match pattern", () => { }); it("子域名通配符 *.example.com 保持原样", () => { - assert.deepEqual(toMatchPatterns("*.example.com"), [ - "http://*.example.com/*", - "https://*.example.com/*", - ]); + assert.deepEqual(toMatchPatterns("*.example.com"), ["http://*.example.com/*", "https://*.example.com/*"]); }); it("路径规则按前缀截断到第一个 *", () => { - assert.deepEqual(toMatchPatterns("oa.com/login/*"), [ - "http://oa.com/login/*", - "https://oa.com/login/*", - ]); + assert.deepEqual(toMatchPatterns("oa.com/login/*"), ["http://oa.com/login/*", "https://oa.com/login/*"]); // 中间通配无法表达,截断成前缀(放宽注入范围,精度仍由 isDomainMatch 把关) - assert.deepEqual(toMatchPatterns("site.com/a/*/b"), [ - "http://site.com/a/*", - "https://site.com/a/*", - ]); + assert.deepEqual(toMatchPatterns("site.com/a/*/b"), ["http://site.com/a/*", "https://site.com/a/*"]); }); it("无通配的路径补上结尾 *", () => { - assert.deepEqual(toMatchPatterns("oa.com/admin"), [ - "http://oa.com/admin*", - "https://oa.com/admin*", - ]); + assert.deepEqual(toMatchPatterns("oa.com/admin"), ["http://oa.com/admin*", "https://oa.com/admin*"]); }); it("去掉端口(match pattern 不支持端口)", () => { @@ -146,8 +131,87 @@ describe("toMatchPatterns - 配置域名转 match pattern", () => { it("无法表达的规则返回空数组", () => { assert.deepEqual(toMatchPatterns(""), []); assert.deepEqual(toMatchPatterns(null), []); - assert.deepEqual(toMatchPatterns("*"), []); // 裸通配 - assert.deepEqual(toMatchPatterns("a.*.b.com"), []); // 中间通配的 host + assert.deepEqual(toMatchPatterns("*"), []); // 裸通配 + assert.deepEqual(toMatchPatterns("a.*.b.com"), []); // 中间通配的 host + }); +}); + +describe("pickFrameWithForm - 从多帧结果里挑出有表单的帧", () => { + it("只有一个帧有表单时返回该帧", () => { + assert.equal( + pickFrameWithForm([ + { frameId: 0, result: 0 }, + { frameId: 5, result: 1 }, + ]), + 5, + ); + }); + + it("顶层帧有表单时优先顶层帧", () => { + assert.equal( + pickFrameWithForm([ + { frameId: 0, result: 1 }, + { frameId: 3, result: 2 }, + ]), + 0, + ); + }); + + it("多个子帧都有表单时取 frameId 最小的", () => { + assert.equal( + pickFrameWithForm([ + { frameId: 7, result: 1 }, + { frameId: 3, result: 1 }, + ]), + 3, + ); + }); + + it("都没有表单时返回 null", () => { + assert.equal( + pickFrameWithForm([ + { frameId: 0, result: 0 }, + { frameId: 2, result: 0 }, + ]), + null, + ); + assert.equal(pickFrameWithForm([]), null); + assert.equal(pickFrameWithForm(undefined), null); + }); + + it("缺少 frameId 时按顶层帧处理", () => { + assert.equal(pickFrameWithForm([{ result: 1 }]), 0); + }); +}); + +describe("debugLog - 调试日志开关", () => { + it("默认静默,打开开关后才输出", async () => { + const logs = []; + const originalLog = console.log; + console.log = (...args) => logs.push(args.join(" ")); + + const loadUtils = () => { + delete require.cache[require.resolve("../dist/utils.js")]; + return require("../dist/utils.js"); + }; + + try { + delete global.chrome; + let U = loadUtils(); + assert.equal(await U.initDebugLog(), false); + U.debugLog("默认不该出现"); + assert.equal(logs.filter((l) => l.includes("默认不该出现")).length, 0); + + global.chrome = { storage: { local: { get: async () => ({ debugLog: true }) } } }; + U = loadUtils(); + assert.equal(await U.initDebugLog(), true); + U.debugLog("开启后应出现"); + assert.equal(logs.filter((l) => l.includes("开启后应出现")).length, 1); + } finally { + console.log = originalLog; + delete global.chrome; + loadUtils(); + } }); }); @@ -163,7 +227,7 @@ describe("escapeHtml", () => { it("组合转义", () => { assert.equal( escapeHtml('text & more'), - "<a href="x" onclick="alert('hi')">text & more</a>" + "<a href="x" onclick="alert('hi')">text & more</a>", ); });