优化
This commit is contained in:
@@ -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 无测试。
|
||||
@@ -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。
|
||||
@@ -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
@@ -1,3 +1,13 @@
|
||||
node_modules/
|
||||
|
||||
# 构建产物:由 npm run build 生成,不入库
|
||||
dist/
|
||||
|
||||
# 发布产物与签名私钥:不入库
|
||||
release/
|
||||
*.zip
|
||||
*.crx
|
||||
*.pem
|
||||
|
||||
.codeartsdoer/
|
||||
.arts
|
||||
|
||||
@@ -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 格式,支持文件口令加密)
|
||||
- 从浏览器书签批量导入域名
|
||||
- 口令强度评估与暴力破解耗时估算
|
||||
- 中英双语界面,侧边栏形态
|
||||
@@ -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
@@ -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.
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+1028
File diff suppressed because it is too large
Load Diff
+12
-4
@@ -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
@@ -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>
|
||||
|
||||
@@ -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") });
|
||||
}
|
||||
@@ -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") });
|
||||
}
|
||||
@@ -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
@@ -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 });
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
|
||||
// 幂等标记:popup 手动填充前会先探测它,避免同一页面被重复注入
|
||||
// (重复执行本脚本会因顶层 const 重复声明而报错,还会叠加多个 MutationObserver)
|
||||
(window as any).__autoLoginInjected = true;
|
||||
|
||||
type Config = DomainConfig & { envId: string; envName: string };
|
||||
|
||||
|
||||
+20
-7
@@ -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,
|
||||
|
||||
Vendored
+19
@@ -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;
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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)));
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/** popup DOM 工具 */
|
||||
|
||||
/** 按 id 取元素,取不到返回 null(与原 popup.ts 的 $ 行为一致) */
|
||||
export const $ = (id: string): any => document.getElementById(id);
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 = "👁";
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -6,6 +6,7 @@
|
||||
const api = factory();
|
||||
root.isDomainMatch = api.isDomainMatch;
|
||||
root.escapeHtml = api.escapeHtml;
|
||||
root.toMatchPatterns = api.toMatchPatterns;
|
||||
}
|
||||
})(typeof globalThis !== "undefined" ? globalThis : this, function () {
|
||||
"use strict";
|
||||
@@ -76,5 +77,46 @@
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
return { isDomainMatch, escapeHtml };
|
||||
/**
|
||||
* 把配置里的域名规则转成 Chrome match pattern,用于"按需注册 content script",
|
||||
* 让脚本只注入用户配置过的域名(而不是全网站无条件注入)。
|
||||
*
|
||||
* match pattern 的硬限制:不支持端口、路径里的 * 只能出现在结尾。因此这里的策略是
|
||||
* "宁可放宽、不做漏配"——路径中第一个 * 之后的部分一律截断,最终匹配精度仍由
|
||||
* content script 内的 isDomainMatch 把关(注入范围放宽不等于会误填)。
|
||||
* 无法表达的规则返回空数组,该域名退化为只能手动填充。
|
||||
*/
|
||||
function toMatchPatterns(pattern: string | null | undefined): string[] {
|
||||
let raw = String(pattern == null ? "" : pattern).trim();
|
||||
if (!raw) return [];
|
||||
raw = raw.replace(/^\*:\/\//i, "").replace(/^https?:\/\//i, "");
|
||||
raw = raw.split(/[?#]/)[0];
|
||||
if (!raw) return [];
|
||||
|
||||
let host = raw;
|
||||
let path = "";
|
||||
const slash = raw.indexOf("/");
|
||||
if (slash !== -1) {
|
||||
host = raw.slice(0, slash);
|
||||
path = raw.slice(slash);
|
||||
}
|
||||
|
||||
// 归一化:去掉端口、中文域名转 punycode,顺便过滤掉非法字符(如 IPv6 方括号)
|
||||
try {
|
||||
host = new URL("http://" + host).hostname;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
if (!host || /[^a-z0-9.*-]/i.test(host)) return [];
|
||||
// match pattern 的 host 只支持 *.example.com 这一种通配形式
|
||||
if (host.includes("*") && !(host.startsWith("*.") && !host.slice(2).includes("*"))) return [];
|
||||
|
||||
if (path.includes("*")) path = path.slice(0, path.indexOf("*") + 1);
|
||||
if (!path) path = "/*";
|
||||
else if (!path.endsWith("*")) path += "*";
|
||||
|
||||
return ["http://" + host + path, "https://" + host + path];
|
||||
}
|
||||
|
||||
return { isDomainMatch, escapeHtml, toMatchPatterns };
|
||||
});
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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 || "" },
|
||||
|
||||
@@ -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
@@ -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("<"), "<");
|
||||
|
||||
+3
-3
@@ -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"]
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user