diff --git a/.codebuddy/memory/2026-09-17.md b/.codebuddy/memory/2026-09-17.md new file mode 100644 index 0000000..6c1890a --- /dev/null +++ b/.codebuddy/memory/2026-09-17.md @@ -0,0 +1,47 @@ +# 2026-09-17 + + +## 构建管线建立(第二项遗留问题) +- 引入 esbuild 0.28.2(devDependency),tsconfig 改 `noEmit: true`:tsc 只做类型检查,esbuild 产出 dist/。 +- 新增 `scripts/build.mjs`(6 入口:utils/env-store/crypto-store/background/content 不打包 + popup/main ESM 打包)、`scripts/package.mjs`(zip 打包)、`scripts/release.mjs`(版本递增 + 同步三处版本 + 打包)、`.gitea/workflows/release.yml`(tag 触发 CI)。 +- 踩坑:esbuild 只要指定 `format: "iife"` 就会把 UMD 的 `module.exports` 换成合成对象(`__commonJS` 包装),导致测试 require 拿到 `{}`;**去掉 format 即可**(bundle:false + 不指定 format 输出正确)。 +- dist/ 已 `git rm --cached` 停止跟踪并加入 .gitignore;release/、*.zip、*.crx、*.pem 一并忽略。 +- 顺带修掉历史遗留失败:`tests/detect-captcha.test.js` 的 chrome mock 缺 `runtime.sendMessage`/`runtime.id`,导致 content.js 加载时自动填充抛未处理拒绝。现在 **54/54 全绿**。 +- 验证:dev/release 两种构建下测试均通过;`npm run release -- patch` 端到端跑通(zip 生成、版本三处同步、仅改 version 行不动格式),验证后已回滚到 1.0.0。 + +## 安全债两条的实施(用户要求:先方案 B,再解决注入问题) +- 方案 B:新增 `src/key-store.ts`(UMD,IndexedDB 存取不可导出 CryptoKey 句柄);`crypto-store.ts` 新增 `unlockWithKey(key)` 并让 `unlock` 复用它;`background.ts` 彻底去掉 `__autoPwd`(仅保留一次性迁移分支),`setupVault`/`unlock` 时 `KeyStore.saveKey`,`doLock` 时 `KeyStore.deleteKey`。 +- 注入收敛:`manifest.json` 删除静态 `content_scripts`;`utils.ts` 新增 `toMatchPatterns()`(去协议/端口/IDN→punycode,path 截断到第一个 `*`);`background.ts` 新增 `syncContentScripts()`,在 onInstalled/onStartup/SW 启动/storage.onChanged/setupVault/unlock 时按已配置域名动态注册,锁定时自动清空;`content.ts` 顶部加 `window.__autoLoginInjected` 标记,popup `fill-current.ts` 改为"探测后再 executeScript 注入"。 +- 构建:`build.mjs` 的 GLOBAL_SCRIPTS 加入 `key-store`,并新增 INJECTED_ASSETS 校验(manifest 里已不再声明 content.js/utils.js,仍需存在)。 +- 测试:54 → **75 全绿**。新增 tests/key-store.test.js(含极简 IndexedDB shim)、utils 的 toMatchPatterns 用例、crypto-store 的 unlockWithKey 用例(含"重设口令后旧句柄失效")。 +- 待办(用户未要求):`host_permissions: ` 换成 optional_host_permissions 按域名申请,以消除安装时的全站数据警告。 + +## 补齐铂金→钻石的最后两项(用户要求:先解决第一项、第二项) +- **content.ts 测试**:引入 jsdom(devDependency,Node 24 环境),新增 `tests/content-fill.test.js`(约 18KB / 24 用例)覆盖字段探测(含 Shadow DOM 穿透、HTML 片段定位、隐藏元素跳过)、填充事件、自动提交延迟、验证码拦截、requestSubmit 回退、自动填充主流程、失败次数上限与冷却、多环境浮窗选择/取消、SPA 延迟重试、manualFill/manualFillMulti 消息。测试总数 75 → **99 全绿**。 + - 技巧:用 `runScripts: "dangerously"` + 注入 script 标签执行 utils.js/content.js(与浏览器经典脚本语义一致,函数挂到 window);jsdom 无布局引擎 → 原型上覆写 offsetWidth/offsetParent 让元素"默认可见",用 hide() 单独模拟隐藏;jsdom 缺 CSS.escape 需 polyfill;跨 realm 的响应对象不能用 deepEqual(原型不同),改用字段断言。 +- **合规文档**:新增 `README.md`(功能/安全设计/技术栈/目录结构/命令/本地加载/发布流程/权限表)、`PRIVACY.md`(中英双语,说明不收集数据、三类存储位置、加密参数、权限用途、导出删除)、`CHANGELOG.md`(Keep a Changelog,Unreleased + 1.0.0=2026-09-14)、`LICENSE`(**MIT,作者 chenxuan,用户未指定许可,已提示可改**)。 + - 事实核对:vault PBKDF2 20 万次、导出文件 10 万次、SPA 重试 30s、失败 3 次/冷却 5 分钟、自动锁定 10 分钟、首次提交 2026-09-14、共 30 次提交。 +- 扩展 zip 仍只含运行时文件(22 个文件),文档与源码不入包。 + +## 段位复评(改造后) +- 结论:**铂金(稳固)**,构建发布与安全设计摸到钻石边,被文档合规与测试深度拖住。 +- 量化现状:源码 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 权限已冗余。 +- 提醒用户:改造完成时有 32 项改动未提交,建议先形成基线。 + +- 用户要求解释「__autoPwd 明文口令」与「 静态注入」两条。已给出完整讲解与改造方案,结论与核实要点记入 MEMORY.md。 +- 重要更正:早前称"任何 content script 都能读到 storage.session"有误 —— 官方文档确认 storage.session 默认仅受信任上下文可访问,需 setAccessLevel 才放开。攻击面论述已改为"安全等级降级 + 口令可被抄走"这一更准确的表述。 +- 已核实 CryptoKey 可 structured clone 入 IndexedDB 且保持 extractable:false,这是替代 __autoPwd 的推荐实现路径。 +- 用户尚未开始动手改这两条,等待其决定。 + +## 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。 +- popup.html 入口改为 dist/popup/main.js(type=module),旧 dist/popup.js 已删除。EnvStore/isDomainMatch/escapeHtml 为全局变量(globals.d.ts 声明),模块内不可 import。 +- 编译通过;npm test 54/55 通过,detect-captcha.test.js 的 "_sendMessage is not a function" 失败为拆分前已存在问题(git stash 验证过),与本次无关。 + + +## auto-login 浏览器插件段位评估 +- 项目评级:黄金→冲击铂金(业余高分段,准专业)。 +- 优点:PBKDF2(200k)+AES-GCM 整库加密、明文只进 storage.session、MV3 适配(SW 重启 autoRestoreUnlock/sidePanel)、Shadow DOM 穿透 + setNativeValue 的表单引擎、验证码检测、失败锁定、i18n、有 TS + node --test 测试。 +- 主要问题:① background.ts 把主口令明文存 chrome.storage.session 的 `__autoPwd` 实现 SW 重启自动解锁——安全模型自我瓦解,最需修复;② popup.ts 1479 行巨石未拆分;③ content script 注入 + body MutationObserver,建议改 scripting.executeScript 按需注入;④ 纯 tsc 无构建管线、dist 入库;⑤ content.ts 无测试。 diff --git a/.codebuddy/memory/MEMORY.md b/.codebuddy/memory/MEMORY.md new file mode 100644 index 0000000..444df9c --- /dev/null +++ b/.codebuddy/memory/MEMORY.md @@ -0,0 +1,31 @@ +# auto-login 项目长期记忆 + +## 已知安全债(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 都能读到"的说法是错的。 + - 已核实:CryptoKey 支持 structured clone 存入 IndexedDB,取回后 extractable 仍为 false(exportKey 抛错)但可正常 decrypt。 +2. **已修复**:原 manifest 静态 `content_scripts` + `` 无条件注入。现移除静态声明,background 通过 `chrome.scripting.registerContentScripts` 只注册**用户配置过的域名**(`syncContentScripts`,由 `utils.toMatchPatterns` 生成 match pattern);popup 手动填充改为"探测 `window.__autoLoginInjected` → `chrome.scripting.executeScript` 按需注入"。 + - 仍保留 `host_permissions: [""]`(按需注入需要),若要进一步收窄安装提示可改 `optional_host_permissions` 并按域名申请。 + - 锁定时 session 无明文域名 → 注入范围自动清空;解锁后才重新注册。 + +## 项目性质 +Chrome MV3「Auto Login Manager」扩展(域名配置自动填充 + 加密存储 + 多环境)。自建 Gitea 仓库:`http://218.61.196.156:41695/Chrome-extension/auto-login.git`。 + +## 环境注意事项 +- npm 全局缓存 `~/.npm` 有权限问题,`npm install` 必须加 `--cache /tmp/npm-cache-autologin`。 +- 全局安装不可用(权限),依赖一律装为 devDependency。 + +## 构建约定(2026-09-17 建立) +- 类型检查与产物生成分离:`tsc --noEmit` 只做类型检查(tsconfig 已设 `noEmit: true`),**esbuild 负责产出 dist/**。 +- 产物分三类,不可混为一谈: + 1. UMD 全局脚本 `utils/env-store/crypto-store`:**必须不打包、不指定 format**。一旦指定 iife,esbuild 会把 `module.exports` 换成合成模块对象,Node 测试 `require(dist/xxx.js)` 就取不到导出。 + 2. 独立入口 `background/content`:不打包(运行期靠 `importScripts` 与全局变量协作)。 + 3. `popup/main.ts`:唯一真正打包的入口(ESM,静态依赖全部内联,由 popup.html 以 `type="module"` 加载)。 +- `scripts/build.mjs` 会校验 manifest.json / popup.html 引用的文件是否都存在(11 处),并检查 manifest 与 package.json 版本一致性。 +- dist/ 已从 git 移除(`.gitignore`),克隆后必须先 `npm run build` 才能加载扩展。 +- 测试依赖 dist:`npm test` = build + `node --test tests/*.test.js`;只想跑测试用 `npm run test:only`(需已有 dist)。 +- 发布:`npm run release -- patch|minor|major|x.y.z [--codebase ]` → 同步 manifest.json / package.json / updates.xml(只替换 version 行,保留原格式)→ 发布构建 → `release/auto-login-v.zip`。不自动 commit/tag。 + +## 部署产物 +- `updates.xml`:自托管自更新清单,`appid=dpcmpkmjkcenjpfcmkoohkcnhbomgmej`,codebase 指向 Windows 本地路径的 .crx。 +- `install-policy.reg`:Windows 强制安装策略。两者都不打进扩展 zip。 diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..ccd2d27 --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,35 @@ +name: build-and-release + +on: + push: + tags: + - "v*" + workflow_dispatch: + +jobs: + package: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Install dependencies + run: npm ci + + - name: Typecheck & test + run: npm test + + - name: Package extension + run: npm run package + + - name: Upload zip artifact + uses: actions/upload-artifact@v4 + with: + name: auto-login-extension + path: release/*.zip + if-no-files-found: error diff --git a/.gitignore b/.gitignore index acd33ae..6abb54c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,13 @@ node_modules/ + +# 构建产物:由 npm run build 生成,不入库 +dist/ + +# 发布产物与签名私钥:不入库 +release/ +*.zip +*.crx +*.pem + .codeartsdoer/ .arts diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..9c6f0a7 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,55 @@ +# 更新日志 + +本文件记录本项目的所有重要变更。 + +格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/), +版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。 + +## [Unreleased] + +### Added + +- 构建管线:引入 esbuild 负责打包与压缩,`tsc` 只做类型检查(`scripts/build.mjs`) +- 构建自检:校验 `manifest.json` / `popup.html` 引用的文件是否存在、版本号是否一致 +- 打包与发布脚本:`npm run package` 产出 zip,`npm run release -- ` 递增版本并同步 `manifest.json` / `package.json` / `updates.xml` +- CI:`.gitea/workflows/release.yml`,推送 `v*` 标签时自动测试、打包并上传产物 +- 密钥句柄存储模块 `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 +- 文档:`README.md`、`PRIVACY.md`、`CHANGELOG.md`、`LICENSE` + +### Changed + +- `popup.ts`(1479 行)拆分为 `src/popup/` 下 15 个模块,模块间通过 `PopupCtx` 上下文互相调用,消除网状耦合 +- `manifest.json` 移除静态 `content_scripts`(原为 `` 无条件注入所有页面) +- 手动填充改为"探测已注入标记 → `chrome.scripting.executeScript` 按需注入" +- `dist/` 不再纳入版本控制,改为构建产物;克隆后需执行 `npm run build` +- 类型检查与产物生成分离:`tsconfig.json` 设置 `noEmit` + +### Security + +- **移除明文口令存储**:不再把主口令写入 `chrome.storage.session`,改为在 IndexedDB 保存不可导出的密钥句柄;旧版本残留的 `__autoPwd` 在升级后会被自动迁移为句柄并立即删除 +- 锁定(手动或 10 分钟无操作)时一并删除密钥句柄,解锁状态不再"自动复活" +- 脚本注入范围由"所有网站"收敛为"用户配置过的域名",锁定时自动清空 +- 未配置的站点不再执行任何扩展脚本,同时消除了原先在所有页面常驻的 `MutationObserver` + +### Fixed + +- `tests/detect-captcha.test.js` 缺少 `chrome.runtime.sendMessage` mock,导致加载 `content.js` 时抛出未处理的 Promise 拒绝,测试结果不干净 + +## [1.0.0] - 2026-09-14 + +### Added + +- 首个可用版本 +- 域名自动填充与自动提交,支持通配符与路径匹配 +- 多环境(个人 / 测试 / 生产)管理与多环境命中弹窗选择 +- 登录表单字段三级降级探测,支持 Shadow DOM 穿透与 SPA 延迟重试 +- 验证码检测(reCAPTCHA / hCaptcha / 滑块 / 图形码),命中时只填充不提交 +- 同一域名连续失败 3 次后暂停自动提交,5 分钟后恢复 +- 保险库加密:PBKDF2-SHA256(20 万次迭代)+ AES-GCM-256,自动锁定 +- 配置导入导出(兼容 v1 / v2 / v3 格式,支持文件口令加密) +- 从浏览器书签批量导入域名 +- 口令强度评估与暴力破解耗时估算 +- 中英双语界面,侧边栏形态 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..be27699 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 chenxuan + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/PRIVACY.md b/PRIVACY.md new file mode 100644 index 0000000..c3de223 --- /dev/null +++ b/PRIVACY.md @@ -0,0 +1,126 @@ +# 隐私政策 / Privacy Policy + +**Auto Login Manager**(下称"本扩展") + +最后更新:2026-09-17 + +--- + +## 中文 + +### 一句话说明 + +本扩展**不收集、不存储、不上传**任何用户数据到任何服务器。所有数据仅保存在你自己的浏览器本机。 + +### 1. 我们收集哪些数据 + +**不收集任何数据。** 具体而言: + +- 没有网络请求:本扩展不包含任何向外部服务器发送数据的代码 +- 没有统计分析、没有崩溃上报、没有广告、没有第三方 SDK +- 不读取你的浏览历史(仅在你主动点击时读取当前标签页的地址与标题) +- 不收集设备标识、位置、联系人等任何信息 + +### 2. 数据保存在哪里 + +| 数据 | 位置 | 说明 | +| --- | --- | --- | +| 域名配置、用户名、密码、环境信息 | `chrome.storage.local` | 设置本机口令后以 **AES-GCM-256 密文**保存 | +| 解锁期间的明文配置 | `chrome.storage.session` | 仅内存,浏览器关闭即清除 | +| 加密密钥句柄 | 扩展的 IndexedDB | 存的是**不可导出**的密钥对象(`extractable: false`),无法被读出原始密钥 | +| 登录失败计数 | `chrome.storage.local` | 非敏感,仅用于失败次数限制 | + +主口令本身**不会**被保存到任何位置。它仅用于派生加密密钥,解锁后只保留派生结果。 + +### 3. 加密方式 + +- 密钥派生:PBKDF2-SHA256,随机盐(本机口令 20 万次迭代;导出文件的口令 10 万次迭代) +- 数据加密:AES-GCM-256(带完整性校验的认证加密) +- 密钥属性:不可导出(`exportKey()` 会失败),只能用于加解密 + +如果你忘记本机口令,**数据无法恢复**,这是加密设计的必然结果。 + +### 4. 权限用途 + +本扩展申请以下权限,用途均限于实现自动填充功能: + +- `storage`:保存配置与解锁期间的会话数据 +- `tabs`:读取当前标签页地址 / 标题,用于「填入当前域名」与登录页匹配 +- `scripting`:在需要时注入填充脚本 +- `activeTab`:你点击扩展时临时访问当前标签页 +- `sidePanel`:显示侧边栏界面 +- `bookmarks`:仅在你点击「从书签导入」时读取书签,用于批量生成域名配置 +- 访问所有网站的权限(``):因为你可以为任意域名配置自动登录;**实际注入范围严格限定为你配置过的域名**,未配置的网站不会执行任何本扩展脚本 + +### 5. 数据的导出与删除 + +- **导出**:由你主动触发,导出的文件保存在你指定的位置,可选择用文件口令加密 +- **删除单条配置**:在侧边栏中删除即可 +- **删除全部数据**:卸载本扩展,或在浏览器中清除该扩展的存储数据 + +### 6. 第三方共享 + +不存在。本扩展不与任何第三方共享数据,因为它根本不向外传输数据。 + +### 7. 政策变更 + +若本政策发生实质性变更,将在本文件顶部的"最后更新"日期中体现,并随扩展版本更新一并发布。 + +### 8. 联系方式 + +如有疑问,请在本项目仓库提交 Issue。 + +--- + +## English + +### Summary + +This extension **does not collect, store, or transmit** any user data. Everything stays in your own browser on your own machine. + +### Data We Collect + +**None.** There are no network requests, no analytics, no crash reporting, no ads, and no third-party SDKs. + +### Where Data Is Stored + +| Data | Location | Notes | +| --- | --- | --- | +| Domain configs, usernames, passwords, environments | `chrome.storage.local` | Stored as **AES-GCM-256 ciphertext** once a master password is set | +| Decrypted configs while unlocked | `chrome.storage.session` | In-memory only, cleared when the browser closes | +| Encryption key handle | Extension IndexedDB | A **non-extractable** key object; the raw key bytes cannot be read out | +| Login failure counters | `chrome.storage.local` | Non-sensitive, used to throttle repeated auto-submits | + +The master password itself is **never** written to any storage. It is only used to derive the encryption key. + +### Encryption + +- Key derivation: PBKDF2-SHA256 with a random salt (200,000 iterations for the master password; 100,000 for exported file passwords) +- Encryption: AES-GCM-256 (authenticated encryption) +- Key property: non-extractable — `exportKey()` fails by design + +If you forget your master password, the data **cannot be recovered**. That is an inherent property of the encryption design. + +### Permissions + +- `storage` — save configs and unlocked session data +- `tabs` — read the active tab's URL/title for "fill current domain" and login-page matching +- `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. + +### Export and Deletion + +- Export is always user-initiated; optional file-password encryption is available +- Delete individual entries in the side panel +- Delete everything by uninstalling the extension or clearing its storage data + +### Third Parties + +None. The extension does not transmit data anywhere. + +### Contact + +Please open an issue in this project's repository. diff --git a/README.md b/README.md new file mode 100644 index 0000000..cffa03f --- /dev/null +++ b/README.md @@ -0,0 +1,177 @@ +# Auto Login Manager + +> 针对特定域名自动填充用户名 / 密码并完成登录,支持多环境(个人 / 测试 / 生产)管理与本地加密存储。 + +Chrome Manifest V3 扩展。所有凭据只保存在本机,不上传任何服务器,无网络请求、无统计分析、无第三方 SDK。 + +## 功能特性 + +**配置与匹配** + +- 域名规则支持精确匹配、通配符(`*.example.com`、`api.*.example.com`)与路径(`oa.com/login/*`) +- 多环境隔离(个人 / 测试 / 生产…),同一域名在不同环境下保存不同账号,命中多个环境时弹窗选择 +- 支持拖拽排序、启用 / 禁用单条配置、按域名 / 用户名 / 别名搜索 +- 从浏览器书签批量导入域名 + +**填充与登录** + +- 自动填充用户名与密码,可选择是否自动点击登录 +- 三级降级探测字段:显式选择器 → 关键词启发式(`name` / `id` / `placeholder` / `autocomplete`)→ 全量兜底 +- 支持 Shadow DOM 穿透(组件库封装的登录框也能识别) +- 针对 SPA 的延迟重试:表单晚于脚本渲染时,最长 30 秒内持续重试 +- 检测到验证码(reCAPTCHA / hCaptcha / 滑块 / 图形码)时只填充、不自动提交,并提示手动完成 +- 同一域名连续登录失败 3 次后暂停自动提交,5 分钟后自动恢复 +- 手动填充:在当前页面按需注入并填充,无需预先打开对应页面 + +**安全与数据** + +- 保险库加密:PBKDF2-SHA256(20 万次迭代)派生密钥 + AES-GCM-256 整库加密 +- 派生密钥为不可导出(`extractable: false`)的 `CryptoKey`,以密钥句柄形式存于 IndexedDB;**主口令不写入任何存储** +- 解锁期间明文只存在于 `chrome.storage.session`(浏览器关闭即清除),10 分钟无操作自动锁定 +- 导出 / 导入支持文件口令加密,兼容 v1 / v2 / v3 三种历史格式 +- 内置口令强度与暴力破解耗时估算 +- 脚本注入范围收敛:只注入用户配置过的域名,未配置的站点不执行任何扩展脚本 + +**其他** + +- 中英双语界面 +- 侧边栏(Side Panel)形态,可与网页并排使用 + +## 安全设计 + +``` +主口令 ──PBKDF2-SHA256(20万次, 随机盐)──▶ CryptoKey(不可导出) + │ + ┌───────────────────────┴───────────────────────┐ + ▼ ▼ + AES-GCM 加密 environments IndexedDB 存密钥句柄 + │ │ + chrome.storage.local(密文) Service Worker 重启后取回句柄解锁 +``` + +- **口令不落盘**:Service Worker 空闲被回收后,靠 IndexedDB 中的密钥句柄恢复解锁,无需重新输入口令,同时避免了口令被读取 +- **句柄不可导出**:`crypto.subtle.exportKey()` 对句柄会失败,拿到句柄只能"使用"不能"抄走" +- **锁定即失效**:手动锁定或自动锁定会删除会话明文与密钥句柄 +- **注入范围 = 已配置域名**:通过 `chrome.scripting.registerContentScripts` 动态注册,锁定时自动清空 + +## 技术栈 + +- TypeScript(`strict` 全开)+ Manifest V3 +- 构建:esbuild(打包 popup、压缩产物);tsc 仅做类型检查 +- 测试:`node:test` + jsdom,无运行时依赖 + +## 目录结构 + +``` +├── manifest.json 扩展清单(不含静态 content_scripts) +├── popup.html / popup.css 侧边栏界面 +├── src/ +│ ├── background.ts Service Worker:加解密持久化、密钥恢复、注入范围注册、消息路由 +│ ├── content.ts 内容脚本:表单探测与填充引擎(按需注入) +│ ├── utils.ts URL 匹配 / HTML 转义 / match pattern 转换 +│ ├── env-store.ts 环境与配置数据层(session 明文) +│ ├── crypto-store.ts 保险库:PBKDF2 + AES-GCM,锁定 / 解锁 +│ ├── crypto-file.ts 导入导出文件的加解密 +│ ├── key-store.ts 不可导出密钥句柄的 IndexedDB 存取 +│ ├── strength.ts 口令强度与破解耗时估算 +│ ├── messaging.ts runtime 消息封装(含重试) +│ ├── i18n.ts 国际化 +│ └── popup/ 侧边栏各模块(入口 main.ts,通过 PopupCtx 互相调用) +├── scripts/ 构建 / 打包 / 发布脚本 +├── tests/ 单元与集成测试 +├── updates.xml 自托管自更新清单(部署用,不入扩展包) +├── install-policy.reg Windows 强制安装策略(部署用,不入扩展包) +└── dist/ 构建产物(不入库,需自行构建) +``` + +## 开发 + +### 环境要求 + +Node.js 18+(开发时使用 v24)。 + +```bash +npm install +``` + +> 若 `~/.npm` 权限异常导致安装失败,可指定缓存目录:`npm install --cache /tmp/npm-cache-autologin` + +### 常用命令 + +| 命令 | 说明 | +| --- | --- | +| `npm run typecheck` | 仅类型检查(`tsc --noEmit`) | +| `npm run build` | 开发构建,带 sourcemap,输出到 `dist/` | +| `npm run build:release` | 发布构建,压缩、无 sourcemap | +| `npm run watch` | 监听源码变更自动重建 | +| `npm test` | 构建 + 运行全部测试 | +| `npm run test:only` | 只跑测试(需已有 `dist/`) | +| `npm run package` | 发布构建 + 打出 `release/auto-login-v<版本>.zip` | +| `npm run release -- patch` | 版本递增 + 同步版本号 + 构建打包(见下文) | + +`dist/` 不纳入版本控制,克隆后必须先执行 `npm run build` 才能加载扩展。 + +### 本地加载 + +1. `npm install && npm run build` +2. 打开 `chrome://extensions`,右上角开启「开发者模式」 +3. 点击「加载已解压的扩展程序」,选择本仓库根目录(即含 `manifest.json` 的目录) +4. 点击扩展图标打开侧边栏;首次使用会要求设置本机口令 + +### 测试 + +```bash +npm test +``` + +- `tests/crypto-store.test.js` 保险库加解密、解锁 / 锁定、密钥句柄解锁 +- `tests/key-store.test.js` 密钥句柄的存取与降级行为 +- `tests/env-store.test.js` 环境与配置 CRUD +- `tests/utils.test.js` URL 匹配、HTML 转义、match pattern 转换 +- `tests/detect-captcha.test.js` 验证码识别 +- `tests/content-fill.test.js` 表单探测与填充引擎(jsdom 真实 DOM) + +## 构建与发布 + +产物分三类,对应不同运行环境(详见 `scripts/build.mjs` 注释): + +1. **全局脚本**(`utils` / `env-store` / `crypto-store` / `key-store`):UMD,各自独立成文件,浏览器挂 `globalThis`、Node 测试可直接 `require` +2. **独立入口**(`background` / `content`):不打包,运行期靠 `importScripts` 与全局变量协作 +3. **模块入口**(`popup/main`):唯一参与打包的入口,ESM,静态依赖全部内联 + +构建时会校验 `manifest.json` 与 `popup.html` 引用的文件是否都存在,并检查版本号是否一致。 + +### 发布流程 + +```bash +# 1. 更新 CHANGELOG.md +# 2. 递增版本并打包(同步 manifest.json / package.json / updates.xml) +npm run release -- patch # 或 minor / major / 1.2.0 +# 3. 提交并打标签 +git add -A && git commit -m "release: v1.0.1" +git tag v1.0.1 && git push && git push --tags +``` + +产物位于 `release/auto-login-v<版本>.zip`:上传 Chrome Web Store 用该 zip;自托管自更新需用同一份内容打包为 `.crx`,并更新 `updates.xml` 的 `codebase`。 + +推送 `v*` 标签会触发 `.gitea/workflows/release.yml`:安装依赖 → 测试 → 打包 → 上传 zip 产物。 + +## 权限说明 + +| 权限 | 用途 | +| --- | --- | +| `storage` | 保存域名配置(密文存 `local`)与解锁期间的会话数据 | +| `tabs` | 读取当前标签页地址与标题,用于「填入当前域名」和匹配判断 | +| `scripting` | 按需注入填充脚本;注入范围由用户配置的域名动态限定 | +| `activeTab` | 用户点击扩展时临时访问当前标签页 | +| `sidePanel` | 提供侧边栏界面 | +| `bookmarks` | 从浏览器书签批量导入域名 | +| `host_permissions: ` | 在用户配置过的任意域名上执行填充(实际注入范围由配置动态限定,未配置的站点不会注入) | + +## 隐私 + +不收集、不上传任何数据。数据处理方式详见 [PRIVACY.md](PRIVACY.md)。 + +## 许可证 + +[MIT](LICENSE) diff --git a/manifest.json b/manifest.json index e2dc6ef..025c067 100644 --- a/manifest.json +++ b/manifest.json @@ -23,12 +23,5 @@ }, "background": { "service_worker": "dist/background.js" - }, - "content_scripts": [ - { - "matches": [""], - "js": ["dist/utils.js", "dist/content.js"], - "run_at": "document_idle" - } - ] + } } diff --git a/package-lock.json b/package-lock.json index d5e9d44..abd559c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,9 +9,675 @@ "version": "1.0.0", "devDependencies": { "@types/chrome": "^0.0.287", + "esbuild": "^0.28.2", + "jsdom": "^29.1.1", "typescript": "^5.6.0" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmmirror.com/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmmirror.com/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmmirror.com/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmmirror.com/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.1", + "resolved": "https://registry.npmmirror.com/@csstools/color-helpers/-/color-helpers-6.1.1.tgz", + "integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.4.0", + "resolved": "https://registry.npmmirror.com/@csstools/css-calc/-/css-calc-3.4.0.tgz", + "integrity": "sha512-XQKj5B7QiZcHiegCOCAzcAOJdhGgWOHbbu62h5e5mkHnn8lWcfiJhllkqWmxu5zWR9jucPHuo1iTB56P033hcg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.3", + "resolved": "https://registry.npmmirror.com/@csstools/css-color-parser/-/css-color-parser-4.2.3.tgz", + "integrity": "sha512-y4LpL+lmpuyKDiEFq2PnZUVFdAjsoB/qQJod79yLNokXyW7jewi+/WJ69EfItj8A2unWtxXnGjw6LYXgXu5ZjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.1", + "@csstools/css-calc": "^3.4.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.14", + "resolved": "https://registry.npmmirror.com/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.14.tgz", + "integrity": "sha512-HpbVXyrofRXpHpgkNIjU/3EWR4WJvOkO3emNK/L6X/mTJU7bGUI3AkkpoTNXznQLp0KRjLHELTGeKI5dIkI9JQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmmirror.com/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@types/chrome": { "version": "0.0.287", "resolved": "https://registry.npmmirror.com/@types/chrome/-/chrome-0.0.287.tgz", @@ -47,6 +713,293 @@ "dev": true, "license": "MIT" }, + "node_modules/bidi-js": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/bidi-js/-/bidi-js-1.1.0.tgz", + "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmmirror.com/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmmirror.com/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmmirror.com/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "8.1.0", + "resolved": "https://registry.npmmirror.com/entities/-/entities-8.1.0.tgz", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.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", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmmirror.com/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmmirror.com/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmmirror.com/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmmirror.com/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmmirror.com/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tldts": { + "version": "7.4.13", + "resolved": "https://registry.npmmirror.com/tldts/-/tldts-7.4.13.tgz", + "integrity": "sha512-iHtaIWWIbMDkCeJdTBzZFGgbluE5J+oHlb2g7+oAz1S1gpuVpabRZdQyd471Vl8UUkcz2vXSL8xZH2kyCe8tfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.13" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.13", + "resolved": "https://registry.npmmirror.com/tldts-core/-/tldts-core-7.4.13.tgz", + "integrity": "sha512-mbYsrih5FRtGxs3Usvl/PqwJsNpp+jsmrdFviiK02teHDG0/HebBG/pqCylje3kzgXYzuLoHJF/0mz9W53t8Xg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmmirror.com/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", @@ -60,6 +1013,81 @@ "engines": { "node": ">=14.17" } + }, + "node_modules/undici": { + "version": "7.29.1", + "resolved": "https://registry.npmmirror.com/undici/-/undici-7.29.1.tgz", + "integrity": "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmmirror.com/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmmirror.com/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.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", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" } } } diff --git a/package.json b/package.json index 8750e9a..c318cc9 100644 --- a/package.json +++ b/package.json @@ -2,13 +2,21 @@ "name": "auto-login", "version": "1.0.0", "description": "Auto Login Manager Chrome Extension", + "private": true, "scripts": { - "build": "tsc", - "watch": "tsc --watch", - "test": "node --test tests/*.test.js" + "typecheck": "tsc --noEmit", + "build": "node scripts/build.mjs", + "build:release": "node scripts/build.mjs --release", + "watch": "node scripts/build.mjs --watch", + "package": "node scripts/package.mjs", + "release": "node scripts/release.mjs", + "test": "node scripts/build.mjs && node --test tests/*.test.js", + "test:only": "node --test tests/*.test.js" }, "devDependencies": { "@types/chrome": "^0.0.287", + "esbuild": "^0.28.2", + "jsdom": "^29.1.1", "typescript": "^5.6.0" } -} \ No newline at end of file +} diff --git a/popup.html b/popup.html index 7edfb2b..2d77631 100644 --- a/popup.html +++ b/popup.html @@ -337,6 +337,6 @@ - + diff --git a/scripts/build.mjs b/scripts/build.mjs new file mode 100644 index 0000000..a253f82 --- /dev/null +++ b/scripts/build.mjs @@ -0,0 +1,136 @@ +#!/usr/bin/env node +/** + * 构建管线:tsc 只做类型检查(--noEmit),esbuild 负责产出 dist/。 + * + * 产物分三类,对应三种运行环境,不能混在同一个 bundle 里: + * 1. 全局脚本(iife):utils / env-store / crypto-store + * 通过 globalThis 暴露 API,被 content script 直接读全局、被 background 用 + * importScripts 加载,也必须能被 Node 测试 require,因此各自独立成文件。 + * 2. 独立入口(iife):background / content + * 运行期自行 importScripts 或读上面的全局变量,esbuild 不会(也不能)跟踪它们。 + * 3. 模块入口(esm,可打包):popup/main + * 由 popup.html 以 type="module" 加载,其静态依赖(i18n / strength / messaging / + * ui-utils / crypto-file / popup/*)全部内联进 dist/popup/main.js。 + * + * 用法: + * node scripts/build.mjs 开发构建(带 sourcemap,不压缩) + * node scripts/build.mjs --release 发布构建(压缩,无 sourcemap) + * node scripts/build.mjs --watch 监听重建 + */ +import { execFileSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { readFile, rm } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import * as esbuild from "esbuild"; + +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 STANDALONE_SCRIPTS = ["background", "content"]; +/** 由 background 通过 chrome.scripting 按需注入的脚本(manifest 里不再静态声明) */ +const INJECTED_ASSETS = ["dist/utils.js", "dist/content.js"]; + +function entryPoints() { + return [ + // UMD 全局脚本:不打包、不指定 format,原样保留 module.exports 分支 + ...GLOBAL_SCRIPTS.map((name) => ({ in: `src/${name}.ts`, out: `dist/${name}.js`, bundle: false })), + // 独立入口:不打包(运行期靠 importScripts / 全局变量协作) + ...STANDALONE_SCRIPTS.map((name) => ({ in: `src/${name}.ts`, out: `dist/${name}.js`, bundle: false })), + // popup:真正的 ESM 打包,静态依赖全部内联 + { in: "src/popup/main.ts", out: "dist/popup/main.js", format: "esm", bundle: true }, + ]; +} + +/** 类型检查单独一步,确保类型错误不会被 esbuild 静默跳过 */ +function typecheck() { + const tscBin = path.join(ROOT, "node_modules", "typescript", "bin", "tsc"); + execFileSync(process.execPath, [tscBin, "--noEmit"], { cwd: ROOT, stdio: "inherit" }); +} + +function esbuildOptions({ release, format, bundle }) { + const options = { + bundle, + platform: "browser", + target: "chrome114", + charset: "utf8", + minify: release, + sourcemap: release ? false : "linked", + legalComments: "none", + logLevel: "warning", + }; + // 只在需要时指定 format:一旦指定 iife,esbuild 会把 UMD 的 module.exports 换成 + // 自己的合成模块对象,Node 测试 require 就取不到导出内容 + if (format) options.format = format; + return options; +} + +/** manifest.json / popup.html 里引用到的文件必须真实存在,避免"构建成功但装不上" */ +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); }; + add(manifest.background?.service_worker); + add(manifest.side_panel?.default_path); + Object.values(manifest.icons || {}).forEach(add); + Object.values(manifest.action?.default_icon || {}).forEach(add); + (manifest.content_scripts || []).forEach((cs) => (cs.js || []).forEach(add)); + // manifest 不再静态声明 content_scripts,这些文件由 background 按需注入,同样必须存在 + INJECTED_ASSETS.forEach(add); + + const html = await readFile(path.join(ROOT, "popup.html"), "utf8"); + for (const m of html.matchAll(/(?:src|href)="([^"]+)"/g)) { + const ref = m[1]; + if (/^(https?:)?\/\//.test(ref) || ref.startsWith("#") || ref.startsWith("data:")) continue; + refs.add(ref); + } + + const missing = [...refs].filter((ref) => !existsSync(path.join(ROOT, ref))); + if (missing.length) { + throw new Error(`以下被引用的文件不存在,构建产物不完整:\n - ${missing.join("\n - ")}`); + } + return [...refs]; +} + +/** manifest.json 是版本号的唯一来源,package.json 需与之保持一致 */ +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 可同步)`); + } + return manifest.version; +} + +export async function buildAll({ release = false, watch = false } = {}) { + typecheck(); + const version = await checkVersionConsistency(); + + 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 })) + ); + await Promise.all(contexts.map((c) => c.watch())); + console.log(`[build] 监听中… v${version}(Ctrl+C 退出)`); + return { version, watching: true }; + } + + 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 }); + } + + const refs = await verifyReferences(); + console.log(`[build] v${version} ${release ? "release" : "dev"} 构建完成:${entryPoints().length} 个入口,校验 ${refs.length} 处引用`); + return { version, refs }; +} + +const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href; +if (isDirectRun) { + const args = process.argv.slice(2); + await buildAll({ release: args.includes("--release"), watch: args.includes("--watch") }); +} diff --git a/scripts/package.mjs b/scripts/package.mjs new file mode 100644 index 0000000..7a2d16b --- /dev/null +++ b/scripts/package.mjs @@ -0,0 +1,45 @@ +#!/usr/bin/env node +/** + * 打包:发布构建 + 打成可直接上传 Chrome / 用于自托管自更新的 zip。 + * + * 只收运行时文件,源码、脚本、部署产物(updates.xml / install-policy.reg)不入包。 + * + * 用法: + * node scripts/package.mjs 构建并打包 + * node scripts/package.mjs --skip-build 仅打包(复用现有 dist) + */ +import { execFileSync } from "node:child_process"; +import { mkdir, readFile, rm } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { buildAll } from "./build.mjs"; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +/** 扩展包内容:manifest 及其引用的所有运行时资源 */ +const PACKAGE_FILES = ["manifest.json", "popup.html", "popup.css", "icons", "_locales", "dist"]; + +export async function packageExtension({ skipBuild = false } = {}) { + if (!skipBuild) await buildAll({ release: true }); + + const manifest = JSON.parse(await readFile(path.join(ROOT, "manifest.json"), "utf8")); + const outDir = path.join(ROOT, "release"); + await mkdir(outDir, { recursive: true }); + + const zipPath = path.join(outDir, `auto-login-v${manifest.version}.zip`); + await rm(zipPath, { force: true }); + + try { + execFileSync("zip", ["-r", "-q", zipPath, ...PACKAGE_FILES], { cwd: ROOT, stdio: "inherit" }); + } catch (err) { + throw new Error(`打包失败(需要系统 zip 命令):${err.message}`); + } + + console.log(`[package] v${manifest.version} → ${path.relative(ROOT, zipPath)}`); + return { version: manifest.version, zipPath }; +} + +const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href; +if (isDirectRun) { + await packageExtension({ skipBuild: process.argv.includes("--skip-build") }); +} diff --git a/scripts/release.mjs b/scripts/release.mjs new file mode 100644 index 0000000..0f13f54 --- /dev/null +++ b/scripts/release.mjs @@ -0,0 +1,85 @@ +#!/usr/bin/env node +/** + * 版本化发布:递增版本号 → 同步 manifest.json / package.json / updates.xml → + * 发布构建 → 打出 zip,并打印后续 git 操作命令(不自动提交/打 tag)。 + * + * 用法: + * npm run release -- patch 1.0.0 → 1.0.1 + * npm run release -- minor 1.0.0 → 1.1.0 + * npm run release -- major 1.0.0 → 2.0.0 + * npm run release -- 1.4.0 指定版本 + * npm run release -- patch --codebase http://host/auto-login.crx 同步自托管更新地址 + */ +import { readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { packageExtension } from "./package.mjs"; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const MANIFEST = path.join(ROOT, "manifest.json"); +const PKG = path.join(ROOT, "package.json"); +const UPDATES = path.join(ROOT, "updates.xml"); + +function bump(current, kind) { + const parts = current.split(".").map((n) => parseInt(n, 10)); + while (parts.length < 3) parts.push(0); + if (kind === "major") return [parts[0] + 1, 0, 0].join("."); + if (kind === "minor") return [parts[0], parts[1] + 1, 0].join("."); + if (kind === "patch") return [parts[0], parts[1], parts[2] + 1].join("."); + if (/^\d+(\.\d+){0,3}$/.test(kind)) return kind; + throw new Error(`无法识别的版本参数:${kind}(支持 patch / minor / major / x.y.z)`); +} + +/** 只替换 version 字段,保留文件原有格式(避免整份 JSON 被重排产生无意义 diff) */ +async function setJsonVersion(file, version) { + const raw = await readFile(file, "utf8"); + const next = raw.replace(/("version"\s*:\s*")[^"]*(")/, `$1${version}$2`); + if (next === raw) throw new Error(`未能在 ${path.basename(file)} 中找到 version 字段`); + await writeFile(file, next); +} + +/** 同步自托管更新清单里的 version 与 codebase */ +async function syncUpdatesXml(version, codebase) { + let xml; + try { + xml = await readFile(UPDATES, "utf8"); + } catch { + return false; + } + let next = xml.replace(/(]*?version=')[^']*(')/, `$1${version}$2`); + if (codebase) { + next = next.replace(/(]*?codebase=')[^']*(')/, `$1${codebase}$2`); + } + await writeFile(UPDATES, next); + return true; +} + +async function main() { + const args = process.argv.slice(2); + const codebaseIdx = args.indexOf("--codebase"); + const codebase = codebaseIdx !== -1 ? args[codebaseIdx + 1] : null; + const kind = args.find((a) => !a.startsWith("--") && a !== codebase) || "patch"; + + const manifest = JSON.parse(await readFile(MANIFEST, "utf8")); + const pkg = JSON.parse(await readFile(PKG, "utf8")); + const nextVersion = bump(manifest.version, kind); + const prevVersion = manifest.version; + + await setJsonVersion(MANIFEST, nextVersion); + if (pkg.version !== nextVersion) await setJsonVersion(PKG, nextVersion); + + const updatesSynced = await syncUpdatesXml(nextVersion, codebase); + + console.log(`[release] ${prevVersion} → ${nextVersion}`); + + 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 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 地址。"); +} + +await main(); diff --git a/src/background.ts b/src/background.ts index 926a73d..ae378d5 100644 --- a/src/background.ts +++ b/src/background.ts @@ -1,4 +1,4 @@ -importScripts("crypto-store.js"); +importScripts("utils.js", "crypto-store.js", "key-store.js"); let masterKey: CryptoKey | null = null; // 解锁后持有,锁定或 SW 重启后清空 @@ -10,9 +10,14 @@ chrome.runtime.onInstalled.addListener(() => { .catch((err) => console.warn("[AutoLogin] 设置 sidePanel 行为失败:", err)); migrateLegacyVault(); + syncContentScripts().catch((err) => console.warn("[AutoLogin] 同步注入范围失败:", err)); console.log("[AutoLogin] 扩展已安装"); }); +chrome.runtime.onStartup.addListener(() => { + syncContentScripts().catch((err) => console.warn("[AutoLogin] 同步注入范围失败:", err)); +}); + chrome.action.onClicked.addListener((tab) => { if (tab?.id !== undefined) { chrome.sidePanel.open({ tabId: tab.id }).catch(() => {}); @@ -58,6 +63,65 @@ 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)); + + +// --------------------------------------------------------------------------- +// content script 注入范围:只注册用户配置过的域名 +// 不再在 manifest 里静态声明 ,未配置的站点完全不会执行任何扩展脚本 +// 锁定时 session 中没有明文域名,注入范围会自动清空(锁着也没东西可填) +// --------------------------------------------------------------------------- +const CONTENT_SCRIPT_ID = "auto-login-fill"; +let appliedPatternsKey: string | null = null; + +/** 汇总所有启用配置对应的 match pattern(加密模式下只有解锁后 session 里才有明文域名) */ +async function collectMatchPatterns(): Promise { + let environments: Environment[] = []; + 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 || []; + } + + const patterns = new Set(); + for (const env of environments) { + for (const cfg of env.configs || []) { + if (cfg.enabled === false) continue; + for (const p of toMatchPatterns(cfg.domain)) patterns.add(p); + } + } + return [...patterns].slice(0, 1000); +} + +async function syncContentScripts(): Promise { + const patterns = await collectMatchPatterns(); + const key = patterns.join("|"); + if (key === appliedPatternsKey) return; // 无变化,跳过 + + try { + const registered = await chrome.scripting.getRegisteredContentScripts(); + if (registered.some((s) => s.id === CONTENT_SCRIPT_ID)) { + await chrome.scripting.unregisterContentScripts({ ids: [CONTENT_SCRIPT_ID] }); + } + if (patterns.length) { + await chrome.scripting.registerContentScripts([ + { + id: CONTENT_SCRIPT_ID, + matches: patterns, + js: ["dist/utils.js", "dist/content.js"], + runAt: "document_idle", + }, + ]); + } + appliedPatternsKey = key; + console.log("[AutoLogin] 注入范围已更新:", patterns.length, "条 match pattern"); + } catch (err) { + console.warn("[AutoLogin] 注册 content script 失败:", err); + appliedPatternsKey = null; // 下次再试 + } +} function resetAutoLockTimer() { @@ -74,29 +138,55 @@ async function doLock() { masterKey = null; if (autoLockTimer) { clearTimeout(autoLockTimer); autoLockTimer = null; } await CryptoStore.lock(); - await chrome.storage.session.remove("__autoPwd"); + // 锁定即丢弃密钥句柄:解锁状态不再"自动复活" + await KeyStore.deleteKey().catch((err) => console.warn("[AutoLogin] 删除密钥句柄失败:", err)); } -// SW 重启后自动恢复解锁:从 session 取已存密码解锁 vault +/** + * SW 重启后自动恢复解锁:从 IndexedDB 取回不可导出的密钥句柄,全程不需要口令。 + * 旧版本曾把主口令明文存在 session(__autoPwd),这里兼容迁移一次并立即删除。 + */ async function autoRestoreUnlock() { try { if (!(await CryptoStore.hasVault())) return; if (masterKey) return; - const { __autoPwd } = await chrome.storage.session.get("__autoPwd"); - if (!__autoPwd) return; - const { key } = await CryptoStore.unlock(__autoPwd); + + // 迁移路径:老版本遗留的明文口令 → 换成密钥句柄,随后删除明文 + const legacy = (await chrome.storage.session.get("__autoPwd")).__autoPwd; + if (legacy) { + await chrome.storage.session.remove("__autoPwd"); + const { key } = await CryptoStore.unlock(legacy); + masterKey = key; + await KeyStore.saveKey(key).catch((err) => console.warn("[AutoLogin] 保存密钥句柄失败:", err)); + resetAutoLockTimer(); + console.log("[AutoLogin] 已将明文口令迁移为密钥句柄"); + return; + } + + const key = await KeyStore.loadKey(); + if (!key) return; + try { + await CryptoStore.unlockWithKey(key); + } catch { + // 句柄与当前 vault 不匹配(例如重设过口令),丢弃后回到锁屏 + await KeyStore.deleteKey().catch(() => {}); + return; + } masterKey = key; resetAutoLockTimer(); - console.log("[AutoLogin] 自动恢复解锁成功"); + console.log("[AutoLogin] 已用密钥句柄恢复解锁"); } catch (err) { - await chrome.storage.session.remove("__autoPwd").catch(() => {}); + console.warn("[AutoLogin] 自动恢复解锁失败:", err); } } // session 中 environments 变化时用内存密钥重新加密写回 local chrome.storage.onChanged.addListener(async (changes, areaName) => { - if (areaName !== "session") return; if (!changes.environments) return; + // 配置增删改后重算注入范围(加密模式下只有 session 有明文域名,锁定时保持上一次结果) + syncContentScripts().catch((err) => console.warn("[AutoLogin] 同步注入范围失败:", err)); + + if (areaName !== "session") return; const environments = changes.environments.newValue; if (!environments) return; // 锁定时被清除,无需持久化 try { @@ -195,9 +285,11 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { })); } masterKey = await CryptoStore.setupVault(message.password, initialEnvs); - await chrome.storage.session.set({ __autoPwd: message.password }); + // 保存不可导出的密钥句柄供 SW 重启后恢复;不保存口令本身 + await KeyStore.saveKey(masterKey).catch((err) => console.warn("[AutoLogin] 保存密钥句柄失败:", err)); await chrome.storage.local.remove("environments"); // 已加密,明文不再需要 resetAutoLockTimer(); + await syncContentScripts(); sendResponse({ success: true }); break; } @@ -206,8 +298,9 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { try { const { key } = await CryptoStore.unlock(message.password); masterKey = key; - await chrome.storage.session.set({ __autoPwd: message.password }); + await KeyStore.saveKey(key).catch((err) => console.warn("[AutoLogin] 保存密钥句柄失败:", err)); resetAutoLockTimer(); + await syncContentScripts(); sendResponse({ success: true }); } catch (err) { sendResponse({ success: false, error: (err as Error).message }); diff --git a/src/content.ts b/src/content.ts index 8d738c6..8f07969 100644 --- a/src/content.ts +++ b/src/content.ts @@ -1,4 +1,7 @@ +// 幂等标记:popup 手动填充前会先探测它,避免同一页面被重复注入 +// (重复执行本脚本会因顶层 const 重复声明而报错,还会叠加多个 MutationObserver) +(window as any).__autoLoginInjected = true; type Config = DomainConfig & { envId: string; envName: string }; diff --git a/src/crypto-store.ts b/src/crypto-store.ts index 2bc44f7..6bfc996 100644 --- a/src/crypto-store.ts +++ b/src/crypto-store.ts @@ -128,6 +128,24 @@ interface CryptoStoreDeps { 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); @@ -136,16 +154,10 @@ interface CryptoStoreDeps { const key = await deriveKey(password, b64decode(meta.salt), meta.iterations); let environments: Environment[]; try { - environments = await decryptJson(cipher, key); + environments = await unlockWithKey(key); } catch { throw new Error("口令错误"); } - 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, key }; } @@ -177,6 +189,7 @@ interface CryptoStoreDeps { isUnlocked, setupVault, unlock, + unlockWithKey, lock, deriveKey, encryptJson, diff --git a/src/globals.d.ts b/src/globals.d.ts index 7cc4fcb..0d7a411 100644 --- a/src/globals.d.ts +++ b/src/globals.d.ts @@ -12,6 +12,8 @@ interface CryptoStoreApi { isUnlocked(): Promise; setupVault(password: string, environments: Environment[]): Promise; unlock(password: string): Promise<{ environments: Environment[]; key: CryptoKey }>; + /** 用已有密钥解锁(从 IndexedDB 取回密钥句柄的场景),无需口令 */ + unlockWithKey(key: CryptoKey): Promise; lock(): Promise; deriveKey(password: string, salt: Uint8Array, iterations: number): Promise; encryptJson(obj: unknown, key: CryptoKey): Promise<{ iv: string; ciphertext: string }>; @@ -24,6 +26,21 @@ interface CryptoStoreApi { randomBytes(n: number): Uint8Array; } +interface KeyStoreApi { + DB_NAME: string; + DB_VERSION: number; + STORE_NAME: string; + MASTER_KEY_ID: string; + openDb(): Promise; + /** 保存密钥句柄(覆盖旧句柄) */ + saveKey(key: CryptoKey): Promise; + /** 取回密钥句柄;不存在或环境不支持时返回 null */ + loadKey(): Promise; + /** 删除密钥句柄 */ + deleteKey(): Promise; + isAvailable(): Promise; +} + interface EnvStoreApi { ENV_KEY: string; ACTIVE_ENV_KEY: string; @@ -48,7 +65,9 @@ declare global { var importScripts: (...urls: string[]) => void; var isDomainMatch: (url: string | null | undefined, pattern: string) => boolean; var escapeHtml: (str: string | null | undefined) => string; + var toMatchPatterns: (pattern: string | null | undefined) => string[]; var EnvStore: EnvStoreApi; var CryptoStore: CryptoStoreApi; + var KeyStore: KeyStoreApi; var module: { exports: any } | undefined; } \ No newline at end of file diff --git a/src/key-store.ts b/src/key-store.ts new file mode 100644 index 0000000..fcbab87 --- /dev/null +++ b/src/key-store.ts @@ -0,0 +1,112 @@ +/** + * 密钥句柄存储:把 PBKDF2 派生出的 AES-GCM 密钥(extractable: false)存进 IndexedDB。 + * + * 为什么可以这么做:CryptoKey 支持 structured clone,存进 IndexedDB 的是"密钥句柄" + * 而不是原始字节;取回后 extractable 仍为 false,exportKey() / wrapKey() 都会失败。 + * 于是 Service Worker 被回收后可以自动恢复解锁(不牺牲 UX), + * 而用户的主口令永远不出现在任何存储里(旧实现把明文口令存 chrome.storage.session)。 + * + * 边界:任何能在扩展上下文执行代码的一方都可以"使用"这个句柄解密(解锁状态下本来 + * 也无法避免——内存里必然存在可解密的材料),但无法导出密钥字节,更拿不到用户口令 + * ——避免了口令在其它站点被复用的连带风险。lock() 时会删除该句柄。 + */ + +interface KeyStoreDeps { + getIndexedDB: () => IDBFactory | null; +} + +(function (root: any, factory: (deps: KeyStoreDeps) => any): void { + const deps: KeyStoreDeps = { + getIndexedDB: () => (typeof indexedDB !== "undefined" ? indexedDB : null), + }; + if (typeof module === "object" && module.exports) { + module.exports = factory(deps); + } else { + root.KeyStore = factory(deps); + } +})(typeof globalThis !== "undefined" ? globalThis : this, function ({ getIndexedDB }: KeyStoreDeps) { + const DB_NAME = "auto-login-keys"; + const DB_VERSION = 1; + const STORE_NAME = "keys"; + const MASTER_KEY_ID = "masterKey"; + + let dbPromise: Promise | null = null; + + function openDb(): Promise { + if (dbPromise) return dbPromise; + dbPromise = new Promise((resolve, reject) => { + const idb = getIndexedDB(); + if (!idb) { + reject(new Error("当前环境不支持 IndexedDB")); + return; + } + const req = idb.open(DB_NAME, DB_VERSION); + req.onupgradeneeded = () => { + const db = req.result; + if (!db.objectStoreNames.contains(STORE_NAME)) db.createObjectStore(STORE_NAME); + }; + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error || new Error("打开 IndexedDB 失败")); + req.onblocked = () => reject(new Error("IndexedDB 被其它连接阻塞")); + }); + // 打开失败后允许下次重试,否则一次失败会永久锁死 + dbPromise.catch(() => { dbPromise = null; }); + return dbPromise; + } + + function withStore(mode: IDBTransactionMode, run: (store: IDBObjectStore) => IDBRequest): Promise { + return openDb().then( + (db) => + new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, mode); + const req = run(tx.objectStore(STORE_NAME)); + req.onsuccess = () => resolve(req.result as T); + req.onerror = () => reject(req.error || new Error("IndexedDB 操作失败")); + tx.onabort = () => reject(tx.error || new Error("IndexedDB 事务中断")); + }) + ); + } + + /** 保存密钥句柄(覆盖旧句柄) */ + async function saveKey(key: CryptoKey): Promise { + await withStore("readwrite", (store) => store.put(key, MASTER_KEY_ID)); + } + + /** 取回密钥句柄;不存在或环境不支持时返回 null */ + async function loadKey(): Promise { + try { + const value = await withStore("readonly", (store) => store.get(MASTER_KEY_ID)); + return value || null; + } catch (err) { + console.warn("[AutoLogin] 读取密钥句柄失败:", err); + return null; + } + } + + /** 删除密钥句柄(锁定、或句柄与当前 vault 不匹配时) */ + async function deleteKey(): Promise { + await withStore("readwrite", (store) => store.delete(MASTER_KEY_ID)); + } + + /** 环境是否支持持久化密钥句柄 */ + async function isAvailable(): Promise { + try { + await openDb(); + return true; + } catch { + return false; + } + } + + return { + DB_NAME, + DB_VERSION, + STORE_NAME, + MASTER_KEY_ID, + openDb, + saveKey, + loadKey, + deleteKey, + isAvailable, + }; +}); diff --git a/src/popup.ts b/src/popup.ts deleted file mode 100644 index 2e3cde3..0000000 --- a/src/popup.ts +++ /dev/null @@ -1,1479 +0,0 @@ -import { t, applyI18n } from "./i18n.js"; -import { encryptConfigs, decryptConfigs } from "./crypto-file.js"; -import { estimateStrength, formatTime } from "./strength.js"; -import { sendMessage } from "./messaging.js"; -import { openConfigUrl, fetchTitleByDomain, closeAllMenus } from "./ui-utils.js"; -import type { DomainConfig, Environment, PwdModalMode, LockMode } from "./types.js"; - -document.addEventListener("DOMContentLoaded", () => { - const $ = (id: string): any => document.getElementById(id); - - applyI18n(); - - const versionLabel = $("versionLabel"); - if (versionLabel) versionLabel.textContent = "v" + chrome.runtime.getManifest().version; - - const domainInput = $("domain"); - const aliasInput = $("alias"); - const usernameInput = $("username"); - const passwordInput = $("password"); - const autoSubmitSelect = $("autoSubmit"); - const enabledCheckbox = $("enabled"); - const delayMsInput = $("delayMs"); - const usernameSelectorInput = $("usernameSelector"); - const passwordSelectorInput = $("passwordSelector"); - const submitSelectorInput = $("submitSelector"); - const addBtn = $("addBtn"); - - const configList = $("configList"); - const addSection = $("addSection"); - const listSection = $("listSection"); - const fillCurrentBtn = $("fillCurrent"); - const fillDomainBtn = $("fillDomain"); - const exportBtn = $("exportBtn"); - const importBtn = $("importBtn"); - const importFile = $("importFile"); - const importBookmarkBtn = $("importBookmarkBtn"); - const bookmarkModal = $("bookmarkModal"); - const bookmarkList = $("bookmarkList"); - const bookmarkSelectAll = $("bookmarkSelectAll"); - const bookmarkSearch = $("bookmarkSearch"); - const bookmarkCancel = $("bookmarkCancel"); - const bookmarkConfirm = $("bookmarkConfirm"); - const toast = $("toast"); - - // 编辑配置弹窗 - const editConfigModal = $("editConfigModal"); - const editConfigModalTitle = $("editConfigModalTitle"); - const editDomainInput = $("editDomain"); - const editUsernameInput = $("editUsername"); - const editPasswordInput = $("editPassword"); - const editAliasInput = $("editAlias"); - const editAutoSubmitSelect = $("editAutoSubmit"); - const editEnabledCheckbox = $("editEnabled"); - const editDelayMsInput = $("editDelayMs"); - const editUsernameSelectorInput = $("editUsernameSelector"); - const editPasswordSelectorInput = $("editPasswordSelector"); - const editSubmitSelectorInput = $("editSubmitSelector"); - const editConfigCancel = $("editConfigCancel"); - const editConfigSave = $("editConfigSave"); - const editFillDomainBtn = $("editFillDomain"); - let editModalEditingId: string | null = null; - const addEntryBtn = $("addEntryBtn"); - const searchBtn = $("searchBtn"); - const searchBox = $("searchBox"); - const searchInput = $("searchInput"); - const searchClear = $("searchClear"); - const searchToggleBtn = $("searchToggleBtn"); - let searchKeyword = ""; - - // 锁定界面 - const lockScreen = $("lockScreen"); - const mainContainer = $("mainContainer"); - const lockTitle = $("lockTitle"); - const lockHint = $("lockHint"); - const lockPassword = $("lockPassword"); - const lockPasswordConfirm = $("lockPasswordConfirm"); - const lockConfirmGroup = $("lockConfirmGroup"); - const lockSubmitBtn = $("lockSubmitBtn"); - const lockWarn = $("lockWarn"); - const lockBtn = $("lockBtn"); - - // 环境栏 - const envSelect = $("envSelect"); - const envManageBtn = $("envManageBtn"); - const envManageModal = $("envManageModal"); - const envManageList = $("envManageList"); - const envManageWarn = $("envManageWarn"); - const envManageCloseX = $("envManageCloseX"); - const envManageAdd = $("envManageAdd"); - const envModal = $("envModal"); - const envModalTitle = $("envModalTitle"); - const envNameInput = $("envNameInput"); - const presetEnvTags = $("presetEnvTags"); - const envModalWarn = $("envModalWarn"); - const envModalCancel = $("envModalCancel"); - const envModalCloseX = $("envModalCloseX"); - const envModalConfirm = $("envModalConfirm"); - - const pwdModal = $("pwdModal"); - const modalTitle = $("modalTitle"); - const encryptToggle = $("encryptToggle"); - const encryptToggleGroup = $("encryptToggleGroup"); - const pwdFields = $("pwdFields"); - const confirmGroup = $("confirmGroup"); - const modalPwd = $("modalPwd"); - const modalPwdConfirm = $("modalPwdConfirm"); - const modalWarn = $("modalWarn"); - const modalCancel = $("modalCancel"); - const modalConfirm = $("modalConfirm"); - const strengthBlock = $("strengthBlock"); - const strengthBar = $("strengthBar"); - const strengthLabel = $("strengthLabel"); - const crackTime = $("crackTime"); - - - let modalResolve: ((value: any) => void) | null = null; - - // 密码强度估算:按 GPU 集群约 10^7 次/秒(PBKDF2-SHA256)计 - - function updateStrength(pwd: string): void { - const { entropy, seconds, level, label } = estimateStrength(pwd); - // 5 档进度条宽度,对数缩放避免弱口令条太短看不见 - const widthMap = { - "empty": 0, - "very-weak": 15, - "weak": 30, - "medium": 55, - "strong": 80, - "very-strong": 100, - }; - strengthBar.style.width = widthMap[level] + "%"; - strengthBar.className = "strength-bar-fg " + level; - strengthLabel.textContent = pwd ? t("strengthLabel", label, entropy.toFixed(1)) : t("strengthEmpty"); - crackTime.textContent = pwd ? t("crackTimeLabel", formatTime(seconds)) : ""; - } - - modalPwd.addEventListener("input", () => { - if (strengthBlock.style.display !== "none") updateStrength(modalPwd.value); - }); - - - // 文件密码 modal:export 显示加密勾选 + 两次输入,import 只输入一次 - function openPwdModal(mode: PwdModalMode): Promise { - return new Promise((resolve) => { - modalResolve = resolve; - if (mode === "export") { - modalTitle.textContent = t("pwdModalExportTitle"); - encryptToggleGroup.style.display = ""; - confirmGroup.style.display = ""; - strengthBlock.style.display = ""; - encryptToggle.checked = true; - pwdFields.classList.remove("hidden"); - modalPwd.value = ""; - modalPwdConfirm.value = ""; - modalWarn.textContent = t("pwdModalExportWarn"); - } else { - modalTitle.textContent = t("pwdModalImportTitle"); - encryptToggleGroup.style.display = "none"; - confirmGroup.style.display = "none"; - strengthBlock.style.display = "none"; - pwdFields.classList.remove("hidden"); - modalPwd.value = ""; - modalPwdConfirm.value = ""; - modalWarn.textContent = ""; - } - pwdModal.classList.add("show"); - setTimeout(() => modalPwd.focus(), 0); - }); - } - - function closePwdModal() { - pwdModal.classList.remove("show"); - modalPwd.value = ""; - modalPwdConfirm.value = ""; - modalResolve = null; - updateStrength(""); - } - - encryptToggle.addEventListener("change", () => { - if (encryptToggle.checked) { - pwdFields.classList.remove("hidden"); - updateStrength(modalPwd.value); - } else { - pwdFields.classList.add("hidden"); - updateStrength(""); - } - }); - - modalCancel.addEventListener("click", () => { - if (modalResolve) modalResolve(null); - closePwdModal(); - }); - - modalConfirm.addEventListener("click", () => { - if (!modalResolve) return; - - const isExport = modalTitle.textContent === t("pwdModalExportTitle"); - if (isExport) { - if (encryptToggle.checked) { - const p1 = modalPwd.value; - const p2 = modalPwdConfirm.value; - if (!p1) { - modalWarn.textContent = t("warnFilePwdEmpty"); - return; - } - if (p1 !== p2) { - modalWarn.textContent = t("warnFilePwdMismatch"); - return; - } - modalResolve({ encrypt: true, password: p1 }); - } else { - modalResolve({ encrypt: false, password: null }); - } - } else { - const p = modalPwd.value; - if (!p) { - modalWarn.textContent = t("warnFilePwdEmpty"); - return; - } - modalResolve({ encrypt: true, password: p }); - } - closePwdModal(); - }); - - modalPwd.addEventListener("keydown", (e: KeyboardEvent) => { - if (e.key === "Enter") { - e.preventDefault(); - if (confirmGroup.style.display === "none") { - modalConfirm.click(); - } else if (modalPwdConfirm.value === "") { - modalPwdConfirm.focus(); - } else { - modalConfirm.click(); - } - } - }); - - modalPwdConfirm.addEventListener("keydown", (e: KeyboardEvent) => { - if (e.key === "Enter") { - e.preventDefault(); - modalConfirm.click(); - } - }); - - function showToast(msg: string): void { - toast.textContent = msg; - toast.classList.add("show"); - setTimeout(() => toast.classList.remove("show"), 2000); - } - - document.querySelectorAll(".toggle-pwd").forEach((btn) => { - btn.addEventListener("click", () => { - const target = $((btn as HTMLElement).dataset.target || ""); - if (target.type === "password") { - target.type = "text"; - btn.classList.remove("eye-open"); - btn.classList.add("eye-closed"); - btn.textContent = "🙈"; - } else { - target.type = "password"; - btn.classList.remove("eye-closed"); - btn.classList.add("eye-open"); - btn.textContent = "👁"; - } - }); - }); - - // 表单内容实时存入草稿,弹窗关闭后不丢失 - const draftFields = [ - domainInput, aliasInput, usernameInput, passwordInput, autoSubmitSelect, - usernameSelectorInput, passwordSelectorInput, submitSelectorInput, - enabledCheckbox, delayMsInput, - ]; - draftFields.forEach((el) => { - el.addEventListener("input", saveDraft); - el.addEventListener("change", saveDraft); - }); - - // 环境数据层(EnvStore) - const { - loadEnvironments, - saveEnvironments, - - setActiveEnv, - createEnvironment, - renameEnvironment, - deleteEnvironment, - loadConfigs, - saveConfigs, - loadAllConfigs, - genId, - genCfgId, - } = EnvStore; - // isDomainMatch 由 utils.js 提供(全局函数) - - - const DRAFT_KEY = "formDraft"; - - function saveDraft() { - const draft = { - domain: domainInput.value, - alias: aliasInput.value, - username: usernameInput.value, - password: passwordInput.value, - autoSubmit: autoSubmitSelect.value, - usernameSelector: usernameSelectorInput.value, - passwordSelector: passwordSelectorInput.value, - submitSelector: submitSelectorInput.value, - enabled: enabledCheckbox.checked, - delayMs: delayMsInput.value, - }; - chrome.storage.local.set({ [DRAFT_KEY]: draft }); - } - - async function loadDraft() { - const data = await chrome.storage.local.get(DRAFT_KEY); - const draft = data[DRAFT_KEY]; - if (!draft) return; - domainInput.value = draft.domain || ""; - aliasInput.value = draft.alias || ""; - usernameInput.value = draft.username || ""; - passwordInput.value = draft.password || ""; - autoSubmitSelect.value = draft.autoSubmit || "true"; - usernameSelectorInput.value = draft.usernameSelector || ""; - passwordSelectorInput.value = draft.passwordSelector || ""; - submitSelectorInput.value = draft.submitSelector || ""; - enabledCheckbox.checked = draft.enabled !== false; - delayMsInput.value = draft.delayMs || ""; - addBtn.textContent = t("addConfigBtn"); - } - - function clearDraft() { - chrome.storage.local.remove(DRAFT_KEY); - } - - // 环境栏 - let envModalResolve: ((value: any) => void) | null = null; - // mode: "create" | "rename" - function openEnvModal(mode: "create" | "rename", currentName = ""): Promise { - return new Promise((resolve) => { - envModalResolve = resolve; - if (mode === "create") { - envModalTitle.textContent = t("envCreateTitle"); - envNameInput.value = ""; - presetEnvTags.style.display = "flex"; - } else { - envModalTitle.textContent = t("envRenameTitle"); - envNameInput.value = currentName; - presetEnvTags.style.display = "flex"; - } - envModalWarn.style.display = "none"; - envModalWarn.textContent = ""; - envModal.classList.add("show"); - setTimeout(() => { - envNameInput.focus(); - envNameInput.select(); - }, 0); - }); - } - - function closeEnvModal() { - envModal.classList.remove("show"); - envNameInput.value = ""; - envModalWarn.style.display = "none"; - envModalResolve = null; - } - - presetEnvTags.querySelectorAll(".preset-tag").forEach((tag: HTMLElement) => { - tag.addEventListener("click", () => { - envNameInput.value = tag.dataset.name || ""; - envNameInput.focus(); - }); - }); - - function closeEnvModalWithResolve() { - if (envModalResolve) envModalResolve(null); - closeEnvModal(); - } - envModalCancel.addEventListener("click", closeEnvModalWithResolve); - envModalCloseX.addEventListener("click", closeEnvModalWithResolve); - - envModalConfirm.addEventListener("click", async () => { - if (!envModalResolve) return; - const name = envNameInput.value.trim(); - if (!name) { - envModalWarn.textContent = t("envWarnEmpty"); - envModalWarn.style.display = "block"; - return; - } - if (name.length > 20) { - envModalWarn.textContent = t("envWarnTooLong"); - envModalWarn.style.display = "block"; - return; - } - envModalResolve(name); - closeEnvModal(); - }); - - envNameInput.addEventListener("keydown", (e: KeyboardEvent) => { - if (e.key === "Enter") { - e.preventDefault(); - envModalConfirm.click(); - } - }); - - async function renderEnvSelect() { - const { environments, activeEnvId } = await loadEnvironments(); - envSelect.innerHTML = ""; - environments.forEach((env) => { - const opt = document.createElement("option"); - opt.value = env.id; - opt.textContent = `${env.name} (${env.configs?.length || 0})`; - if (env.id === activeEnvId) opt.selected = true; - envSelect.appendChild(opt); - }); - } - - envSelect.addEventListener("change", async () => { - await setActiveEnv(envSelect.value); - resetForm(); - await renderConfigList(); - showToast(t("toastSwitchEnv")); - }); - - // 环境管理对话框 - function openEnvManageModal() { - envManageWarn.textContent = ""; - envManageWarn.style.display = "none"; - renderEnvManageList(); - envManageModal.classList.add("show"); - } - - function closeEnvManageModal() { - envManageModal.classList.remove("show"); - } - - async function renderEnvManageList() { - const { environments, activeEnvId } = await loadEnvironments(); - envManageList.innerHTML = ""; - - if (environments.length === 0) { - envManageList.innerHTML = `
${t("envManageEmpty")}
`; - return; - } - - environments.forEach((env) => { - const row = document.createElement("div"); - row.className = "env-manage-row" + (env.id === activeEnvId ? " active" : ""); - - const nameSpan = document.createElement("span"); - nameSpan.className = "env-manage-name"; - const cnt = env.configs?.length || 0; - const activeMark = env.id === activeEnvId ? `${t("envCurrentMark")}` : ""; - nameSpan.innerHTML = `${escapeHtml(env.name)} (${cnt}) ${activeMark}`; - - const actions = document.createElement("div"); - actions.className = "env-manage-actions"; - - const editBtn = document.createElement("button"); - editBtn.className = "btn-icon"; - editBtn.title = t("renameBtn"); - editBtn.innerHTML = ''; - editBtn.addEventListener("click", async () => { - const newName = await openEnvModal("rename", env.name); - if (!newName) return; - try { - await renameEnvironment(env.id, newName); - await renderEnvManageList(); - await renderEnvSelect(); - showToast(t("toastRenamed")); - } catch (err) { - showToast((err as Error).message || t("toastRenameFail")); - } - }); - - const delBtn = document.createElement("button"); - delBtn.className = "btn-icon"; - delBtn.title = t("deleteBtn"); - delBtn.innerHTML = ''; - delBtn.addEventListener("click", async () => { - if (environments.length <= 1) { - showToast(t("toastKeepOneEnv")); - return; - } - const cnt2 = env.configs?.length || 0; - const msg = cnt2 > 0 - ? t("confirmDeleteEnvWithCfgs", env.name, String(cnt2)) - : t("confirmDeleteEnv", env.name); - if (!confirm(msg)) return; - try { - await deleteEnvironment(env.id); - await renderEnvManageList(); - await renderEnvSelect(); - resetForm(); - await renderConfigList(); - showToast(t("toastEnvDeleted")); - } catch (err) { - showToast((err as Error).message || t("toastDeleteFail")); - } - }); - - actions.appendChild(editBtn); - actions.appendChild(delBtn); - row.appendChild(nameSpan); - row.appendChild(actions); - envManageList.appendChild(row); - }); - } - - envManageBtn.addEventListener("click", openEnvManageModal); - envManageCloseX.addEventListener("click", closeEnvManageModal); - - envManageAdd.addEventListener("click", async () => { - const name = await openEnvModal("create"); - if (!name) return; - try { - await createEnvironment(name); - await renderEnvManageList(); - await renderEnvSelect(); - resetForm(); - await renderConfigList(); - showToast(t("toastEnvCreated", name as string)); - } catch (err) { - showToast((err as Error).message || t("toastCreateFail")); - } - }); - - - - document.addEventListener("click", (e) => { - if (!(e.target as HTMLElement).closest(".config-actions")) closeAllMenus(); - }); - - - async function renderConfigList() { - const allConfigs = await loadConfigs(); - configList.innerHTML = ""; - - // 无配置时禁用导出,此时表单在上、空状态的列表在下;有配置时反过来 - const isEmpty = allConfigs.length === 0; - addSection.classList.toggle("collapsed", !isEmpty); - exportBtn.disabled = isEmpty; - exportBtn.classList.toggle("btn-disabled", isEmpty); - const parent = addSection.parentElement; - const addIdx = Array.prototype.indexOf.call(parent.children, addSection); - const listIdx = Array.prototype.indexOf.call(parent.children, listSection); - if (isEmpty && addIdx > listIdx) { - parent.insertBefore(addSection, listSection); - } else if (!isEmpty && listIdx > addIdx) { - parent.insertBefore(listSection, addSection); - } - - if (isEmpty) { - configList.innerHTML = ` -
-
- - - - - - -
-
${t("emptyStateTitle")}
-
${t("emptyStateSub")}
-
- `; - return; - } - - // 搜索过滤:支持域名、用户名、别名模糊检索 - 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; - - if (configs.length === 0) { - configList.innerHTML = ` -
-
${t("noSearchResult")}
-
- `; - return; - } - - let draggedConfigId: string | null = null; - - configs.forEach((cfg) => { - const item = document.createElement("div"); - item.className = "config-item" + (cfg.enabled === false ? " config-disabled" : ""); - if (!kw) { - item.draggable = true; - item.addEventListener("dragstart", (e: DragEvent) => { - draggedConfigId = cfg.id; - item.classList.add("dragging"); - if (e.dataTransfer) e.dataTransfer.effectAllowed = "move"; - }); - item.addEventListener("dragend", () => { - draggedConfigId = null; - item.classList.remove("dragging"); - configList.querySelectorAll(".config-item").forEach((el: Element) => el.classList.remove("drag-over")); - }); - item.addEventListener("dragover", (e: DragEvent) => { - e.preventDefault(); - if (e.dataTransfer) e.dataTransfer.dropEffect = "move"; - if (cfg.id !== draggedConfigId) { - item.classList.add("drag-over"); - } - }); - item.addEventListener("dragleave", () => { - item.classList.remove("drag-over"); - }); - item.addEventListener("drop", async (e) => { - e.preventDefault(); - item.classList.remove("drag-over"); - if (!draggedConfigId || draggedConfigId === cfg.id) return; - const fromIdx = allConfigs.findIndex((c) => c.id === draggedConfigId); - const toIdx = allConfigs.findIndex((c) => c.id === cfg.id); - if (fromIdx === -1 || toIdx === -1) return; - const [moved] = allConfigs.splice(fromIdx, 1); - allConfigs.splice(toIdx, 0, moved); - await saveConfigs(allConfigs); - renderConfigList(); - }); - } - - const info = document.createElement("div"); - info.className = "config-info"; - const displayName = escapeHtml(cfg.alias || cfg.domain); - const enabledTag = cfg.enabled === false - ? `${t("tagDisabled")}` - : ""; - info.innerHTML = ` -
${displayName} ${enabledTag}
- `; - - const actions = document.createElement("div"); - actions.className = "config-actions"; - const isDisabled = cfg.enabled === false; - - // 编辑(图标按钮) - const editBtn = document.createElement("button"); - editBtn.className = "btn-icon"; - editBtn.title = t("editBtn"); - editBtn.innerHTML = ''; - editBtn.addEventListener("click", (e) => { - e.stopPropagation(); - editModalEditingId = cfg.id; - editConfigModalTitle.textContent = t("editConfigTitle"); - editConfigSave.textContent = t("updateConfigBtn"); - editDomainInput.value = cfg.domain; - editAliasInput.value = cfg.alias || ""; - editUsernameInput.value = cfg.username; - editPasswordInput.value = cfg.password; - editAutoSubmitSelect.value = String(cfg.autoSubmit); - editUsernameSelectorInput.value = cfg.usernameSelector || ""; - editPasswordSelectorInput.value = cfg.passwordSelector || ""; - editSubmitSelectorInput.value = cfg.submitSelector || ""; - editEnabledCheckbox.checked = cfg.enabled !== false; - editDelayMsInput.value = cfg.delayMs || ""; - editConfigModal.classList.add("show"); - editDomainInput.focus(); - }); - - // 三个小点下拉菜单(收起禁用/删除) - const moreBtn = document.createElement("button"); - moreBtn.className = "btn-icon"; - moreBtn.title = t("moreActionsTitle"); - moreBtn.innerHTML = ''; - moreBtn.addEventListener("click", (e) => { - e.stopPropagation(); - const open = menu.classList.contains("open"); - closeAllMenus(); - if (!open) menu.classList.add("open"); - }); - - const menu = document.createElement("div"); - menu.className = "config-menu"; - - const toggleItem = document.createElement("button"); - toggleItem.className = "config-menu-item"; - toggleItem.textContent = isDisabled ? t("toggleEnable") : t("toggleDisable"); - toggleItem.addEventListener("click", async (e) => { - e.stopPropagation(); - closeAllMenus(); - const updated = configs.map((c) => - c.id === cfg.id ? { ...c, enabled: c.enabled === false } : c - ); - await saveConfigs(updated); - renderConfigList(); - renderEnvSelect(); - if (envManageModal.classList.contains("show")) renderEnvManageList(); - showToast(isDisabled ? t("toastEnabled") : t("toastDisabled")); - }); - - const delItem = document.createElement("button"); - delItem.className = "config-menu-item danger"; - delItem.textContent = t("deleteBtn"); - delItem.addEventListener("click", async (e) => { - e.stopPropagation(); - closeAllMenus(); - const updated = configs.filter((c) => c.id !== cfg.id); - await saveConfigs(updated); - showToast(t("toastDeleted")); - renderConfigList(); - renderEnvSelect(); - if (envManageModal.classList.contains("show")) renderEnvManageList(); - - }); - - menu.appendChild(toggleItem); - menu.appendChild(delItem); - actions.appendChild(editBtn); - actions.appendChild(moreBtn); - actions.appendChild(menu); - - // 点击整项 → 新窗口打开 - item.addEventListener("click", () => openConfigUrl(cfg.domain)); - - item.appendChild(info); - item.appendChild(actions); - configList.appendChild(item); - }); - } - - function resetForm() { - domainInput.value = ""; - aliasInput.value = ""; - usernameInput.value = ""; - passwordInput.value = ""; - autoSubmitSelect.value = "true"; - usernameSelectorInput.value = ""; - passwordSelectorInput.value = ""; - submitSelectorInput.value = ""; - enabledCheckbox.checked = true; - delayMsInput.value = ""; - addBtn.textContent = t("addConfigBtn"); - clearDraft(); - } - - - addBtn.addEventListener("click", async () => { - const domain = domainInput.value.trim(); - const username = usernameInput.value.trim(); - const password = passwordInput.value; - - if (!domain || !username || !password) { - showToast(t("toastRequired")); - return; - } - - const config: DomainConfig = { - id: genCfgId(), - domain, - alias: aliasInput.value.trim() || await fetchTitleByDomain(domain) || null, - username, - password, - autoSubmit: autoSubmitSelect.value === "true", - usernameSelector: usernameSelectorInput.value.trim() || null, - passwordSelector: passwordSelectorInput.value.trim() || null, - submitSelector: submitSelectorInput.value.trim() || null, - enabled: enabledCheckbox.checked, - delayMs: delayMsInput.value ? Number(delayMsInput.value) : null, - }; - - const configs = await loadConfigs(); - - configs.push(config); - showToast(t("toastAdded")); - - await saveConfigs(configs); - resetForm(); - renderConfigList(); - renderEnvSelect(); - if (envManageModal.classList.contains("show")) renderEnvManageList(); - }); - - // 编辑配置弹窗:保存 - editConfigSave.addEventListener("click", async () => { - const domain = editDomainInput.value.trim(); - const username = editUsernameInput.value.trim(); - const password = editPasswordInput.value; - - if (!domain || !username || !password) { - showToast(t("toastRequired")); - return; - } - - const config: DomainConfig = { - id: editModalEditingId || genCfgId(), - domain, - alias: editAliasInput.value.trim() || await fetchTitleByDomain(domain) || null, - username, - password, - autoSubmit: editAutoSubmitSelect.value === "true", - usernameSelector: editUsernameSelectorInput.value.trim() || null, - passwordSelector: editPasswordSelectorInput.value.trim() || null, - submitSelector: editSubmitSelectorInput.value.trim() || null, - enabled: editEnabledCheckbox.checked, - delayMs: editDelayMsInput.value ? Number(editDelayMsInput.value) : null, - }; - - const configs = await loadConfigs(); - - if (editModalEditingId) { - const idx = configs.findIndex((c) => c.id === editModalEditingId); - if (idx !== -1) { - configs[idx] = config; - } - showToast(t("toastUpdated")); - } else { - configs.push(config); - showToast(t("toastAdded")); - } - - await saveConfigs(configs); - editConfigModal.classList.remove("show"); - editModalEditingId = null; - renderConfigList(); - renderEnvSelect(); - if (envManageModal.classList.contains("show")) renderEnvManageList(); - }); - - // 添加配置入口按钮:打开新增弹窗 - addEntryBtn.addEventListener("click", () => { - editModalEditingId = null; - editConfigModalTitle.textContent = t("addConfigTitle"); - editConfigSave.textContent = t("okBtn"); - editDomainInput.value = ""; - editUsernameInput.value = ""; - editPasswordInput.value = ""; - editAliasInput.value = ""; - editAutoSubmitSelect.value = "true"; - editEnabledCheckbox.checked = true; - editDelayMsInput.value = ""; - editUsernameSelectorInput.value = ""; - editPasswordSelectorInput.value = ""; - editSubmitSelectorInput.value = ""; - editConfigModal.classList.add("show"); - editDomainInput.focus(); - }); - - // 搜索按钮:展开内联搜索框 - searchBtn.addEventListener("click", () => { - searchBtn.hidden = true; - searchBox.hidden = false; - searchInput.focus(); - }); - - // 搜索框内搜索图标:收起回图标按钮 - searchToggleBtn.addEventListener("click", () => { - searchInput.value = ""; - searchKeyword = ""; - searchClear.hidden = true; - searchBox.hidden = true; - searchBtn.hidden = false; - renderConfigList(); - }); - - // 搜索输入:实时过滤 - searchInput.addEventListener("input", () => { - searchKeyword = searchInput.value; - searchClear.hidden = !searchKeyword; - renderConfigList(); - }); - - // 清除搜索 - searchClear.addEventListener("click", () => { - searchInput.value = ""; - searchKeyword = ""; - searchClear.hidden = true; - renderConfigList(); - searchInput.focus(); - }); - - // 编辑配置弹窗:取消 - const editConfigCloseX = $("editConfigCloseX"); - function closeEditConfigModal() { - editConfigModal.classList.remove("show"); - editModalEditingId = null; - } - editConfigCancel.addEventListener("click", closeEditConfigModal); - editConfigCloseX.addEventListener("click", closeEditConfigModal); - - // 点击遮罩关闭编辑弹窗 - editConfigModal.addEventListener("click", (e: MouseEvent) => { - if (e.target === editConfigModal) { - editConfigModal.classList.remove("show"); - editModalEditingId = null; - } - }); - - fillCurrentBtn.addEventListener("click", async () => { - const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); - if (!tab || tab.id === undefined) { - showToast(t("toastNoTab")); - return; - } - - // 跨所有环境查找匹配当前标签页的配置 - const allConfigs = await loadAllConfigs(); - const matched = allConfigs.filter((c) => isDomainMatch(tab.url, c.domain)); - - if (matched.length === 0) { - showToast(t("toastNoMatch")); - return; - } - - if (matched.length === 1) { - try { - const resp: any = await chrome.tabs.sendMessage(tab.id, { - action: "manualFill", - config: matched[0], - }); - if (resp && (resp as any).success) { - showToast(t("toastFilled")); - } else if (resp && resp.diag) { - const d = resp.diag; - showToast(`input=${d.total} pwd=${d.pwd} form=${d.forms} | ${d.sample.join(", ")}`); - } else { - showToast(t("toastFilledNoForm")); - } - } catch (err) { - // content script 未注入:常见于安装扩展前已打开的页面 - if (/Could not establish connection|Receiving end does not exist/i.test((err as Error).message)) { - showToast(t("toastScriptNotInjected")); - } else { - showToast(t("toastSendFail", (err as Error).message || String(err))); - } - } - return; - } - - - try { - await chrome.tabs.sendMessage(tab.id, { - action: "manualFillMulti", - configs: matched, - tabUrl: tab.url, - }); - showToast(t("toastMultiMatch", String(matched.length))); - } catch (err) { - if (/Could not establish connection|Receiving end does not exist/i.test((err as Error).message)) { - showToast(t("toastScriptNotInjected")); - } else { - showToast(t("toastSendFail", (err as Error).message || String(err))); - } - } - }); - - fillDomainBtn.addEventListener("click", async () => { - const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); - if (!tab || !tab.url) { - showToast(t("toastNoTab")); - return; - } - try { - const u = new URL(tab.url); - const host = u.host; - if (!host) { - showToast(t("toastNoDomain")); - return; - } - const value = u.pathname && u.pathname !== "/" ? host + u.pathname : host; - domainInput.value = value; - if (tab.title) aliasInput.value = tab.title; - saveDraft(); - showToast(t("toastDomainFilled", value)); - } catch { - showToast(t("toastUrlParseFail")); - } - }); - - editFillDomainBtn.addEventListener("click", async () => { - const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); - if (!tab || !tab.url) { - showToast(t("toastNoTab")); - return; - } - try { - const hostname = new URL(tab.url).hostname; - if (!hostname) { - showToast(t("toastNoDomain")); - return; - } - editDomainInput.value = hostname; - showToast(t("toastDomainFilled", hostname)); - } catch { - showToast(t("toastUrlParseFail")); - } - }); - - // 导出整个 environments 结构(含环境名) - exportBtn.addEventListener("click", async () => { - const { environments } = await loadEnvironments(); - const totalConfigs = environments.reduce((s, e) => s + (e.configs?.length || 0), 0); - if (totalConfigs === 0) { - showToast(t("toastNoExportable")); - return; - } - const choice = await openPwdModal("export"); - if (!choice) { - showToast(t("toastExportCancelled")); - return; - } - const stamp = new Date().toISOString().replace(/[:T]/g, "").slice(0, 15); - let payload; - let filename; - if ((choice as any).encrypt) { - const cipher = await encryptConfigs(environments, (choice as any).password); - payload = { - app: "AutoLoginManager", - version: 3, - exportedAt: new Date().toISOString(), - encrypted: true, - cipher, - }; - filename = `auto-login-configs-${stamp}.enc.json`; - } else { - payload = { - app: "AutoLoginManager", - version: 3, - exportedAt: new Date().toISOString(), - encrypted: false, - environments, - }; - filename = `auto-login-configs-${stamp}.json`; - } - const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = filename; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - showToast(t("toastExported", String(environments.length), String(totalConfigs), (choice as any).encrypt ? t("encryptedMark") : "")); - }); - - // 导入配置:兼容 v1(数组) / v2(configs) / v3(environments),加密则弹口令 modal - importBtn.addEventListener("click", () => importFile.click()); - - importFile.addEventListener("change", async (e: Event) => { - const file = (e.target as HTMLInputElement).files?.[0]; - if (!file) return; - try { - const text = await file.text(); - const data = JSON.parse(text); - let incomingEnvironments: Environment[] | null = null; - let incomingConfigs: DomainConfig[] | null = null; - - // 1. v1: 直接数组格式 - if (Array.isArray(data)) { - incomingConfigs = data; - } - // 2. 加密格式 - else if (data?.encrypted === true && data?.cipher) { - const choice = await openPwdModal("import"); - if (!choice) { - showToast(t("toastImportCancelled")); - importFile.value = ""; - return; - } - try { - const decrypted = await decryptConfigs(data.cipher, (choice as any).password); - // 解密后可能是 v3 (environments 数组/对象) 或 v1 (configs 数组) - if (Array.isArray(decrypted)) { - // 区分环境数组(v3, 元素含 configs)与配置数组(v1, 元素含 domain) - if (decrypted.some((e) => e && Array.isArray(e.configs))) { - incomingEnvironments = decrypted; - } else { - incomingConfigs = decrypted; - } - } else if (Array.isArray(decrypted?.environments)) { - incomingEnvironments = decrypted.environments; - } else if (Array.isArray(decrypted?.configs)) { - incomingConfigs = decrypted.configs; - } else { - showToast(t("toastInvalidFormat")); - importFile.value = ""; - return; - } - } catch (err) { - showToast(t("toastDecryptFail")); - importFile.value = ""; - return; - } - } - // 3. v3 明文:含 environments - else if (Array.isArray(data?.environments)) { - incomingEnvironments = data.environments; - } - // 4. v2 明文:含 configs - else if (Array.isArray(data?.configs)) { - incomingConfigs = data.configs; - } else { - showToast(t("toastFileInvalid")); - importFile.value = ""; - return; - } - - const { environments: existingEnvs, activeEnvId } = await loadEnvironments(); - - // 分支 A:导入 v3 环境结构 —— 按"环境名"匹配,已存在的环境跳过,新的环境整批导入 - if (incomingEnvironments) { - const existingNames = new Set(existingEnvs.map((e) => e.name)); - let addedEnvs = 0; - let addedCfgs = 0; - let skippedCfgs = 0; - for (const env of incomingEnvironments) { - if (!env || !env.name) { - skippedCfgs += (env?.configs?.length) || 0; - continue; - } - if (existingNames.has(env.name)) { - // 同名环境:跳过整个环境 - skippedCfgs += (env.configs?.length) || 0; - continue; - } - existingNames.add(env.name); - const newEnv: Environment = { - id: genId(), - name: env.name, - configs: [], - }; - for (const cfg of (env.configs || [])) { - if (!cfg || !cfg.domain || !cfg.username || !cfg.password) { - skippedCfgs++; - continue; - } - newEnv.configs.push({ - id: cfg.id || genCfgId(), - domain: cfg.domain, - alias: cfg.alias || null, - username: cfg.username, - password: cfg.password, - autoSubmit: cfg.autoSubmit !== false, - usernameSelector: cfg.usernameSelector || null, - passwordSelector: cfg.passwordSelector || null, - submitSelector: cfg.submitSelector || null, - enabled: cfg.enabled !== false, - delayMs: cfg.delayMs || null, - }); - addedCfgs++; - } - existingEnvs.push(newEnv); - addedEnvs++; - } - await saveEnvironments(existingEnvs, activeEnvId ?? undefined); - await renderEnvSelect(); - await renderConfigList(); - showToast(t("toastImportedEnvs", String(addedEnvs), String(addedCfgs), String(skippedCfgs))); - return; - } - - // 分支 B:导入 v1/v2 配置数组 —— 合并到当前激活环境,相同 domain 跳过 - const curEnv = existingEnvs.find((e) => e.id === activeEnvId) || existingEnvs[0]; - const seenDomains = new Set(curEnv.configs.map((c) => c.domain)); - let added = 0; - let skipped = 0; - for (const cfg of incomingConfigs || []) { - if (!cfg || !cfg.domain || !cfg.username || !cfg.password) { - skipped++; - continue; - } - if (seenDomains.has(cfg.domain)) { - skipped++; - continue; - } - seenDomains.add(cfg.domain); - curEnv.configs.push({ - id: cfg.id || genCfgId(), - domain: cfg.domain, - alias: cfg.alias || null, - username: cfg.username, - password: cfg.password, - autoSubmit: cfg.autoSubmit !== false, - usernameSelector: cfg.usernameSelector || null, - passwordSelector: cfg.passwordSelector || null, - submitSelector: cfg.submitSelector || null, - enabled: cfg.enabled !== false, - delayMs: cfg.delayMs || null, - }); - added++; - } - await saveEnvironments(existingEnvs); - await renderConfigList(); - showToast(t("toastImportedCfgs", String(added), String(skipped))); - } catch (err) { - showToast(t("toastImportFail", (err as Error).message || "parse error")); - } finally { - importFile.value = ""; - } - }); - - // 从书签导入 - 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 }); - } - if (node.children) { - flattenBookmarks(node.children, out); - } - } - } - - function urlToDomain(url: string): string | null { - try { - const u = new URL(url); - let domain = u.host + u.pathname; - if (domain.endsWith("/") && u.pathname !== "/") domain = domain.replace(/\/$/, ""); - if (u.pathname === "/") domain = u.host; - return domain; - } catch (e) { - return null; - } - } - - let bookmarkItems: { title: string; url: string }[] = []; - - function renderBookmarkList(filter: string): void { - const kw = (filter || "").trim().toLowerCase(); - bookmarkList.innerHTML = ""; - const items = kw - ? bookmarkItems.filter((b) => b.title.toLowerCase().includes(kw) || b.url.toLowerCase().includes(kw)) - : bookmarkItems; - const frag = document.createDocumentFragment(); - for (const b of items) { - const row = document.createElement("label"); - row.className = "bookmark-item"; - const cb = document.createElement("input"); - cb.type = "checkbox"; - cb.dataset.url = b.url; - const text = document.createElement("span"); - text.className = "bookmark-item-text"; - text.textContent = b.title; - const sub = document.createElement("span"); - sub.className = "bookmark-item-url"; - sub.textContent = b.url; - row.appendChild(cb); - row.appendChild(text); - row.appendChild(sub); - frag.appendChild(row); - } - bookmarkList.appendChild(frag); - updateBookmarkSelectAllState(); - } - - function updateBookmarkSelectAllState() { - const cbs = bookmarkList.querySelectorAll("input[type=checkbox]"); - const checked = bookmarkList.querySelectorAll("input[type=checkbox]:checked"); - bookmarkSelectAll.checked = cbs.length > 0 && cbs.length === checked.length; - bookmarkConfirm.disabled = checked.length === 0; - } - - importBookmarkBtn.addEventListener("click", async () => { - try { - const tree = await chrome.bookmarks.getTree(); - bookmarkItems = []; - flattenBookmarks(tree, bookmarkItems); - bookmarkSearch.value = ""; - bookmarkSelectAll.checked = false; - renderBookmarkList(""); - bookmarkModal.classList.add("show"); - } catch (e) { - showToast(t("bookmarkLoadFail")); - } - }); - - bookmarkSearch.addEventListener("input", () => { - renderBookmarkList(bookmarkSearch.value); - }); - - bookmarkList.addEventListener("change", () => { - updateBookmarkSelectAllState(); - }); - - bookmarkSelectAll.addEventListener("change", () => { - const cbs = bookmarkList.querySelectorAll("input[type=checkbox]"); - cbs.forEach((cb: HTMLInputElement) => (cb.checked = bookmarkSelectAll.checked)); - bookmarkConfirm.disabled = !bookmarkSelectAll.checked; - }); - - bookmarkCancel.addEventListener("click", () => { - bookmarkModal.classList.remove("show"); - }); - - bookmarkModal.addEventListener("click", (e: MouseEvent) => { - if (e.target === bookmarkModal) bookmarkModal.classList.remove("show"); - }); - - bookmarkConfirm.addEventListener("click", async () => { - const checked = bookmarkList.querySelectorAll("input[type=checkbox]:checked"); - if (!checked.length) return; - const configs = await loadConfigs(); - const existingDomains = new Set(configs.map((c) => c.domain)); - let added = 0; - let skipped = 0; - for (const cb of checked) { - const domain = urlToDomain(cb.dataset.url); - if (!domain || existingDomains.has(domain)) { - skipped++; - continue; - } - existingDomains.add(domain); - configs.push({ - id: Date.now().toString(36) + Math.random().toString(36).slice(2, 8), - domain, - alias: cb.parentElement.querySelector(".bookmark-item-text").textContent || null, - username: "", - password: "", - autoSubmit: true, - usernameSelector: null, - passwordSelector: null, - submitSelector: null, - enabled: true, - delayMs: null, - }); - added++; - } - await saveConfigs(configs); - bookmarkModal.classList.remove("show"); - renderConfigList(); - renderEnvSelect(); - showToast(t("toastBookmarkImported", String(added), String(skipped))); - }); - - - // 锁定 / 解锁 - let lockMode = "setup"; // "setup" | "unlock" - let lockAfterSetup = false; - - function showLockScreen(mode: LockMode): void { - lockMode = mode; - lockScreen.style.display = "flex"; - mainContainer.style.display = "none"; - lockPassword.value = ""; - lockPasswordConfirm.value = ""; - lockWarn.textContent = ""; - lockWarn.style.display = "none"; - - if (mode === "setup") { - lockTitle.textContent = t("lockSetupTitle"); - lockHint.textContent = t("lockSetupHint"); - lockConfirmGroup.style.display = "block"; - lockSubmitBtn.textContent = t("lockSetupBtn"); - } else { - lockTitle.textContent = t("lockUnlockTitle"); - lockHint.textContent = t("lockUnlockHint"); - lockConfirmGroup.style.display = "none"; - lockSubmitBtn.textContent = t("lockUnlockBtn"); - } - setTimeout(() => lockPassword.focus(), 50); - } - - function showMainUI() { - lockScreen.style.display = "none"; - mainContainer.style.display = "flex"; - } - - async function initLockState() { - try { - const resp = await sendMessage({ action: "getVaultStatus" }); - lockBtn.hidden = false; - if (!(resp as any).hasVault) { - // 无 vault:明文模式,直接进入,不锁定 - showMainUI(); - loadDraft(); - renderEnvSelect(); - renderConfigList(); - } else if (!(resp as any).unlocked) { - showLockScreen("unlock"); - } else { - showMainUI(); - loadDraft(); - renderEnvSelect(); - renderConfigList(); - } - } catch (err) { - console.warn("[AutoLogin]", t("logGetLockStatusFail"), err); - lockBtn.hidden = false; - showMainUI(); - loadDraft(); - renderEnvSelect(); - renderConfigList(); - } - } - - lockSubmitBtn.addEventListener("click", async () => { - const pwd = lockPassword.value; - if (!pwd) { - lockWarn.textContent = t("lockWarnEmpty"); - lockWarn.style.display = "block"; - return; - } - if (lockMode === "setup") { - if (pwd.length < 6) { - lockWarn.textContent = t("lockWarnShort"); - lockWarn.style.display = "block"; - return; - } - if (pwd !== lockPasswordConfirm.value) { - lockWarn.textContent = t("lockWarnMismatch"); - lockWarn.style.display = "block"; - return; - } - try { - await sendMessage({ action: "setupVault", password: pwd }); - showToast(t("toastPasswordSet")); - if (lockAfterSetup) { - lockAfterSetup = false; - await sendMessage({ action: "lock" }); - showLockScreen("unlock"); - showToast(t("toastLocked")); - } else { - showMainUI(); - loadDraft(); - renderEnvSelect(); - renderConfigList(); - } - } catch (err) { - lockWarn.textContent = t("lockWarnSetupFail", (err as Error).message || String(err)); - lockWarn.style.display = "block"; - } - } else { - try { - const resp = await sendMessage({ action: "unlock", password: pwd }); - if ((resp as any).success) { - showToast(t("toastUnlocked")); - showMainUI(); - loadDraft(); - renderEnvSelect(); - renderConfigList(); - } else { - lockWarn.textContent = (resp as any).error || t("lockWarnUnlockFail", ""); - lockWarn.style.display = "block"; - } - } catch (err) { - lockWarn.textContent = t("lockWarnUnlockFail", (err as Error).message || String(err)); - lockWarn.style.display = "block"; - } - } - }); - - lockPassword.addEventListener("keydown", (e: KeyboardEvent) => { - if (e.key === "Enter") lockSubmitBtn.click(); - }); - lockPasswordConfirm.addEventListener("keydown", (e: KeyboardEvent) => { - if (e.key === "Enter") lockSubmitBtn.click(); - }); - - lockBtn.addEventListener("click", async () => { - const resp = await sendMessage({ action: "getVaultStatus" }).catch(() => null); - if (resp && !(resp as any).hasVault) { - // 明文模式:先设密码再锁定 - lockAfterSetup = true; - showLockScreen("setup"); - return; - } - if (!confirm(t("lockConfirmPrompt"))) return; - await sendMessage({ action: "lock" }); - showLockScreen("unlock"); - showToast(t("toastLocked")); - }); - - // 弹窗内的点击视为用户活动,重置 background 自动锁定计时 - document.addEventListener("click", () => { - if (lockScreen.style.display === "none") { - sendMessage({ action: "ping" }, 1).catch(() => {}); - } - }); - - initLockState(); -}); diff --git a/src/popup/bookmark-import.ts b/src/popup/bookmark-import.ts new file mode 100644 index 0000000..9e1f74b --- /dev/null +++ b/src/popup/bookmark-import.ts @@ -0,0 +1,148 @@ +/** 从浏览器书签导入:展示书签树扁平列表,勾选后按 URL 生成占位配置(同 domain 跳过)。 */ +import { t } from "../i18n.js"; +import { $ } from "./dom"; +import type { PopupCtx } from "./context"; + +const bookmarkModal = $("bookmarkModal"); +const bookmarkList = $("bookmarkList"); +const bookmarkSelectAll = $("bookmarkSelectAll"); +const bookmarkSearch = $("bookmarkSearch"); +const bookmarkCancel = $("bookmarkCancel"); +const bookmarkConfirm = $("bookmarkConfirm"); +const importBookmarkBtn = $("importBookmarkBtn"); + +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 }); + } + if (node.children) { + flattenBookmarks(node.children, out); + } + } +} + +function urlToDomain(url: string): string | null { + try { + const u = new URL(url); + let domain = u.host + u.pathname; + if (domain.endsWith("/") && u.pathname !== "/") domain = domain.replace(/\/$/, ""); + if (u.pathname === "/") domain = u.host; + return domain; + } catch (e) { + return null; + } +} + +export function initBookmarkImport(ctx: PopupCtx) { + const { loadConfigs, saveConfigs } = EnvStore; + + let bookmarkItems: { title: string; url: string }[] = []; + + function renderBookmarkList(filter: string): void { + const kw = (filter || "").trim().toLowerCase(); + bookmarkList.innerHTML = ""; + const items = kw + ? bookmarkItems.filter((b) => b.title.toLowerCase().includes(kw) || b.url.toLowerCase().includes(kw)) + : bookmarkItems; + const frag = document.createDocumentFragment(); + for (const b of items) { + const row = document.createElement("label"); + row.className = "bookmark-item"; + const cb = document.createElement("input"); + cb.type = "checkbox"; + cb.dataset.url = b.url; + const text = document.createElement("span"); + text.className = "bookmark-item-text"; + text.textContent = b.title; + const sub = document.createElement("span"); + sub.className = "bookmark-item-url"; + sub.textContent = b.url; + row.appendChild(cb); + row.appendChild(text); + row.appendChild(sub); + frag.appendChild(row); + } + bookmarkList.appendChild(frag); + updateBookmarkSelectAllState(); + } + + function updateBookmarkSelectAllState() { + const cbs = bookmarkList.querySelectorAll("input[type=checkbox]"); + const checked = bookmarkList.querySelectorAll("input[type=checkbox]:checked"); + bookmarkSelectAll.checked = cbs.length > 0 && cbs.length === checked.length; + bookmarkConfirm.disabled = checked.length === 0; + } + + importBookmarkBtn.addEventListener("click", async () => { + try { + const tree = await chrome.bookmarks.getTree(); + bookmarkItems = []; + flattenBookmarks(tree, bookmarkItems); + bookmarkSearch.value = ""; + bookmarkSelectAll.checked = false; + renderBookmarkList(""); + bookmarkModal.classList.add("show"); + } catch (e) { + ctx.showToast(t("bookmarkLoadFail")); + } + }); + + bookmarkSearch.addEventListener("input", () => { + renderBookmarkList(bookmarkSearch.value); + }); + + bookmarkList.addEventListener("change", () => { + updateBookmarkSelectAllState(); + }); + + bookmarkSelectAll.addEventListener("change", () => { + const cbs = bookmarkList.querySelectorAll("input[type=checkbox]"); + cbs.forEach((cb: HTMLInputElement) => (cb.checked = bookmarkSelectAll.checked)); + bookmarkConfirm.disabled = !bookmarkSelectAll.checked; + }); + + bookmarkCancel.addEventListener("click", () => { + bookmarkModal.classList.remove("show"); + }); + + bookmarkModal.addEventListener("click", (e: MouseEvent) => { + if (e.target === bookmarkModal) bookmarkModal.classList.remove("show"); + }); + + bookmarkConfirm.addEventListener("click", async () => { + const checked = bookmarkList.querySelectorAll("input[type=checkbox]:checked"); + if (!checked.length) return; + const configs = await loadConfigs(); + const existingDomains = new Set(configs.map((c) => c.domain)); + let added = 0; + let skipped = 0; + for (const cb of checked) { + const domain = urlToDomain(cb.dataset.url); + if (!domain || existingDomains.has(domain)) { + skipped++; + continue; + } + existingDomains.add(domain); + configs.push({ + id: Date.now().toString(36) + Math.random().toString(36).slice(2, 8), + domain, + alias: cb.parentElement.querySelector(".bookmark-item-text").textContent || null, + username: "", + password: "", + autoSubmit: true, + usernameSelector: null, + passwordSelector: null, + submitSelector: null, + enabled: true, + delayMs: null, + }); + added++; + } + await saveConfigs(configs); + bookmarkModal.classList.remove("show"); + ctx.renderConfigList(); + ctx.renderEnvSelect(); + ctx.showToast(t("toastBookmarkImported", String(added), String(skipped))); + }); +} diff --git a/src/popup/config-edit-modal.ts b/src/popup/config-edit-modal.ts new file mode 100644 index 0000000..a1173cb --- /dev/null +++ b/src/popup/config-edit-modal.ts @@ -0,0 +1,147 @@ +/** + * 编辑 / 新增域名配置弹窗。 + * 通过 ctx.openEditModal(cfg | null) 打开:cfg 为 null 表示新增。 + */ +import { t } from "../i18n.js"; +import { fetchTitleByDomain } from "../ui-utils.js"; +import type { DomainConfig } from "../types.js"; +import { $ } from "./dom"; +import type { PopupCtx } from "./context"; + +const editConfigModal = $("editConfigModal"); +const editConfigModalTitle = $("editConfigModalTitle"); +const editDomainInput = $("editDomain"); +const editUsernameInput = $("editUsername"); +const editPasswordInput = $("editPassword"); +const editAliasInput = $("editAlias"); +const editAutoSubmitSelect = $("editAutoSubmit"); +const editEnabledCheckbox = $("editEnabled"); +const editDelayMsInput = $("editDelayMs"); +const editUsernameSelectorInput = $("editUsernameSelector"); +const editPasswordSelectorInput = $("editPasswordSelector"); +const editSubmitSelectorInput = $("editSubmitSelector"); +const editConfigCancel = $("editConfigCancel"); +const editConfigSave = $("editConfigSave"); +const editFillDomainBtn = $("editFillDomain"); +const editConfigCloseX = $("editConfigCloseX"); + +let editModalEditingId: string | null = null; + +export function initEditConfigModal(ctx: PopupCtx) { + function openEditModal(cfg: DomainConfig | null): void { + editModalEditingId = cfg ? cfg.id : null; + if (cfg) { + editConfigModalTitle.textContent = t("editConfigTitle"); + editConfigSave.textContent = t("updateConfigBtn"); + editDomainInput.value = cfg.domain; + editAliasInput.value = cfg.alias || ""; + editUsernameInput.value = cfg.username; + editPasswordInput.value = cfg.password; + editAutoSubmitSelect.value = String(cfg.autoSubmit); + editUsernameSelectorInput.value = cfg.usernameSelector || ""; + editPasswordSelectorInput.value = cfg.passwordSelector || ""; + editSubmitSelectorInput.value = cfg.submitSelector || ""; + editEnabledCheckbox.checked = cfg.enabled !== false; + editDelayMsInput.value = cfg.delayMs || ""; + } else { + editConfigModalTitle.textContent = t("addConfigTitle"); + editConfigSave.textContent = t("okBtn"); + editDomainInput.value = ""; + editUsernameInput.value = ""; + editPasswordInput.value = ""; + editAliasInput.value = ""; + editAutoSubmitSelect.value = "true"; + editEnabledCheckbox.checked = true; + editDelayMsInput.value = ""; + editUsernameSelectorInput.value = ""; + editPasswordSelectorInput.value = ""; + editSubmitSelectorInput.value = ""; + } + editConfigModal.classList.add("show"); + editDomainInput.focus(); + } + + function closeEditConfigModal() { + editConfigModal.classList.remove("show"); + editModalEditingId = null; + } + + // 保存 + editConfigSave.addEventListener("click", async () => { + const domain = editDomainInput.value.trim(); + const username = editUsernameInput.value.trim(); + const password = editPasswordInput.value; + + if (!domain || !username || !password) { + ctx.showToast(t("toastRequired")); + return; + } + + const config: DomainConfig = { + id: editModalEditingId || EnvStore.genCfgId(), + domain, + alias: editAliasInput.value.trim() || await fetchTitleByDomain(domain) || null, + username, + password, + autoSubmit: editAutoSubmitSelect.value === "true", + usernameSelector: editUsernameSelectorInput.value.trim() || null, + passwordSelector: editPasswordSelectorInput.value.trim() || null, + submitSelector: editSubmitSelectorInput.value.trim() || null, + enabled: editEnabledCheckbox.checked, + delayMs: editDelayMsInput.value ? Number(editDelayMsInput.value) : null, + }; + + const configs = await EnvStore.loadConfigs(); + + if (editModalEditingId) { + const idx = configs.findIndex((c) => c.id === editModalEditingId); + if (idx !== -1) { + configs[idx] = config; + } + ctx.showToast(t("toastUpdated")); + } else { + configs.push(config); + ctx.showToast(t("toastAdded")); + } + + await EnvStore.saveConfigs(configs); + editConfigModal.classList.remove("show"); + editModalEditingId = null; + ctx.renderConfigList(); + ctx.renderEnvSelect(); + ctx.refreshEnvManage(); + }); + + editConfigCancel.addEventListener("click", closeEditConfigModal); + editConfigCloseX.addEventListener("click", closeEditConfigModal); + + // 点击遮罩关闭 + editConfigModal.addEventListener("click", (e: MouseEvent) => { + if (e.target === editConfigModal) { + editConfigModal.classList.remove("show"); + editModalEditingId = null; + } + }); + + // 填入当前标签页域名 + editFillDomainBtn.addEventListener("click", async () => { + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + if (!tab || !tab.url) { + ctx.showToast(t("toastNoTab")); + return; + } + try { + const hostname = new URL(tab.url).hostname; + if (!hostname) { + ctx.showToast(t("toastNoDomain")); + return; + } + editDomainInput.value = hostname; + ctx.showToast(t("toastDomainFilled", hostname)); + } catch { + ctx.showToast(t("toastUrlParseFail")); + } + }); + + return { openEditModal }; +} diff --git a/src/popup/config-form.ts b/src/popup/config-form.ts new file mode 100644 index 0000000..19413ae --- /dev/null +++ b/src/popup/config-form.ts @@ -0,0 +1,152 @@ +/** + * 添加配置表单:表单字段、草稿持久化(弹窗关闭后不丢失)、 + * 添加按钮提交、"填入当前域名"按钮。 + */ +import { t } from "../i18n.js"; +import { fetchTitleByDomain } from "../ui-utils.js"; +import type { DomainConfig } from "../types.js"; +import { $ } from "./dom"; +import type { PopupCtx } from "./context"; + +const domainInput = $("domain"); +const aliasInput = $("alias"); +const usernameInput = $("username"); +const passwordInput = $("password"); +const autoSubmitSelect = $("autoSubmit"); +const enabledCheckbox = $("enabled"); +const delayMsInput = $("delayMs"); +const usernameSelectorInput = $("usernameSelector"); +const passwordSelectorInput = $("passwordSelector"); +const submitSelectorInput = $("submitSelector"); +const addBtn = $("addBtn"); +const fillDomainBtn = $("fillDomain"); + +const DRAFT_KEY = "formDraft"; + +const draftFields = [ + domainInput, aliasInput, usernameInput, passwordInput, autoSubmitSelect, + usernameSelectorInput, passwordSelectorInput, submitSelectorInput, + enabledCheckbox, delayMsInput, +]; + +export function initConfigForm(ctx: PopupCtx) { + function saveDraft() { + const draft = { + domain: domainInput.value, + alias: aliasInput.value, + username: usernameInput.value, + password: passwordInput.value, + autoSubmit: autoSubmitSelect.value, + usernameSelector: usernameSelectorInput.value, + passwordSelector: passwordSelectorInput.value, + submitSelector: submitSelectorInput.value, + enabled: enabledCheckbox.checked, + delayMs: delayMsInput.value, + }; + chrome.storage.local.set({ [DRAFT_KEY]: draft }); + } + + async function loadDraft() { + const data = await chrome.storage.local.get(DRAFT_KEY); + const draft = data[DRAFT_KEY]; + if (!draft) return; + domainInput.value = draft.domain || ""; + aliasInput.value = draft.alias || ""; + usernameInput.value = draft.username || ""; + passwordInput.value = draft.password || ""; + autoSubmitSelect.value = draft.autoSubmit || "true"; + usernameSelectorInput.value = draft.usernameSelector || ""; + passwordSelectorInput.value = draft.passwordSelector || ""; + submitSelectorInput.value = draft.submitSelector || ""; + enabledCheckbox.checked = draft.enabled !== false; + delayMsInput.value = draft.delayMs || ""; + addBtn.textContent = t("addConfigBtn"); + } + + function clearDraft() { + chrome.storage.local.remove(DRAFT_KEY); + } + + function resetForm() { + domainInput.value = ""; + aliasInput.value = ""; + usernameInput.value = ""; + passwordInput.value = ""; + autoSubmitSelect.value = "true"; + usernameSelectorInput.value = ""; + passwordSelectorInput.value = ""; + submitSelectorInput.value = ""; + enabledCheckbox.checked = true; + delayMsInput.value = ""; + addBtn.textContent = t("addConfigBtn"); + clearDraft(); + } + + // 表单内容实时存入草稿,弹窗关闭后不丢失 + draftFields.forEach((el) => { + el.addEventListener("input", saveDraft); + el.addEventListener("change", saveDraft); + }); + + addBtn.addEventListener("click", async () => { + const domain = domainInput.value.trim(); + const username = usernameInput.value.trim(); + const password = passwordInput.value; + + if (!domain || !username || !password) { + ctx.showToast(t("toastRequired")); + return; + } + + const config: DomainConfig = { + id: EnvStore.genCfgId(), + domain, + alias: aliasInput.value.trim() || await fetchTitleByDomain(domain) || null, + username, + password, + autoSubmit: autoSubmitSelect.value === "true", + usernameSelector: usernameSelectorInput.value.trim() || null, + passwordSelector: passwordSelectorInput.value.trim() || null, + submitSelector: submitSelectorInput.value.trim() || null, + enabled: enabledCheckbox.checked, + delayMs: delayMsInput.value ? Number(delayMsInput.value) : null, + }; + + const configs = await EnvStore.loadConfigs(); + + configs.push(config); + ctx.showToast(t("toastAdded")); + + await EnvStore.saveConfigs(configs); + resetForm(); + ctx.renderConfigList(); + ctx.renderEnvSelect(); + ctx.refreshEnvManage(); + }); + + // 填入当前标签页域名 + fillDomainBtn.addEventListener("click", async () => { + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + if (!tab || !tab.url) { + ctx.showToast(t("toastNoTab")); + return; + } + try { + const u = new URL(tab.url); + const host = u.host; + if (!host) { + ctx.showToast(t("toastNoDomain")); + return; + } + const value = u.pathname && u.pathname !== "/" ? host + u.pathname : host; + domainInput.value = value; + if (tab.title) aliasInput.value = tab.title; + saveDraft(); + ctx.showToast(t("toastDomainFilled", value)); + } catch { + ctx.showToast(t("toastUrlParseFail")); + } + }); + + return { resetForm, saveDraft, loadDraft }; +} diff --git a/src/popup/config-list.ts b/src/popup/config-list.ts new file mode 100644 index 0000000..6f9906d --- /dev/null +++ b/src/popup/config-list.ts @@ -0,0 +1,238 @@ +/** + * 配置列表:渲染、内联搜索过滤、拖拽排序、启用/禁用与删除菜单、 + * 空状态与"添加区/列表区"位置互换。 + */ +import { t } from "../i18n.js"; +import { closeAllMenus, openConfigUrl } from "../ui-utils.js"; +import { $ } from "./dom"; +import type { PopupCtx } from "./context"; + +const configList = $("configList"); +const addSection = $("addSection"); +const listSection = $("listSection"); +const exportBtn = $("exportBtn"); +const searchBtn = $("searchBtn"); +const searchBox = $("searchBox"); +const searchInput = $("searchInput"); +const searchClear = $("searchClear"); +const searchToggleBtn = $("searchToggleBtn"); + +let searchKeyword = ""; + +export function initConfigList(ctx: PopupCtx) { + const { loadConfigs, saveConfigs } = EnvStore; + + async function renderConfigList() { + const allConfigs = await loadConfigs(); + configList.innerHTML = ""; + + // 无配置时禁用导出,此时表单在上、空状态的列表在下;有配置时反过来 + const isEmpty = allConfigs.length === 0; + addSection.classList.toggle("collapsed", !isEmpty); + exportBtn.disabled = isEmpty; + exportBtn.classList.toggle("btn-disabled", isEmpty); + const parent = addSection.parentElement; + const addIdx = Array.prototype.indexOf.call(parent.children, addSection); + const listIdx = Array.prototype.indexOf.call(parent.children, listSection); + if (isEmpty && addIdx > listIdx) { + parent.insertBefore(addSection, listSection); + } else if (!isEmpty && listIdx > addIdx) { + parent.insertBefore(listSection, addSection); + } + + if (isEmpty) { + configList.innerHTML = ` +
+
+ + + + + + +
+
${t("emptyStateTitle")}
+
${t("emptyStateSub")}
+
+ `; + return; + } + + // 搜索过滤:支持域名、用户名、别名模糊检索 + 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; + + if (configs.length === 0) { + configList.innerHTML = ` +
+
${t("noSearchResult")}
+
+ `; + return; + } + + let draggedConfigId: string | null = null; + + configs.forEach((cfg) => { + const item = document.createElement("div"); + item.className = "config-item" + (cfg.enabled === false ? " config-disabled" : ""); + if (!kw) { + item.draggable = true; + item.addEventListener("dragstart", (e: DragEvent) => { + draggedConfigId = cfg.id; + item.classList.add("dragging"); + if (e.dataTransfer) e.dataTransfer.effectAllowed = "move"; + }); + item.addEventListener("dragend", () => { + draggedConfigId = null; + item.classList.remove("dragging"); + configList.querySelectorAll(".config-item").forEach((el: Element) => el.classList.remove("drag-over")); + }); + item.addEventListener("dragover", (e: DragEvent) => { + e.preventDefault(); + if (e.dataTransfer) e.dataTransfer.dropEffect = "move"; + if (cfg.id !== draggedConfigId) { + item.classList.add("drag-over"); + } + }); + item.addEventListener("dragleave", () => { + item.classList.remove("drag-over"); + }); + item.addEventListener("drop", async (e) => { + e.preventDefault(); + item.classList.remove("drag-over"); + if (!draggedConfigId || draggedConfigId === cfg.id) return; + const fromIdx = allConfigs.findIndex((c) => c.id === draggedConfigId); + const toIdx = allConfigs.findIndex((c) => c.id === cfg.id); + if (fromIdx === -1 || toIdx === -1) return; + const [moved] = allConfigs.splice(fromIdx, 1); + allConfigs.splice(toIdx, 0, moved); + await saveConfigs(allConfigs); + renderConfigList(); + }); + } + + const info = document.createElement("div"); + info.className = "config-info"; + const displayName = escapeHtml(cfg.alias || cfg.domain); + const enabledTag = cfg.enabled === false + ? `${t("tagDisabled")}` + : ""; + info.innerHTML = ` +
${displayName} ${enabledTag}
+ `; + + const actions = document.createElement("div"); + actions.className = "config-actions"; + const isDisabled = cfg.enabled === false; + + // 编辑(图标按钮) + const editBtn = document.createElement("button"); + editBtn.className = "btn-icon"; + editBtn.title = t("editBtn"); + editBtn.innerHTML = ''; + editBtn.addEventListener("click", (e) => { + e.stopPropagation(); + ctx.openEditModal(cfg); + }); + + // 三个小点下拉菜单(收起禁用/删除) + const moreBtn = document.createElement("button"); + moreBtn.className = "btn-icon"; + moreBtn.title = t("moreActionsTitle"); + moreBtn.innerHTML = ''; + moreBtn.addEventListener("click", (e) => { + e.stopPropagation(); + const open = menu.classList.contains("open"); + closeAllMenus(); + if (!open) menu.classList.add("open"); + }); + + const menu = document.createElement("div"); + menu.className = "config-menu"; + + const toggleItem = document.createElement("button"); + toggleItem.className = "config-menu-item"; + toggleItem.textContent = isDisabled ? t("toggleEnable") : t("toggleDisable"); + toggleItem.addEventListener("click", async (e) => { + e.stopPropagation(); + closeAllMenus(); + const updated = configs.map((c) => + c.id === cfg.id ? { ...c, enabled: c.enabled === false } : c + ); + await saveConfigs(updated); + renderConfigList(); + ctx.renderEnvSelect(); + ctx.refreshEnvManage(); + ctx.showToast(isDisabled ? t("toastEnabled") : t("toastDisabled")); + }); + + const delItem = document.createElement("button"); + delItem.className = "config-menu-item danger"; + delItem.textContent = t("deleteBtn"); + delItem.addEventListener("click", async (e) => { + e.stopPropagation(); + closeAllMenus(); + const updated = configs.filter((c) => c.id !== cfg.id); + await saveConfigs(updated); + ctx.showToast(t("toastDeleted")); + renderConfigList(); + ctx.renderEnvSelect(); + ctx.refreshEnvManage(); + }); + + menu.appendChild(toggleItem); + menu.appendChild(delItem); + actions.appendChild(editBtn); + actions.appendChild(moreBtn); + actions.appendChild(menu); + + // 点击整项 → 新窗口打开 + item.addEventListener("click", () => openConfigUrl(cfg.domain)); + + item.appendChild(info); + item.appendChild(actions); + configList.appendChild(item); + }); + } + + // 搜索按钮:展开内联搜索框 + searchBtn.addEventListener("click", () => { + searchBtn.hidden = true; + searchBox.hidden = false; + searchInput.focus(); + }); + + // 搜索框内搜索图标:收起回图标按钮 + searchToggleBtn.addEventListener("click", () => { + searchInput.value = ""; + searchKeyword = ""; + searchClear.hidden = true; + searchBox.hidden = true; + searchBtn.hidden = false; + renderConfigList(); + }); + + // 搜索输入:实时过滤 + searchInput.addEventListener("input", () => { + searchKeyword = searchInput.value; + searchClear.hidden = !searchKeyword; + renderConfigList(); + }); + + // 清除搜索 + searchClear.addEventListener("click", () => { + searchInput.value = ""; + searchKeyword = ""; + searchClear.hidden = true; + renderConfigList(); + searchInput.focus(); + }); + + return { renderConfigList }; +} diff --git a/src/popup/context.ts b/src/popup/context.ts new file mode 100644 index 0000000..1d825aa --- /dev/null +++ b/src/popup/context.ts @@ -0,0 +1,20 @@ +/** popup 各模块共享的上下文:由 main.ts 组装,模块间通过它互调,避免循环依赖 */ +import type { DomainConfig } from "../types.js"; + +export interface PopupCtx { + showToast(msg: string): void; + /** 重置添加表单并清空草稿 */ + resetForm(): void; + /** 表单草稿:保存 */ + saveDraft(): void; + /** 表单草稿:恢复 */ + loadDraft(): Promise; + /** 重新渲染环境下拉框 */ + renderEnvSelect(): Promise; + /** 重新渲染配置列表 */ + renderConfigList(): Promise; + /** 环境管理弹窗打开时刷新其列表 */ + refreshEnvManage(): void; + /** 打开编辑配置弹窗;cfg 为 null 表示新增 */ + openEditModal(cfg: DomainConfig | null): void; +} diff --git a/src/popup/dom.ts b/src/popup/dom.ts new file mode 100644 index 0000000..8fbcd7e --- /dev/null +++ b/src/popup/dom.ts @@ -0,0 +1,4 @@ +/** popup DOM 工具 */ + +/** 按 id 取元素,取不到返回 null(与原 popup.ts 的 $ 行为一致) */ +export const $ = (id: string): any => document.getElementById(id); diff --git a/src/popup/env-modal.ts b/src/popup/env-modal.ts new file mode 100644 index 0000000..bb8065c --- /dev/null +++ b/src/popup/env-modal.ts @@ -0,0 +1,85 @@ +/** + * 新建 / 重命名环境弹窗。 + * openEnvModal 返回 Promise,确认后 resolve(环境名),取消 resolve(null)。 + */ +import { t } from "../i18n.js"; +import { $ } from "./dom"; + +const envModal = $("envModal"); +const envModalTitle = $("envModalTitle"); +const envNameInput = $("envNameInput"); +const presetEnvTags = $("presetEnvTags"); +const envModalWarn = $("envModalWarn"); +const envModalCancel = $("envModalCancel"); +const envModalCloseX = $("envModalCloseX"); +const envModalConfirm = $("envModalConfirm"); + +let envModalResolve: ((value: any) => void) | null = null; + +// mode: "create" | "rename" +export function openEnvModal(mode: "create" | "rename", currentName = ""): Promise { + return new Promise((resolve) => { + envModalResolve = resolve; + if (mode === "create") { + envModalTitle.textContent = t("envCreateTitle"); + envNameInput.value = ""; + presetEnvTags.style.display = "flex"; + } else { + envModalTitle.textContent = t("envRenameTitle"); + envNameInput.value = currentName; + presetEnvTags.style.display = "flex"; + } + envModalWarn.style.display = "none"; + envModalWarn.textContent = ""; + envModal.classList.add("show"); + setTimeout(() => { + envNameInput.focus(); + envNameInput.select(); + }, 0); + }); +} + +function closeEnvModal() { + envModal.classList.remove("show"); + envNameInput.value = ""; + envModalWarn.style.display = "none"; + envModalResolve = null; +} + +presetEnvTags.querySelectorAll(".preset-tag").forEach((tag: HTMLElement) => { + tag.addEventListener("click", () => { + envNameInput.value = tag.dataset.name || ""; + envNameInput.focus(); + }); +}); + +function closeEnvModalWithResolve() { + if (envModalResolve) envModalResolve(null); + closeEnvModal(); +} +envModalCancel.addEventListener("click", closeEnvModalWithResolve); +envModalCloseX.addEventListener("click", closeEnvModalWithResolve); + +envModalConfirm.addEventListener("click", async () => { + if (!envModalResolve) return; + const name = envNameInput.value.trim(); + if (!name) { + envModalWarn.textContent = t("envWarnEmpty"); + envModalWarn.style.display = "block"; + return; + } + if (name.length > 20) { + envModalWarn.textContent = t("envWarnTooLong"); + envModalWarn.style.display = "block"; + return; + } + envModalResolve(name); + closeEnvModal(); +}); + +envNameInput.addEventListener("keydown", (e: KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + envModalConfirm.click(); + } +}); diff --git a/src/popup/env-section.ts b/src/popup/env-section.ts new file mode 100644 index 0000000..397901d --- /dev/null +++ b/src/popup/env-section.ts @@ -0,0 +1,160 @@ +/** + * 环境栏(顶部环境下拉 + 管理按钮)与环境管理弹窗。 + * 环境增删改后通过 ctx 通知其他模块刷新。 + */ +import { t } from "../i18n.js"; +import { closeAllMenus } from "../ui-utils.js"; +import { openEnvModal } from "./env-modal.js"; +import { $ } from "./dom"; +import type { PopupCtx } from "./context"; + +const { + loadEnvironments, + setActiveEnv, + createEnvironment, + renameEnvironment, + deleteEnvironment, +} = EnvStore; + +const envSelect = $("envSelect"); +const envManageBtn = $("envManageBtn"); +const envManageModal = $("envManageModal"); +const envManageList = $("envManageList"); +const envManageWarn = $("envManageWarn"); +const envManageCloseX = $("envManageCloseX"); +const envManageAdd = $("envManageAdd"); + +export function initEnvSection(ctx: PopupCtx) { + async function renderEnvSelect() { + const { environments, activeEnvId } = await loadEnvironments(); + envSelect.innerHTML = ""; + environments.forEach((env) => { + const opt = document.createElement("option"); + opt.value = env.id; + opt.textContent = `${env.name} (${env.configs?.length || 0})`; + if (env.id === activeEnvId) opt.selected = true; + envSelect.appendChild(opt); + }); + } + + envSelect.addEventListener("change", async () => { + await setActiveEnv(envSelect.value); + ctx.resetForm(); + await ctx.renderConfigList(); + ctx.showToast(t("toastSwitchEnv")); + }); + + // 环境管理对话框 + function openEnvManageModal() { + envManageWarn.textContent = ""; + envManageWarn.style.display = "none"; + renderEnvManageList(); + envManageModal.classList.add("show"); + } + + function closeEnvManageModal() { + envManageModal.classList.remove("show"); + } + + async function renderEnvManageList() { + const { environments, activeEnvId } = await loadEnvironments(); + envManageList.innerHTML = ""; + + if (environments.length === 0) { + envManageList.innerHTML = `
${t("envManageEmpty")}
`; + return; + } + + environments.forEach((env) => { + const row = document.createElement("div"); + row.className = "env-manage-row" + (env.id === activeEnvId ? " active" : ""); + + const nameSpan = document.createElement("span"); + nameSpan.className = "env-manage-name"; + const cnt = env.configs?.length || 0; + const activeMark = env.id === activeEnvId ? `${t("envCurrentMark")}` : ""; + nameSpan.innerHTML = `${escapeHtml(env.name)} (${cnt}) ${activeMark}`; + + const actions = document.createElement("div"); + actions.className = "env-manage-actions"; + + const editBtn = document.createElement("button"); + editBtn.className = "btn-icon"; + editBtn.title = t("renameBtn"); + editBtn.innerHTML = ''; + editBtn.addEventListener("click", async () => { + const newName = await openEnvModal("rename", env.name); + if (!newName) return; + try { + await renameEnvironment(env.id, newName); + await renderEnvManageList(); + await renderEnvSelect(); + ctx.showToast(t("toastRenamed")); + } catch (err) { + ctx.showToast((err as Error).message || t("toastRenameFail")); + } + }); + + const delBtn = document.createElement("button"); + delBtn.className = "btn-icon"; + delBtn.title = t("deleteBtn"); + 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); + if (!confirm(msg)) return; + try { + await deleteEnvironment(env.id); + await renderEnvManageList(); + await renderEnvSelect(); + ctx.resetForm(); + await ctx.renderConfigList(); + ctx.showToast(t("toastEnvDeleted")); + } catch (err) { + ctx.showToast((err as Error).message || t("toastDeleteFail")); + } + }); + + actions.appendChild(editBtn); + actions.appendChild(delBtn); + row.appendChild(nameSpan); + row.appendChild(actions); + envManageList.appendChild(row); + }); + } + + envManageBtn.addEventListener("click", openEnvManageModal); + envManageCloseX.addEventListener("click", closeEnvManageModal); + + envManageAdd.addEventListener("click", async () => { + const name = await openEnvModal("create"); + if (!name) return; + try { + await createEnvironment(name); + await renderEnvManageList(); + await renderEnvSelect(); + ctx.resetForm(); + await ctx.renderConfigList(); + ctx.showToast(t("toastEnvCreated", name as string)); + } catch (err) { + ctx.showToast((err as Error).message || t("toastCreateFail")); + } + }); + + function refreshEnvManage() { + if (envManageModal.classList.contains("show")) renderEnvManageList(); + } + + // 点击页面其他区域收起配置项的"三个点"菜单 + document.addEventListener("click", (e) => { + if (!(e.target as HTMLElement).closest(".config-actions")) closeAllMenus(); + }); + + return { renderEnvSelect, refreshEnvManage }; +} diff --git a/src/popup/fill-current.ts b/src/popup/fill-current.ts new file mode 100644 index 0000000..8ac5047 --- /dev/null +++ b/src/popup/fill-current.ts @@ -0,0 +1,98 @@ +/** + * "在当前页面填充"按钮:跨所有环境匹配当前标签页域名, + * 按需用 chrome.scripting 注入 content script 后再发起手动填充。 + * + * 为什么不再依赖静态注入:manifest 里已移除 content_scripts(原来 无条件 + * 注入所有页面)。点击扩展图标本身会授予 activeTab,因此这条路径无需广域 host 权限, + * 未配置过的站点也不会被执行任何脚本。 + */ +import { t } from "../i18n.js"; +import { $ } from "./dom"; +import type { PopupCtx } from "./context"; + +/** content script 已注入的标记,定义在 content.ts 顶部 */ +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); +} + +/** 需要时才注入 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"], + }); +} + +/** 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)) { + ctx.showToast(t("toastScriptNotInjected")); + } else { + ctx.showToast(t("toastSendFail", msg)); + } +} + +export function initFillCurrent(ctx: PopupCtx) { + const fillCurrentBtn = $("fillCurrent"); + + fillCurrentBtn.addEventListener("click", async () => { + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + if (!tab || tab.id === undefined) { + ctx.showToast(t("toastNoTab")); + return; + } + + // 跨所有环境查找匹配当前标签页的配置 + const allConfigs = await EnvStore.loadAllConfigs(); + const matched = allConfigs.filter((c) => isDomainMatch(tab.url, c.domain)); + + if (matched.length === 0) { + ctx.showToast(t("toastNoMatch")); + return; + } + + if (matched.length === 1) { + try { + await ensureContentScript(tab.id); + const resp: any = await chrome.tabs.sendMessage(tab.id, { + action: "manualFill", + config: matched[0], + }); + if (resp && (resp as any).success) { + ctx.showToast(t("toastFilled")); + } else if (resp && resp.diag) { + const d = resp.diag; + ctx.showToast(`input=${d.total} pwd=${d.pwd} form=${d.forms} | ${d.sample.join(", ")}`); + } else { + ctx.showToast(t("toastFilledNoForm")); + } + } catch (err) { + toastSendError(ctx, err); + } + return; + } + + try { + await ensureContentScript(tab.id); + await chrome.tabs.sendMessage(tab.id, { + action: "manualFillMulti", + configs: matched, + tabUrl: tab.url, + }); + ctx.showToast(t("toastMultiMatch", String(matched.length))); + } catch (err) { + toastSendError(ctx, err); + } + }); +} diff --git a/src/popup/lock.ts b/src/popup/lock.ts new file mode 100644 index 0000000..e481190 --- /dev/null +++ b/src/popup/lock.ts @@ -0,0 +1,159 @@ +/** + * 锁定 / 解锁界面与顶栏锁定按钮。 + * 解锁成功后通过 ctx 进入主界面(恢复草稿 + 渲染环境与配置列表)。 + */ +import { t } from "../i18n.js"; +import { sendMessage } from "../messaging.js"; +import type { LockMode } from "../types.js"; +import { $ } from "./dom"; +import type { PopupCtx } from "./context"; + +const lockScreen = $("lockScreen"); +const mainContainer = $("mainContainer"); +const lockTitle = $("lockTitle"); +const lockHint = $("lockHint"); +const lockPassword = $("lockPassword"); +const lockPasswordConfirm = $("lockPasswordConfirm"); +const lockConfirmGroup = $("lockConfirmGroup"); +const lockSubmitBtn = $("lockSubmitBtn"); +const lockWarn = $("lockWarn"); +const lockBtn = $("lockBtn"); + +export function initLock(ctx: PopupCtx) { + let lockMode: LockMode = "setup"; // "setup" | "unlock" + let lockAfterSetup = false; + + function showLockScreen(mode: LockMode): void { + lockMode = mode; + lockScreen.style.display = "flex"; + mainContainer.style.display = "none"; + lockPassword.value = ""; + lockPasswordConfirm.value = ""; + lockWarn.textContent = ""; + lockWarn.style.display = "none"; + + if (mode === "setup") { + lockTitle.textContent = t("lockSetupTitle"); + lockHint.textContent = t("lockSetupHint"); + lockConfirmGroup.style.display = "block"; + lockSubmitBtn.textContent = t("lockSetupBtn"); + } else { + lockTitle.textContent = t("lockUnlockTitle"); + lockHint.textContent = t("lockUnlockHint"); + lockConfirmGroup.style.display = "none"; + lockSubmitBtn.textContent = t("lockUnlockBtn"); + } + setTimeout(() => lockPassword.focus(), 50); + } + + function showMainUI() { + lockScreen.style.display = "none"; + mainContainer.style.display = "flex"; + } + + async function enterMain() { + showMainUI(); + await ctx.loadDraft(); + await ctx.renderEnvSelect(); + await ctx.renderConfigList(); + } + + async function initLockState() { + try { + const resp = await sendMessage({ action: "getVaultStatus" }); + lockBtn.hidden = false; + if (!(resp as any).hasVault) { + // 无 vault:明文模式,直接进入,不锁定 + await enterMain(); + } else if (!(resp as any).unlocked) { + showLockScreen("unlock"); + } else { + await enterMain(); + } + } catch (err) { + console.warn("[AutoLogin]", t("logGetLockStatusFail"), err); + lockBtn.hidden = false; + await enterMain(); + } + } + + lockSubmitBtn.addEventListener("click", async () => { + const pwd = lockPassword.value; + if (!pwd) { + lockWarn.textContent = t("lockWarnEmpty"); + lockWarn.style.display = "block"; + return; + } + if (lockMode === "setup") { + if (pwd.length < 6) { + lockWarn.textContent = t("lockWarnShort"); + lockWarn.style.display = "block"; + return; + } + if (pwd !== lockPasswordConfirm.value) { + lockWarn.textContent = t("lockWarnMismatch"); + lockWarn.style.display = "block"; + return; + } + try { + await sendMessage({ action: "setupVault", password: pwd }); + ctx.showToast(t("toastPasswordSet")); + if (lockAfterSetup) { + lockAfterSetup = false; + await sendMessage({ action: "lock" }); + showLockScreen("unlock"); + ctx.showToast(t("toastLocked")); + } else { + await enterMain(); + } + } catch (err) { + lockWarn.textContent = t("lockWarnSetupFail", (err as Error).message || String(err)); + lockWarn.style.display = "block"; + } + } else { + try { + const resp = await sendMessage({ action: "unlock", password: pwd }); + if ((resp as any).success) { + ctx.showToast(t("toastUnlocked")); + await enterMain(); + } else { + lockWarn.textContent = (resp as any).error || t("lockWarnUnlockFail", ""); + lockWarn.style.display = "block"; + } + } catch (err) { + lockWarn.textContent = t("lockWarnUnlockFail", (err as Error).message || String(err)); + lockWarn.style.display = "block"; + } + } + }); + + lockPassword.addEventListener("keydown", (e: KeyboardEvent) => { + if (e.key === "Enter") lockSubmitBtn.click(); + }); + lockPasswordConfirm.addEventListener("keydown", (e: KeyboardEvent) => { + if (e.key === "Enter") lockSubmitBtn.click(); + }); + + lockBtn.addEventListener("click", async () => { + const resp = await sendMessage({ action: "getVaultStatus" }).catch(() => null); + if (resp && !(resp as any).hasVault) { + // 明文模式:先设密码再锁定 + lockAfterSetup = true; + showLockScreen("setup"); + return; + } + if (!confirm(t("lockConfirmPrompt"))) return; + await sendMessage({ action: "lock" }); + showLockScreen("unlock"); + ctx.showToast(t("toastLocked")); + }); + + // 弹窗内的点击视为用户活动,重置 background 自动锁定计时 + document.addEventListener("click", () => { + if (lockScreen.style.display === "none") { + sendMessage({ action: "ping" }, 1).catch(() => {}); + } + }); + + return { initLockState }; +} diff --git a/src/popup/main.ts b/src/popup/main.ts new file mode 100644 index 0000000..10cf998 --- /dev/null +++ b/src/popup/main.ts @@ -0,0 +1,53 @@ +/** + * popup 入口:组装各模块并建立共享上下文(PopupCtx)。 + * 模块间不互相 import,统一通过 ctx 互调,避免循环依赖。 + */ +import { t, applyI18n } from "../i18n.js"; +import type { DomainConfig } from "../types.js"; +import type { PopupCtx } from "./context"; +import { showToast } from "./toast"; +import { initTogglePwd } from "./toggle-pwd"; +import { initConfigForm } from "./config-form"; +import { initEditConfigModal } from "./config-edit-modal"; +import { initEnvSection } from "./env-section"; +import { initConfigList } from "./config-list"; +import { initTransfer } from "./transfer"; +import { initBookmarkImport } from "./bookmark-import"; +import { initFillCurrent } from "./fill-current"; +import { initLock } from "./lock"; + +applyI18n(); + +const versionLabel = document.getElementById("versionLabel"); +if (versionLabel) versionLabel.textContent = "v" + chrome.runtime.getManifest().version; + +initTogglePwd(); + +// 各模块初始化后返回的 API +let formApi: ReturnType; +let envApi: ReturnType; +let listApi: ReturnType; +let editApi: 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), +}; + +formApi = initConfigForm(ctx); +editApi = initEditConfigModal(ctx); +envApi = initEnvSection(ctx); +listApi = initConfigList(ctx); +initTransfer(ctx); +initBookmarkImport(ctx); +initFillCurrent(ctx); + +const lockApi = initLock(ctx); +lockApi.initLockState(); diff --git a/src/popup/pwd-modal.ts b/src/popup/pwd-modal.ts new file mode 100644 index 0000000..52efb74 --- /dev/null +++ b/src/popup/pwd-modal.ts @@ -0,0 +1,149 @@ +/** + * 文件密码弹窗:导出时显示加密勾选 + 两次输入(含强度条),导入时只输入一次。 + * openPwdModal 返回 Promise,确认后 resolve({ encrypt, password }),取消 resolve(null)。 + */ +import { t } from "../i18n.js"; +import { estimateStrength, formatTime } from "../strength.js"; +import type { PwdModalMode } from "../types.js"; +import { $ } from "./dom"; + +const pwdModal = $("pwdModal"); +const modalTitle = $("modalTitle"); +const encryptToggle = $("encryptToggle"); +const encryptToggleGroup = $("encryptToggleGroup"); +const pwdFields = $("pwdFields"); +const confirmGroup = $("confirmGroup"); +const modalPwd = $("modalPwd"); +const modalPwdConfirm = $("modalPwdConfirm"); +const modalWarn = $("modalWarn"); +const modalCancel = $("modalCancel"); +const modalConfirm = $("modalConfirm"); +const strengthBlock = $("strengthBlock"); +const strengthBar = $("strengthBar"); +const strengthLabel = $("strengthLabel"); +const crackTime = $("crackTime"); + +let modalResolve: ((value: any) => void) | null = null; + +function updateStrength(pwd: string): void { + const { entropy, seconds, level, label } = estimateStrength(pwd); + // 5 档进度条宽度,对数缩放避免弱口令条太短看不见 + const widthMap = { + "empty": 0, + "very-weak": 15, + "weak": 30, + "medium": 55, + "strong": 80, + "very-strong": 100, + }; + strengthBar.style.width = widthMap[level] + "%"; + strengthBar.className = "strength-bar-fg " + level; + strengthLabel.textContent = pwd ? t("strengthLabel", label, entropy.toFixed(1)) : t("strengthEmpty"); + crackTime.textContent = pwd ? t("crackTimeLabel", formatTime(seconds)) : ""; +} + +modalPwd.addEventListener("input", () => { + if (strengthBlock.style.display !== "none") updateStrength(modalPwd.value); +}); + +export function openPwdModal(mode: PwdModalMode): Promise { + return new Promise((resolve) => { + modalResolve = resolve; + if (mode === "export") { + modalTitle.textContent = t("pwdModalExportTitle"); + encryptToggleGroup.style.display = ""; + confirmGroup.style.display = ""; + strengthBlock.style.display = ""; + encryptToggle.checked = true; + pwdFields.classList.remove("hidden"); + modalPwd.value = ""; + modalPwdConfirm.value = ""; + modalWarn.textContent = t("pwdModalExportWarn"); + } else { + modalTitle.textContent = t("pwdModalImportTitle"); + encryptToggleGroup.style.display = "none"; + confirmGroup.style.display = "none"; + strengthBlock.style.display = "none"; + pwdFields.classList.remove("hidden"); + modalPwd.value = ""; + modalPwdConfirm.value = ""; + modalWarn.textContent = ""; + } + pwdModal.classList.add("show"); + setTimeout(() => modalPwd.focus(), 0); + }); +} + +function closePwdModal() { + pwdModal.classList.remove("show"); + modalPwd.value = ""; + modalPwdConfirm.value = ""; + modalResolve = null; + updateStrength(""); +} + +encryptToggle.addEventListener("change", () => { + if (encryptToggle.checked) { + pwdFields.classList.remove("hidden"); + updateStrength(modalPwd.value); + } else { + pwdFields.classList.add("hidden"); + updateStrength(""); + } +}); + +modalCancel.addEventListener("click", () => { + if (modalResolve) modalResolve(null); + closePwdModal(); +}); + +modalConfirm.addEventListener("click", () => { + if (!modalResolve) return; + + const isExport = modalTitle.textContent === t("pwdModalExportTitle"); + if (isExport) { + if (encryptToggle.checked) { + const p1 = modalPwd.value; + const p2 = modalPwdConfirm.value; + if (!p1) { + modalWarn.textContent = t("warnFilePwdEmpty"); + return; + } + if (p1 !== p2) { + modalWarn.textContent = t("warnFilePwdMismatch"); + return; + } + modalResolve({ encrypt: true, password: p1 }); + } else { + modalResolve({ encrypt: false, password: null }); + } + } else { + const p = modalPwd.value; + if (!p) { + modalWarn.textContent = t("warnFilePwdEmpty"); + return; + } + modalResolve({ encrypt: true, password: p }); + } + closePwdModal(); +}); + +modalPwd.addEventListener("keydown", (e: KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + if (confirmGroup.style.display === "none") { + modalConfirm.click(); + } else if (modalPwdConfirm.value === "") { + modalPwdConfirm.focus(); + } else { + modalConfirm.click(); + } + } +}); + +modalPwdConfirm.addEventListener("keydown", (e: KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + modalConfirm.click(); + } +}); diff --git a/src/popup/toast.ts b/src/popup/toast.ts new file mode 100644 index 0000000..6007bea --- /dev/null +++ b/src/popup/toast.ts @@ -0,0 +1,8 @@ +/** 轻提示 toast */ +export function showToast(msg: string): void { + const toast = document.getElementById("toast"); + if (!toast) return; + toast.textContent = msg; + toast.classList.add("show"); + setTimeout(() => toast.classList.remove("show"), 2000); +} diff --git a/src/popup/toggle-pwd.ts b/src/popup/toggle-pwd.ts new file mode 100644 index 0000000..e848545 --- /dev/null +++ b/src/popup/toggle-pwd.ts @@ -0,0 +1,20 @@ +/** 密码输入框"眼睛"按钮:切换明文/密文显示 */ +export function initTogglePwd(): void { + document.querySelectorAll(".toggle-pwd").forEach((btn) => { + btn.addEventListener("click", () => { + const target = document.getElementById((btn as HTMLElement).dataset.target || "") as HTMLInputElement; + if (!target) return; + if (target.type === "password") { + target.type = "text"; + btn.classList.remove("eye-open"); + btn.classList.add("eye-closed"); + btn.textContent = "🙈"; + } else { + target.type = "password"; + btn.classList.remove("eye-closed"); + btn.classList.add("eye-open"); + btn.textContent = "👁"; + } + }); + }); +} diff --git a/src/popup/transfer.ts b/src/popup/transfer.ts new file mode 100644 index 0000000..e7f4a60 --- /dev/null +++ b/src/popup/transfer.ts @@ -0,0 +1,222 @@ +/** + * 配置导入 / 导出。 + * 导出:整个 environments 结构(含环境名),可选文件密码 AES-GCM 加密。 + * 导入:兼容 v1(数组) / v2(configs) / v3(environments),加密则弹口令 modal。 + */ +import { t } from "../i18n.js"; +import { encryptConfigs, decryptConfigs } from "../crypto-file.js"; +import type { DomainConfig, Environment } from "../types.js"; +import { $ } from "./dom"; +import type { PopupCtx } from "./context"; +import { openPwdModal } from "./pwd-modal"; + +const exportBtn = $("exportBtn"); +const importBtn = $("importBtn"); +const importFile = $("importFile"); + +export function initTransfer(ctx: PopupCtx) { + const { loadEnvironments, saveEnvironments, genId, genCfgId } = EnvStore; + + // 导出整个 environments 结构(含环境名) + exportBtn.addEventListener("click", async () => { + const { environments } = await loadEnvironments(); + const totalConfigs = environments.reduce((s, e) => s + (e.configs?.length || 0), 0); + if (totalConfigs === 0) { + ctx.showToast(t("toastNoExportable")); + return; + } + const choice = await openPwdModal("export"); + if (!choice) { + ctx.showToast(t("toastExportCancelled")); + return; + } + const stamp = new Date().toISOString().replace(/[:T]/g, "").slice(0, 15); + let payload; + let filename; + if ((choice as any).encrypt) { + const cipher = await encryptConfigs(environments, (choice as any).password); + payload = { + app: "AutoLoginManager", + version: 3, + exportedAt: new Date().toISOString(), + encrypted: true, + cipher, + }; + filename = `auto-login-configs-${stamp}.enc.json`; + } else { + payload = { + app: "AutoLoginManager", + version: 3, + exportedAt: new Date().toISOString(), + encrypted: false, + environments, + }; + filename = `auto-login-configs-${stamp}.json`; + } + const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + ctx.showToast(t("toastExported", String(environments.length), String(totalConfigs), (choice as any).encrypt ? t("encryptedMark") : "")); + }); + + importBtn.addEventListener("click", () => importFile.click()); + + importFile.addEventListener("change", async (e: Event) => { + const file = (e.target as HTMLInputElement).files?.[0]; + if (!file) return; + try { + const text = await file.text(); + const data = JSON.parse(text); + let incomingEnvironments: Environment[] | null = null; + let incomingConfigs: DomainConfig[] | null = null; + + // 1. v1: 直接数组格式 + if (Array.isArray(data)) { + incomingConfigs = data; + } + // 2. 加密格式 + else if (data?.encrypted === true && data?.cipher) { + const choice = await openPwdModal("import"); + if (!choice) { + ctx.showToast(t("toastImportCancelled")); + importFile.value = ""; + return; + } + try { + const decrypted = await decryptConfigs(data.cipher, (choice as any).password); + // 解密后可能是 v3 (environments 数组/对象) 或 v1 (configs 数组) + if (Array.isArray(decrypted)) { + // 区分环境数组(v3, 元素含 configs)与配置数组(v1, 元素含 domain) + if (decrypted.some((e) => e && Array.isArray(e.configs))) { + incomingEnvironments = decrypted; + } else { + incomingConfigs = decrypted; + } + } else if (Array.isArray(decrypted?.environments)) { + incomingEnvironments = decrypted.environments; + } else if (Array.isArray(decrypted?.configs)) { + incomingConfigs = decrypted.configs; + } else { + ctx.showToast(t("toastInvalidFormat")); + importFile.value = ""; + return; + } + } catch (err) { + ctx.showToast(t("toastDecryptFail")); + importFile.value = ""; + return; + } + } + // 3. v3 明文:含 environments + else if (Array.isArray(data?.environments)) { + incomingEnvironments = data.environments; + } + // 4. v2 明文:含 configs + else if (Array.isArray(data?.configs)) { + incomingConfigs = data.configs; + } else { + ctx.showToast(t("toastFileInvalid")); + importFile.value = ""; + return; + } + + const { environments: existingEnvs, activeEnvId } = await loadEnvironments(); + + // 分支 A:导入 v3 环境结构 —— 按"环境名"匹配,已存在的环境跳过,新的环境整批导入 + if (incomingEnvironments) { + const existingNames = new Set(existingEnvs.map((e) => e.name)); + let addedEnvs = 0; + let addedCfgs = 0; + let skippedCfgs = 0; + for (const env of incomingEnvironments) { + if (!env || !env.name) { + skippedCfgs += (env?.configs?.length) || 0; + continue; + } + if (existingNames.has(env.name)) { + // 同名环境:跳过整个环境 + skippedCfgs += (env.configs?.length) || 0; + continue; + } + existingNames.add(env.name); + const newEnv: Environment = { + id: genId(), + name: env.name, + configs: [], + }; + for (const cfg of (env.configs || [])) { + if (!cfg || !cfg.domain || !cfg.username || !cfg.password) { + skippedCfgs++; + continue; + } + newEnv.configs.push({ + id: cfg.id || genCfgId(), + domain: cfg.domain, + alias: cfg.alias || null, + username: cfg.username, + password: cfg.password, + autoSubmit: cfg.autoSubmit !== false, + usernameSelector: cfg.usernameSelector || null, + passwordSelector: cfg.passwordSelector || null, + submitSelector: cfg.submitSelector || null, + enabled: cfg.enabled !== false, + delayMs: cfg.delayMs || null, + }); + addedCfgs++; + } + existingEnvs.push(newEnv); + addedEnvs++; + } + await saveEnvironments(existingEnvs, activeEnvId ?? undefined); + await ctx.renderEnvSelect(); + await ctx.renderConfigList(); + ctx.showToast(t("toastImportedEnvs", String(addedEnvs), String(addedCfgs), String(skippedCfgs))); + return; + } + + // 分支 B:导入 v1/v2 配置数组 —— 合并到当前激活环境,相同 domain 跳过 + const curEnv = existingEnvs.find((e) => e.id === activeEnvId) || existingEnvs[0]; + const seenDomains = new Set(curEnv.configs.map((c) => c.domain)); + let added = 0; + let skipped = 0; + for (const cfg of incomingConfigs || []) { + if (!cfg || !cfg.domain || !cfg.username || !cfg.password) { + skipped++; + continue; + } + if (seenDomains.has(cfg.domain)) { + skipped++; + continue; + } + seenDomains.add(cfg.domain); + curEnv.configs.push({ + id: cfg.id || genCfgId(), + domain: cfg.domain, + alias: cfg.alias || null, + username: cfg.username, + password: cfg.password, + autoSubmit: cfg.autoSubmit !== false, + usernameSelector: cfg.usernameSelector || null, + passwordSelector: cfg.passwordSelector || null, + submitSelector: cfg.submitSelector || null, + enabled: cfg.enabled !== false, + delayMs: cfg.delayMs || null, + }); + added++; + } + await saveEnvironments(existingEnvs); + await ctx.renderConfigList(); + ctx.showToast(t("toastImportedCfgs", String(added), String(skipped))); + } catch (err) { + ctx.showToast(t("toastImportFail", (err as Error).message || "parse error")); + } finally { + importFile.value = ""; + } + }); +} diff --git a/src/utils.ts b/src/utils.ts index cc68bb8..ff790cb 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -6,6 +6,7 @@ const api = factory(); root.isDomainMatch = api.isDomainMatch; root.escapeHtml = api.escapeHtml; + root.toMatchPatterns = api.toMatchPatterns; } })(typeof globalThis !== "undefined" ? globalThis : this, function () { "use strict"; @@ -76,5 +77,46 @@ .replace(/'/g, "'"); } - return { isDomainMatch, escapeHtml }; + /** + * 把配置里的域名规则转成 Chrome match pattern,用于"按需注册 content script", + * 让脚本只注入用户配置过的域名(而不是全网站无条件注入)。 + * + * match pattern 的硬限制:不支持端口、路径里的 * 只能出现在结尾。因此这里的策略是 + * "宁可放宽、不做漏配"——路径中第一个 * 之后的部分一律截断,最终匹配精度仍由 + * content script 内的 isDomainMatch 把关(注入范围放宽不等于会误填)。 + * 无法表达的规则返回空数组,该域名退化为只能手动填充。 + */ + function toMatchPatterns(pattern: string | null | undefined): string[] { + let raw = String(pattern == null ? "" : pattern).trim(); + if (!raw) return []; + raw = raw.replace(/^\*:\/\//i, "").replace(/^https?:\/\//i, ""); + raw = raw.split(/[?#]/)[0]; + if (!raw) return []; + + let host = raw; + let path = ""; + const slash = raw.indexOf("/"); + if (slash !== -1) { + host = raw.slice(0, slash); + path = raw.slice(slash); + } + + // 归一化:去掉端口、中文域名转 punycode,顺便过滤掉非法字符(如 IPv6 方括号) + try { + host = new URL("http://" + host).hostname; + } catch { + return []; + } + if (!host || /[^a-z0-9.*-]/i.test(host)) return []; + // match pattern 的 host 只支持 *.example.com 这一种通配形式 + if (host.includes("*") && !(host.startsWith("*.") && !host.slice(2).includes("*"))) return []; + + if (path.includes("*")) path = path.slice(0, path.indexOf("*") + 1); + if (!path) path = "/*"; + else if (!path.endsWith("*")) path += "*"; + + return ["http://" + host + path, "https://" + host + path]; + } + + return { isDomainMatch, escapeHtml, toMatchPatterns }; }); diff --git a/tests/content-fill.test.js b/tests/content-fill.test.js new file mode 100644 index 0000000..de60bc7 --- /dev/null +++ b/tests/content-fill.test.js @@ -0,0 +1,476 @@ +// tests/content-fill.test.js +// 用 jsdom(真实 DOM)测试 content.js 的登录表单探测与填充引擎: +// 字段猜测、隐藏元素跳过、Shadow DOM 穿透、HTML 片段定位、 +// 自动提交/验证码拦截、失败次数限制、多环境浮窗、SPA 重试、手动填充消息。 +// 运行:node --test tests/content-fill.test.js +// +// jsdom 不实现布局(offsetWidth/offsetParent 恒为 0/null),因此这里统一把元素视为"可见", +// 需要模拟隐藏时用 hide() 单独覆盖 —— 这样能真实覆盖 content.js 里的可见性判断分支。 + +const { describe, it } = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const { JSDOM } = require("jsdom"); + +const UTILS_CODE = fs.readFileSync(path.join(__dirname, "..", "dist", "utils.js"), "utf8"); +const CONTENT_CODE = fs.readFileSync(path.join(__dirname, "..", "dist", "content.js"), "utf8"); + +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 = {} } = {}) { + const state = { + configs, + attempts: new Map(Object.entries(attempts)), + calls: [], + messageListener: null, + }; + + const api = { + runtime: { + id: "mock-extension-id", + onMessage: { addListener: (fn) => { state.messageListener = fn; } }, + sendMessage: (message) => { + state.calls.push(message.action); + switch (message.action) { + case "loadAllConfigs": + return Promise.resolve({ configs: state.configs }); + case "getVaultStatus": + return Promise.resolve({ hasVault: false, unlocked: true, pendingImport: false }); + case "getDomainAttempts": { + const key = `${message.envId}::${message.domain}`; + return Promise.resolve({ attempts: state.attempts.get(key) || { count: 0, lastAttempt: 0 } }); + } + case "incrementDomainAttempts": { + const key = `${message.envId}::${message.domain}`; + const cur = state.attempts.get(key) || { count: 0, lastAttempt: 0 }; + cur.count += 1; + cur.lastAttempt = Date.now(); + state.attempts.set(key, cur); + return Promise.resolve({ attempts: cur }); + } + case "resetDomainAttempts": + return Promise.resolve({ success: true }); + default: + return Promise.resolve({}); + } + }, + }, + i18n: { getMessage: (key) => key }, + storage: { + local: { get: async () => ({}), set: async () => {}, remove: async () => {} }, + session: { get: async () => ({}), set: async () => {}, remove: async () => {} }, + }, + }; + + return { api, state }; +} + +/** 在 jsdom 页面里执行 utils.js + content.js(与浏览器一致:按普通脚本顺序执行) */ +function loadContentPage({ html = "", url = "https://example.com/login", configs = [], attempts = {} } = {}) { + 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); + + const chrome = createChromeMock({ configs, attempts }); + win.chrome = chrome.api; + + const inject = (code) => { + const el = win.document.createElement("script"); + el.textContent = code; + win.document.head.appendChild(el); + }; + inject(UTILS_CODE); + inject(CONTENT_CODE); + + return { dom, win, doc: win.document, chrome }; +} + +/** 模拟元素不可见(隐藏域 / display:none) */ +function hide(el) { + Object.defineProperty(el, "offsetWidth", { get: () => 0, configurable: true }); + Object.defineProperty(el, "offsetParent", { get: () => null, configurable: true }); + return el; +} + +function makeConfig(overrides = {}) { + return { + id: "cfg1", + envId: "env1", + envName: "测试", + domain: "example.com", + alias: null, + username: "auto-user", + password: "auto-pass", + autoSubmit: false, + usernameSelector: null, + passwordSelector: null, + submitSelector: null, + enabled: true, + delayMs: null, + ...overrides, + }; +} + +describe("content - 字段探测", () => { + it("标准登录表单:用户名 / 密码 / 提交按钮都能找到", () => { + const ctx = loadContentPage({ + html: `
+ + + +
`, + }); + + 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, "登录"); + }); + + it("显式选择器优先于自动探测", () => { + const ctx = loadContentPage({ + html: `
+ + + + +
`, + }); + + const config = { passwordSelector: "#right", usernameSelector: "#u2" }; + assert.equal(ctx.win.findPasswordField(config).id, "right"); + assert.equal(ctx.win.findUsernameField(config).id, "u2"); + }); + + it("用户名按 name / placeholder 关键词猜测", () => { + const ctx = loadContentPage({ + html: `
+ + + +
`, + }); + assert.equal(ctx.win.findUsernameField({}).id, "account"); + }); + + it("隐藏的密码框被跳过,选可见的那个", () => { + const ctx = loadContentPage({ + html: `
+ + +
`, + }); + hide(ctx.doc.getElementById("ghost")); + assert.equal(ctx.win.findPasswordField({}).id, "real"); + }); + + it("页面上没有密码框时返回 null,fillForm 返回 false", () => { + const ctx = loadContentPage({ html: `
` }); + assert.equal(ctx.win.findPasswordField({}), null); + assert.equal(ctx.win.fillForm({ username: "u", password: "p", autoSubmit: false }, false), false); + }); + + it("提交按钮按文案识别(非 type=submit)", () => { + const ctx = loadContentPage({ + html: `
`, + }); + assert.equal(ctx.win.findSubmitButton({}, ctx.doc.querySelector("input[type=password]")).id, "btn"); + }); + + it("能穿透 Shadow DOM 找到登录框", () => { + const ctx = loadContentPage({ html: `
` }); + const shadow = ctx.doc.getElementById("host").attachShadow({ mode: "open" }); + shadow.innerHTML = `
+ + + +
`; + + assert.equal(ctx.win.findPasswordField({}).getAttribute("name"), "pass"); + assert.equal(ctx.win.findUsernameField({}).getAttribute("name"), "user"); + }); + + it("resolveElement 支持选择器与整段 HTML 片段两种写法", () => { + const ctx = loadContentPage({ + html: `
`, + }); + assert.equal(ctx.win.resolveElement("input[name=pass]").id, "pwd"); + // 从 HTML 片段反推选择器(用户直接粘贴元素代码的场景) + assert.equal(ctx.win.resolveElement('').id, "pwd"); + }); +}); + +describe("content - 填充与提交", () => { + it("填充用户名和密码,并对两个字段派发 input/change 事件", () => { + const ctx = loadContentPage({ + html: `
+ + + +
`, + }); + + const events = []; + for (const id of ["u", "p"]) { + const el = ctx.doc.getElementById(id); + el.addEventListener("input", () => events.push(`${id}:input`)); + el.addEventListener("change", () => events.push(`${id}:change`)); + } + + assert.equal(ctx.win.fillForm({ username: "alice", password: "secret", autoSubmit: false }, false), true); + assert.equal(ctx.doc.getElementById("u").value, "alice"); + assert.equal(ctx.doc.getElementById("p").value, "secret"); + assert.deepEqual(events, ["u:input", "u:change", "p:input", "p:change"]); + }); + + it("autoSubmit 为真时延迟点击提交按钮", async () => { + const ctx = loadContentPage({ + html: `
`, + }); + const btn = ctx.doc.getElementById("btn"); + let clicks = 0; + btn.addEventListener("click", () => clicks++); + + ctx.win.fillForm({ username: "u", password: "p", autoSubmit: true }, true); + assert.equal(clicks, 0, "不应立即点击"); + + await tick(AFTER_SUBMIT); + assert.equal(clicks, 1); + }); + + it("检测到验证码时不自动提交,并弹出提示", async () => { + const ctx = loadContentPage({ + html: `
+ + +
+ +
`, + }); + const btn = ctx.doc.getElementById("btn"); + let clicks = 0; + btn.addEventListener("click", () => clicks++); + + ctx.win.fillForm({ username: "u", password: "p", autoSubmit: true }, true); + await tick(AFTER_SUBMIT); + + assert.equal(clicks, 0, "有验证码时不能自动提交"); + assert.ok(ctx.doc.body.innerHTML.includes("captchaToastTitle"), "应显示验证码提示浮窗"); + }); + + it("没有提交按钮但在 form 内时回退到 requestSubmit", async () => { + const ctx = loadContentPage({ + html: `
`, + }); + let submitted = 0; + const form = ctx.doc.querySelector("form"); + Object.defineProperty(form, "requestSubmit", { value: () => { submitted++; }, configurable: true }); + + ctx.win.fillForm({ username: "u", password: "p", autoSubmit: true }, true); + await tick(AFTER_SUBMIT); + assert.equal(submitted, 1); + }); +}); + +describe("content - 自动填充主流程", () => { + const LOGIN_HTML = `
+ + + +
`; + + it("域名命中配置时自动填充", async () => { + 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"); + assert.equal(ctx.doc.getElementById("p").value, "auto-pass"); + }); + + it("域名不匹配时什么都不做", async () => { + const ctx = loadContentPage({ html: LOGIN_HTML, url: "https://other.com/login", configs: [makeConfig()] }); + await tick(60); + + assert.equal(ctx.doc.getElementById("u").value, ""); + assert.equal(ctx.doc.getElementById("p").value, ""); + }); + + it("配置被禁用时不填充", async () => { + const ctx = loadContentPage({ html: LOGIN_HTML, configs: [makeConfig({ enabled: false })] }); + await tick(60); + assert.equal(ctx.doc.getElementById("u").value, ""); + }); + + it("未达失败上限时自动提交并累加计数", async () => { + const ctx = loadContentPage({ html: LOGIN_HTML, configs: [makeConfig({ autoSubmit: true })] }); + let clicks = 0; + ctx.doc.getElementById("btn").addEventListener("click", () => clicks++); + + await tick(AFTER_SUBMIT); + assert.equal(ctx.doc.getElementById("u").value, "auto-user"); + assert.equal(clicks, 1); + assert.ok(ctx.chrome.state.calls.includes("incrementDomainAttempts")); + }); + + it("失败次数达到上限后只填充、不自动提交", async () => { + const ctx = loadContentPage({ + html: LOGIN_HTML, + configs: [makeConfig({ autoSubmit: true })], + attempts: { "env1::example.com": { count: 3, lastAttempt: Date.now() } }, + }); + let clicks = 0; + ctx.doc.getElementById("btn").addEventListener("click", () => clicks++); + + await tick(AFTER_SUBMIT); + assert.equal(ctx.doc.getElementById("u").value, "auto-user", "仍然填充"); + assert.equal(clicks, 0, "不应自动提交"); + assert.ok(!ctx.chrome.state.calls.includes("incrementDomainAttempts")); + }); + + it("失败计数超过冷却时间后重新计数并允许提交", async () => { + const ctx = loadContentPage({ + html: LOGIN_HTML, + configs: [makeConfig({ autoSubmit: true })], + attempts: { "env1::example.com": { count: 3, lastAttempt: Date.now() - 10 * 60 * 1000 } }, + }); + let clicks = 0; + ctx.doc.getElementById("btn").addEventListener("click", () => clicks++); + + await tick(AFTER_SUBMIT); + assert.equal(clicks, 1, "冷却后应重新允许提交"); + }); +}); + +describe("content - 多环境命中", () => { + const LOGIN_HTML = `
+ + +
`; + + it("多个环境命中时弹出选择浮窗,点击后填充对应账号", async () => { + const ctx = loadContentPage({ + html: LOGIN_HTML, + configs: [ + makeConfig({ id: "c1", envId: "e1", envName: "个人", username: "personal@x.com" }), + makeConfig({ id: "c2", envId: "e2", envName: "生产", username: "prod@x.com" }), + ], + }); + await tick(60); + + const picker = ctx.doc.getElementById("auto-login-env-picker"); + assert.ok(picker, "应出现多环境选择浮窗"); + assert.equal(ctx.doc.getElementById("u").value, "", "选择前不填充"); + + const buttons = [...picker.querySelectorAll("button")]; + const prodBtn = buttons.find((b) => b.textContent.includes("生产")); + prodBtn.dispatchEvent(new ctx.win.MouseEvent("click", { bubbles: true })); + await tick(60); + + assert.equal(ctx.doc.getElementById("u").value, "prod@x.com"); + assert.equal(ctx.doc.getElementById("auto-login-env-picker"), null, "选择后浮窗关闭"); + }); + + it("点击取消关闭浮窗且不填充", async () => { + const ctx = loadContentPage({ + html: LOGIN_HTML, + configs: [makeConfig({ id: "c1" }), makeConfig({ id: "c2", envId: "e2", envName: "生产" })], + }); + await tick(60); + + const picker = ctx.doc.getElementById("auto-login-env-picker"); + const cancelBtn = [...picker.querySelectorAll("button")].pop(); + cancelBtn.dispatchEvent(new ctx.win.MouseEvent("click", { bubbles: true })); + + assert.equal(ctx.doc.getElementById("auto-login-env-picker"), null); + assert.equal(ctx.doc.getElementById("u").value, ""); + }); +}); + +describe("content - SPA 延迟渲染", () => { + it("表单晚于脚本出现时,重试后仍能填充", async () => { + const ctx = loadContentPage({ + html: `
`, + configs: [makeConfig()], + }); + await tick(80); + assert.equal(ctx.doc.querySelectorAll("input").length, 0, "此时还没有表单"); + + // 模拟 SPA 前端稍后渲染出登录表单 + ctx.doc.getElementById("app").innerHTML = `
+ + +
`; + + await tick(700); // 重试间隔 500ms + assert.equal(ctx.doc.getElementById("u").value, "auto-user"); + }); +}); + +describe("content - 手动填充消息", () => { + const LOGIN_HTML = `
+ + +
`; + + it("manualFill 填充指定配置并回包 success", async () => { + const ctx = loadContentPage({ html: LOGIN_HTML }); + let response = null; + + ctx.chrome.state.messageListener( + { action: "manualFill", config: makeConfig({ username: "manual@x.com" }) }, + {}, + (r) => { response = r; } + ); + await tick(30); + + // 注意:response 由 jsdom realm 创建,跨 realm 不能用 deepEqual(原型不同) + assert.equal(response.success, true); + assert.equal(ctx.doc.getElementById("u").value, "manual@x.com"); + }); + + it("manualFill 找不到表单时回包诊断信息", async () => { + const ctx = loadContentPage({ html: `
没有表单
` }); + let response = null; + + ctx.chrome.state.messageListener( + { action: "manualFill", config: makeConfig() }, + {}, + (r) => { response = r; } + ); + await tick(30); + + assert.equal(response.success, false); + assert.equal(response.diag.total, 0); + assert.equal(response.diag.forms, 0); + }); + + it("manualFillMulti 弹出多环境浮窗", async () => { + const ctx = loadContentPage({ html: LOGIN_HTML }); + let response = null; + + ctx.chrome.state.messageListener( + { action: "manualFillMulti", configs: [makeConfig({ id: "c1" }), makeConfig({ id: "c2", envId: "e2", envName: "生产" })] }, + {}, + (r) => { response = r; } + ); + await tick(30); + + assert.equal(response.success, true); + assert.ok(ctx.doc.getElementById("auto-login-env-picker")); + }); +}); diff --git a/tests/crypto-store.test.js b/tests/crypto-store.test.js index 6005302..14f8fef 100644 --- a/tests/crypto-store.test.js +++ b/tests/crypto-store.test.js @@ -137,6 +137,51 @@ describe("crypto-store - 设置/解锁/锁定", () => { }); }); +describe("crypto-store - unlockWithKey(密钥句柄恢复)", () => { + it("用 setupVault 返回的密钥可重新解锁,无需口令", async () => { + const storage = createMemoryStorage(); + const C = loadCryptoStore(storage); + const key = await C.setupVault("pw", [{ id: "e1", name: "个人", configs: [{ domain: "a.com" }] }]); + await C.lock(); + assert.equal(await C.isUnlocked(), false); + + // 模拟 SW 重启后从 IndexedDB 取回句柄 + const environments = await C.unlockWithKey(key); + assert.equal(environments.length, 1); + assert.equal(environments[0].name, "个人"); + assert.equal(await C.isUnlocked(), true); + assert.ok(storage.sessionStore.environments); + // 全程没有明文口令落进任何存储 + assert.equal(storage.sessionStore.__autoPwd, undefined); + }); + + it("密钥与 vault 不匹配时抛错", async () => { + const storage = createMemoryStorage(); + const C = loadCryptoStore(storage); + await C.setupVault("pw", [{ id: "e1", name: "x", configs: [] }]); + await C.lock(); + + const otherKey = await C.deriveKey("别的口令", C.randomBytes(16), C.PBKDF2_ITERATIONS); + await assert.rejects(() => C.unlockWithKey(otherKey)); + assert.equal(await C.isUnlocked(), false); + }); + + it("未设置口令时 unlockWithKey 抛错", async () => { + const C = loadCryptoStore(createMemoryStorage()); + const key = await C.deriveKey("pw", C.randomBytes(16), C.PBKDF2_ITERATIONS); + await assert.rejects(() => C.unlockWithKey(key), /尚未设置口令/); + }); + + it("重设口令后旧句柄失效(背景会据此丢弃句柄)", async () => { + const storage = createMemoryStorage(); + const C = loadCryptoStore(storage); + const oldKey = await C.setupVault("old-pw", [{ id: "e1", name: "x", configs: [] }]); + await C.setupVault("new-pw", [{ id: "e1", name: "x", configs: [] }]); + await C.lock(); + await assert.rejects(() => C.unlockWithKey(oldKey)); + }); +}); + describe("crypto-store - persistEncrypted", () => { it("用密钥重新加密 environments 写回 local", async () => { const storage = createMemoryStorage(); diff --git a/tests/detect-captcha.test.js b/tests/detect-captcha.test.js index e58b0bd..8fac6c4 100644 --- a/tests/detect-captcha.test.js +++ b/tests/detect-captcha.test.js @@ -42,7 +42,11 @@ function loadContentJs(mockDoc) { document: mockDoc, chrome: { runtime: { + id: "mock-extension-id", onMessage: { addListener() {} }, + // content.js 加载时会自动调用 loadAllConfigs → sendMessage, + // 缺少该 mock 会抛 "_sendMessage is not a function"(未处理拒绝,污染测试结果) + sendMessage: () => Promise.resolve({ configs: [] }), }, // i18n mock:getMessage 返回 key 自身作为文案(fallback) i18n: { getMessage: (key) => key || "" }, diff --git a/tests/key-store.test.js b/tests/key-store.test.js new file mode 100644 index 0000000..7b7206c --- /dev/null +++ b/tests/key-store.test.js @@ -0,0 +1,157 @@ +// tests/key-store.test.js +// 测试 key-store.js:把不可导出的 CryptoKey 句柄存取到 IndexedDB +// 运行:node --test tests/key-store.test.js +// +// 说明:用极简 IndexedDB shim 覆盖模块的异步流程与 API 语义; +// 真实的 structured clone(CryptoKey 句柄持久化)语义由浏览器保证,Node 侧无法完整模拟。 + +const { describe, it, beforeEach } = require("node:test"); +const assert = require("node:assert/strict"); + +function createFakeIndexedDB() { + const databases = new Map(); + + function makeRequest(getResult) { + const req = { result: undefined, error: null, onsuccess: null, onerror: null }; + queueMicrotask(() => { + try { + req.result = getResult(); + if (req.onsuccess) req.onsuccess(); + } catch (err) { + req.error = err; + if (req.onerror) req.onerror(); + } + }); + return req; + } + + function makeDb(name) { + const stores = new Map(); + return { + objectStoreNames: { contains: (n) => stores.has(n) }, + createObjectStore: (n) => { + stores.set(n, new Map()); + return {}; + }, + transaction(storeName) { + const data = stores.get(storeName); + const tx = { error: null, onabort: null }; + tx.objectStore = () => ({ + put: (value, key) => makeRequest(() => { data.set(key, value); return key; }), + get: (key) => makeRequest(() => data.get(key)), + delete: (key) => makeRequest(() => { data.delete(key); return undefined; }), + }); + return tx; + }, + }; + } + + return { + open(name) { + const req = { + 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); + databases.set(name, db); + } + req.result = db; + if (isNew && req.onupgradeneeded) req.onupgradeneeded(); + if (req.onsuccess) req.onsuccess(); + }); + return req; + }, + }; +} + +function loadKeyStore() { + delete require.cache[require.resolve("../dist/key-store.js")]; + return require("../dist/key-store.js"); +} + +async function makeAesKey() { + // extractable = false:这正是 vault 派生密钥的形态 + return crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]); +} + +describe("key-store - 密钥句柄存取", () => { + let K; + + beforeEach(() => { + global.indexedDB = createFakeIndexedDB(); + K = loadKeyStore(); + }); + + it("初始没有句柄时 loadKey 返回 null", async () => { + assert.equal(await K.loadKey(), null); + }); + + it("saveKey 后可以取回同一个密钥对象", async () => { + const key = await makeAesKey(); + await K.saveKey(key); + const loaded = await K.loadKey(); + assert.ok(loaded); + assert.equal(loaded.algorithm.name, "AES-GCM"); + }); + + it("取回的密钥仍不可导出(句柄而非原始字节)", async () => { + const key = await makeAesKey(); + await K.saveKey(key); + const loaded = await K.loadKey(); + assert.equal(loaded.extractable, false); + await assert.rejects(() => crypto.subtle.exportKey("raw", loaded)); + }); + + it("取回的密钥可以正常解密", async () => { + const key = await makeAesKey(); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const ciphertext = await crypto.subtle.encrypt( + { name: "AES-GCM", iv }, + key, + new TextEncoder().encode("secret") + ); + + await K.saveKey(key); + const loaded = await K.loadKey(); + const plain = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, loaded, ciphertext); + assert.equal(new TextDecoder().decode(plain), "secret"); + }); + + it("重复 saveKey 覆盖旧句柄", async () => { + const first = await makeAesKey(); + await K.saveKey(first); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const payload = new TextEncoder().encode("hello"); + const ciphertext = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, first, payload); + + const second = await makeAesKey(); + await K.saveKey(second); + const loaded = await K.loadKey(); + + // 取回的是第二个密钥,用旧密钥加密的数据解不开 + await assert.rejects(() => crypto.subtle.decrypt({ name: "AES-GCM", iv }, loaded, ciphertext)); + }); + + it("deleteKey 后取不到句柄(锁定即丢弃)", async () => { + await K.saveKey(await makeAesKey()); + await K.deleteKey(); + assert.equal(await K.loadKey(), null); + }); + + it("isAvailable 在支持 IndexedDB 时为 true", async () => { + assert.equal(await K.isAvailable(), true); + }); +}); + +describe("key-store - 不支持 IndexedDB 时降级", () => { + it("loadKey 返回 null 而不是抛错", async () => { + delete global.indexedDB; + const K = loadKeyStore(); + assert.equal(await K.loadKey(), null); + assert.equal(await K.isAvailable(), false); + }); +}); diff --git a/tests/utils.test.js b/tests/utils.test.js index 03e0e5a..fe89028 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 } = require("../dist/utils.js"); +const { isDomainMatch, escapeHtml, toMatchPatterns } = require("../dist/utils.js"); describe("isDomainMatch - 纯 hostname 匹配", () => { it("精确域名匹配", () => { @@ -81,6 +81,76 @@ describe("isDomainMatch - hostname + path 匹配", () => { }); }); +describe("toMatchPatterns - 配置域名转 match pattern", () => { + it("纯域名同时生成 http / https 两条", () => { + assert.deepEqual(toMatchPatterns("example.com"), [ + "http://example.com/*", + "https://example.com/*", + ]); + }); + + it("带协议前缀的配置会被剥离", () => { + assert.deepEqual(toMatchPatterns("https://example.com"), [ + "http://example.com/*", + "https://example.com/*", + ]); + }); + + it("子域名通配符 *.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/*", + ]); + // 中间通配无法表达,截断成前缀(放宽注入范围,精度仍由 isDomainMatch 把关) + 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*", + ]); + }); + + it("去掉端口(match pattern 不支持端口)", () => { + assert.deepEqual(toMatchPatterns("example.com:8080/app"), [ + "http://example.com/app*", + "https://example.com/app*", + ]); + }); + + it("中文域名转成 punycode", () => { + assert.deepEqual(toMatchPatterns("中文域名.中国"), [ + "http://xn--fiq06l2rdsvs.xn--fiqs8s/*", + "https://xn--fiq06l2rdsvs.xn--fiqs8s/*", + ]); + }); + + it("query / hash 会被忽略", () => { + assert.deepEqual(toMatchPatterns("oa.com/login?next=/home#x"), [ + "http://oa.com/login*", + "https://oa.com/login*", + ]); + }); + + it("无法表达的规则返回空数组", () => { + assert.deepEqual(toMatchPatterns(""), []); + assert.deepEqual(toMatchPatterns(null), []); + assert.deepEqual(toMatchPatterns("*"), []); // 裸通配 + assert.deepEqual(toMatchPatterns("a.*.b.com"), []); // 中间通配的 host + }); +}); + describe("escapeHtml", () => { it("转义 5 个特殊字符", () => { assert.equal(escapeHtml("<"), "<"); diff --git a/tsconfig.json b/tsconfig.json index 706884e..a08e3b7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,12 +10,12 @@ "skipLibCheck": true, "downlevelIteration": true, "forceConsistentCasingInFileNames": true, - "outDir": "dist", - "rootDir": "src", + // 只做类型检查;实际产物由 esbuild 生成到 dist/(见 scripts/build.mjs) + "noEmit": true, "sourceMap": false, "declaration": false, "removeComments": false }, "include": ["src/**/*.ts"], "exclude": ["node_modules", "dist", "tests"] -} \ No newline at end of file +}