This commit is contained in:
2026-09-17 23:00:47 +08:00
parent def731fc5a
commit 8883a5c506
43 changed files with 4530 additions and 1515 deletions
+47
View File
@@ -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: <all_urls>` 换成 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 <all_urls> 未收敛 ④ 无 lint/formatter 配置 ⑤ deepQuerySelectorAll 每 500ms 全量遍历+扫描 shadowRoot,最长 30s ⑥ 调试日志无开关 ⑦ activeTab 权限已冗余。
- 提醒用户:改造完成时有 32 项改动未提交,建议先形成基线。
- 用户要求解释「__autoPwd 明文口令」与「<all_urls> 静态注入」两条。已给出完整讲解与改造方案,结论与核实要点记入 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 注入 <all_urls> + body MutationObserver,建议改 scripting.executeScript 按需注入;④ 纯 tsc 无构建管线、dist 入库;⑤ content.ts 无测试。
+31
View File
@@ -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` + `<all_urls>` 无条件注入。现移除静态声明,background 通过 `chrome.scripting.registerContentScripts` 只注册**用户配置过的域名**(`syncContentScripts`,由 `utils.toMatchPatterns` 生成 match pattern);popup 手动填充改为"探测 `window.__autoLoginInjected` → `chrome.scripting.executeScript` 按需注入"。
- 仍保留 `host_permissions: ["<all_urls>"]`(按需注入需要),若要进一步收窄安装提示可改 `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 <url>]` → 同步 manifest.json / package.json / updates.xml(只替换 version 行,保留原格式)→ 发布构建 → `release/auto-login-v<ver>.zip`。不自动 commit/tag。
## 部署产物
- `updates.xml`:自托管自更新清单,`appid=dpcmpkmjkcenjpfcmkoohkcnhbomgmej`,codebase 指向 Windows 本地路径的 .crx。
- `install-policy.reg`:Windows 强制安装策略。两者都不打进扩展 zip。
+35
View File
@@ -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
+10
View File
@@ -1,3 +1,13 @@
node_modules/
# 构建产物:由 npm run build 生成,不入库
dist/
# 发布产物与签名私钥:不入库
release/
*.zip
*.crx
*.pem
.codeartsdoer/
.arts
+55
View File
@@ -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 -- <patch|minor|major|x.y.z>` 递增版本并同步 `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`(原为 `<all_urls>` 无条件注入所有页面)
- 手动填充改为"探测已注入标记 → `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 格式,支持文件口令加密)
- 从浏览器书签批量导入域名
- 口令强度评估与暴力破解耗时估算
- 中英双语界面,侧边栏形态
+21
View File
@@ -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.
+126
View File
@@ -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`:仅在你点击「从书签导入」时读取书签,用于批量生成域名配置
- 访问所有网站的权限(`<all_urls>`):因为你可以为任意域名配置自动登录;**实际注入范围严格限定为你配置过的域名**,未配置的网站不会执行任何本扩展脚本
### 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"
- `<all_urls>` — 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.
+177
View File
@@ -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: <all_urls>` | 在用户配置过的任意域名上执行填充(实际注入范围由配置动态限定,未配置的站点不会注入) |
## 隐私
不收集、不上传任何数据。数据处理方式详见 [PRIVACY.md](PRIVACY.md)。
## 许可证
[MIT](LICENSE)
+1 -8
View File
@@ -23,12 +23,5 @@
},
"background": {
"service_worker": "dist/background.js"
},
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["dist/utils.js", "dist/content.js"],
"run_at": "document_idle"
}
]
}
}
+1028
View File
File diff suppressed because it is too large Load Diff
+12 -4
View File
@@ -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"
}
}
}
+1 -1
View File
@@ -337,6 +337,6 @@
<script src="dist/utils.js"></script>
<script src="dist/env-store.js"></script>
<script src="dist/crypto-store.js"></script>
<script type="module" src="dist/popup.js"></script>
<script type="module" src="dist/popup/main.js"></script>
</body>
</html>
+136
View File
@@ -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") });
}
+45
View File
@@ -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") });
}
+85
View File
@@ -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(/(<updatecheck\b[^>]*?version=')[^']*(')/, `$1${version}$2`);
if (codebase) {
next = next.replace(/(<updatecheck\b[^>]*?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();
+104 -11
View File
@@ -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 里静态声明 <all_urls>,未配置的站点完全不会执行任何扩展脚本
// 锁定时 session 中没有明文域名,注入范围会自动清空(锁着也没东西可填)
// ---------------------------------------------------------------------------
const CONTENT_SCRIPT_ID = "auto-login-fill";
let appliedPatternsKey: string | null = null;
/** 汇总所有启用配置对应的 match pattern(加密模式下只有解锁后 session 里才有明文域名) */
async function collectMatchPatterns(): Promise<string[]> {
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<string>();
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<void> {
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 });
+3
View File
@@ -1,4 +1,7 @@
// 幂等标记:popup 手动填充前会先探测它,避免同一页面被重复注入
// (重复执行本脚本会因顶层 const 重复声明而报错,还会叠加多个 MutationObserver)
(window as any).__autoLoginInjected = true;
type Config = DomainConfig & { envId: string; envName: string };
+20 -7
View File
@@ -128,6 +128,24 @@ interface CryptoStoreDeps {
return key;
}
/**
* 用已有密钥解锁:解密 local 密文并写回 session,返回明文。
* 供"从 IndexedDB 取回不可导出密钥句柄"的场景复用,全程不需要口令参与。
* 密钥与 vault 不匹配(例如重设过口令)时抛错。
*/
async function unlockWithKey(key: CryptoKey): Promise<Environment[]> {
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,
+19
View File
@@ -12,6 +12,8 @@ interface CryptoStoreApi {
isUnlocked(): Promise<boolean>;
setupVault(password: string, environments: Environment[]): Promise<CryptoKey>;
unlock(password: string): Promise<{ environments: Environment[]; key: CryptoKey }>;
/** 用已有密钥解锁(从 IndexedDB 取回密钥句柄的场景),无需口令 */
unlockWithKey(key: CryptoKey): Promise<Environment[]>;
lock(): Promise<void>;
deriveKey(password: string, salt: Uint8Array<ArrayBuffer>, iterations: number): Promise<CryptoKey>;
encryptJson(obj: unknown, key: CryptoKey): Promise<{ iv: string; ciphertext: string }>;
@@ -24,6 +26,21 @@ interface CryptoStoreApi {
randomBytes(n: number): Uint8Array<ArrayBuffer>;
}
interface KeyStoreApi {
DB_NAME: string;
DB_VERSION: number;
STORE_NAME: string;
MASTER_KEY_ID: string;
openDb(): Promise<IDBDatabase>;
/** 保存密钥句柄(覆盖旧句柄) */
saveKey(key: CryptoKey): Promise<void>;
/** 取回密钥句柄;不存在或环境不支持时返回 null */
loadKey(): Promise<CryptoKey | null>;
/** 删除密钥句柄 */
deleteKey(): Promise<void>;
isAvailable(): Promise<boolean>;
}
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;
}
+112
View File
@@ -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<IDBDatabase> | null = null;
function openDb(): Promise<IDBDatabase> {
if (dbPromise) return dbPromise;
dbPromise = new Promise<IDBDatabase>((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<T>(mode: IDBTransactionMode, run: (store: IDBObjectStore) => IDBRequest): Promise<T> {
return openDb().then(
(db) =>
new Promise<T>((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<void> {
await withStore("readwrite", (store) => store.put(key, MASTER_KEY_ID));
}
/** 取回密钥句柄;不存在或环境不支持时返回 null */
async function loadKey(): Promise<CryptoKey | null> {
try {
const value = await withStore<CryptoKey | undefined>("readonly", (store) => store.get(MASTER_KEY_ID));
return value || null;
} catch (err) {
console.warn("[AutoLogin] 读取密钥句柄失败:", err);
return null;
}
}
/** 删除密钥句柄(锁定、或句柄与当前 vault 不匹配时) */
async function deleteKey(): Promise<void> {
await withStore("readwrite", (store) => store.delete(MASTER_KEY_ID));
}
/** 环境是否支持持久化密钥句柄 */
async function isAvailable(): Promise<boolean> {
try {
await openDb();
return true;
} catch {
return false;
}
}
return {
DB_NAME,
DB_VERSION,
STORE_NAME,
MASTER_KEY_ID,
openDb,
saveKey,
loadKey,
deleteKey,
isAvailable,
};
});
-1479
View File
File diff suppressed because it is too large Load Diff
+148
View File
@@ -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)));
});
}
+147
View File
@@ -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 };
}
+152
View File
@@ -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 };
}
+238
View File
@@ -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 = `
<div class="empty-state">
<div class="empty-icon">
<svg viewBox="0 0 24 24" width="56" height="56" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="5" width="18" height="14" rx="2"/>
<path d="M3 10h18"/>
<circle cx="7" cy="14.5" r="0.6" fill="currentColor"/>
<path d="M11 14.5h6"/>
</svg>
</div>
<div class="empty-text">${t("emptyStateTitle")}</div>
<div class="empty-sub">${t("emptyStateSub")}</div>
</div>
`;
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 = `
<div class="empty-state">
<div class="empty-text">${t("noSearchResult")}</div>
</div>
`;
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
? `<span class="tag-disabled">${t("tagDisabled")}</span>`
: "";
info.innerHTML = `
<div class="config-domain">${displayName} ${enabledTag}</div>
`;
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 = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>';
editBtn.addEventListener("click", (e) => {
e.stopPropagation();
ctx.openEditModal(cfg);
});
// 三个小点下拉菜单(收起禁用/删除)
const moreBtn = document.createElement("button");
moreBtn.className = "btn-icon";
moreBtn.title = t("moreActionsTitle");
moreBtn.innerHTML = '<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><circle cx="12" cy="5" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="12" cy="19" r="1.6"/></svg>';
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 };
}
+20
View File
@@ -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<void>;
/** 重新渲染环境下拉框 */
renderEnvSelect(): Promise<void>;
/** 重新渲染配置列表 */
renderConfigList(): Promise<void>;
/** 环境管理弹窗打开时刷新其列表 */
refreshEnvManage(): void;
/** 打开编辑配置弹窗;cfg 为 null 表示新增 */
openEditModal(cfg: DomainConfig | null): void;
}
+4
View File
@@ -0,0 +1,4 @@
/** popup DOM 工具 */
/** 按 id 取元素,取不到返回 null(与原 popup.ts 的 $ 行为一致) */
export const $ = (id: string): any => document.getElementById(id);
+85
View File
@@ -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<string | null> {
return new Promise<string | null>((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();
}
});
+160
View File
@@ -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 = `<div class="env-manage-empty">${t("envManageEmpty")}</div>`;
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 ? `<span class="env-manage-active-mark">${t("envCurrentMark")}</span>` : "";
nameSpan.innerHTML = `${escapeHtml(env.name)} <span class="env-manage-count">(${cnt})</span> ${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 = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>';
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 = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>';
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 };
}
+98
View File
@@ -0,0 +1,98 @@
/**
* "在当前页面填充"按钮:跨所有环境匹配当前标签页域名,
* 按需用 chrome.scripting 注入 content script 后再发起手动填充。
*
* 为什么不再依赖静态注入:manifest 里已移除 content_scripts(原来 <all_urls> 无条件
* 注入所有页面)。点击扩展图标本身会授予 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<boolean> {
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<void> {
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);
}
});
}
+159
View File
@@ -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 };
}
+53
View File
@@ -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<typeof initConfigForm>;
let envApi: ReturnType<typeof initEnvSection>;
let listApi: ReturnType<typeof initConfigList>;
let editApi: ReturnType<typeof initEditConfigModal>;
// 共享上下文:方法体内引用上面的 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();
+149
View File
@@ -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<any> {
return new Promise<any>((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();
}
});
+8
View File
@@ -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);
}
+20
View File
@@ -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 = "👁";
}
});
});
}
+222
View File
@@ -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 = "";
}
});
}
+43 -1
View File
@@ -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, "&#39;");
}
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 };
});
+476
View File
@@ -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(`<!DOCTYPE html><html><body>${html}</body></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: `<form>
<input type="text" name="username">
<input type="password" name="pass">
<button type="submit">登录</button>
</form>`,
});
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: `<form>
<input type="password" id="wrong">
<input type="password" id="right">
<input type="text" id="u1">
<input type="text" id="u2" name="account">
</form>`,
});
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: `<form>
<input type="text" placeholder="请输入手机号" id="phone">
<input type="text" placeholder="登录账号" id="account">
<input type="password">
</form>`,
});
assert.equal(ctx.win.findUsernameField({}).id, "account");
});
it("隐藏的密码框被跳过,选可见的那个", () => {
const ctx = loadContentPage({
html: `<form>
<input type="password" id="ghost">
<input type="password" id="real">
</form>`,
});
hide(ctx.doc.getElementById("ghost"));
assert.equal(ctx.win.findPasswordField({}).id, "real");
});
it("页面上没有密码框时返回 null,fillForm 返回 false", () => {
const ctx = loadContentPage({ html: `<form><input type="text" name="q"></form>` });
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: `<form><input type="password"><button id="btn">Sign in</button></form>`,
});
assert.equal(ctx.win.findSubmitButton({}, ctx.doc.querySelector("input[type=password]")).id, "btn");
});
it("能穿透 Shadow DOM 找到登录框", () => {
const ctx = loadContentPage({ html: `<div id="host"></div>` });
const shadow = ctx.doc.getElementById("host").attachShadow({ mode: "open" });
shadow.innerHTML = `<form>
<input type="text" name="user">
<input type="password" name="pass">
<button type="submit">登录</button>
</form>`;
assert.equal(ctx.win.findPasswordField({}).getAttribute("name"), "pass");
assert.equal(ctx.win.findUsernameField({}).getAttribute("name"), "user");
});
it("resolveElement 支持选择器与整段 HTML 片段两种写法", () => {
const ctx = loadContentPage({
html: `<form><input type="password" id="pwd" name="pass"></form>`,
});
assert.equal(ctx.win.resolveElement("input[name=pass]").id, "pwd");
// 从 HTML 片段反推选择器(用户直接粘贴元素代码的场景)
assert.equal(ctx.win.resolveElement('<input type="password" id="pwd">').id, "pwd");
});
});
describe("content - 填充与提交", () => {
it("填充用户名和密码,并对两个字段派发 input/change 事件", () => {
const ctx = loadContentPage({
html: `<form>
<input type="text" name="username" id="u">
<input type="password" id="p">
<button type="submit" id="btn">登录</button>
</form>`,
});
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: `<form><input type="text" name="username"><input type="password"><button type="submit" id="btn">登录</button></form>`,
});
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: `<form>
<input type="text" name="username">
<input type="password">
<div class="g-recaptcha"></div>
<button type="submit" id="btn">登录</button>
</form>`,
});
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: `<form><input type="text" name="username"><input type="password"></form>`,
});
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 = `<form>
<input type="text" name="username" id="u">
<input type="password" id="p">
<button type="submit" id="btn">登录</button>
</form>`;
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 = `<form>
<input type="text" name="username" id="u">
<input type="password" id="p">
</form>`;
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: `<div id="app"></div>`,
configs: [makeConfig()],
});
await tick(80);
assert.equal(ctx.doc.querySelectorAll("input").length, 0, "此时还没有表单");
// 模拟 SPA 前端稍后渲染出登录表单
ctx.doc.getElementById("app").innerHTML = `<form>
<input type="text" name="username" id="u">
<input type="password" id="p">
</form>`;
await tick(700); // 重试间隔 500ms
assert.equal(ctx.doc.getElementById("u").value, "auto-user");
});
});
describe("content - 手动填充消息", () => {
const LOGIN_HTML = `<form>
<input type="text" name="username" id="u">
<input type="password" id="p">
</form>`;
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: `<div>没有表单</div>` });
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"));
});
});
+45
View File
@@ -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();
+4
View File
@@ -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 || "" },
+157
View File
@@ -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);
});
});
+71 -1
View File
@@ -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("<"), "&lt;");
+3 -3
View File
@@ -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"]
}
}