no message
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
{
|
||||||
|
}
|
||||||
@@ -103,6 +103,10 @@
|
|||||||
"message": "Add Domain Configuration",
|
"message": "Add Domain Configuration",
|
||||||
"description": "Add config section title"
|
"description": "Add config section title"
|
||||||
},
|
},
|
||||||
|
"editConfigTitle": {
|
||||||
|
"message": "Edit Domain Configuration",
|
||||||
|
"description": "Edit config section title"
|
||||||
|
},
|
||||||
"domainLabel": {
|
"domainLabel": {
|
||||||
"message": "Domain / URL Match Rule",
|
"message": "Domain / URL Match Rule",
|
||||||
"description": "Domain input label"
|
"description": "Domain input label"
|
||||||
|
|||||||
@@ -103,6 +103,10 @@
|
|||||||
"message": "添加域名配置",
|
"message": "添加域名配置",
|
||||||
"description": "添加配置区标题"
|
"description": "添加配置区标题"
|
||||||
},
|
},
|
||||||
|
"editConfigTitle": {
|
||||||
|
"message": "编辑域名配置",
|
||||||
|
"description": "编辑配置区标题"
|
||||||
|
},
|
||||||
"domainLabel": {
|
"domainLabel": {
|
||||||
"message": "域名 / URL 匹配规则",
|
"message": "域名 / URL 匹配规则",
|
||||||
"description": "域名输入框 label"
|
"description": "域名输入框 label"
|
||||||
|
|||||||
+8
-28
@@ -1,23 +1,12 @@
|
|||||||
// background.js —— Service Worker,负责:
|
|
||||||
// 1. Side Panel 打开行为
|
|
||||||
// 2. 口令锁状态管理(解锁后内存持有 AES 密钥)
|
|
||||||
// 3. session 数据变化时自动加密回写 local
|
|
||||||
// 4. 闲置自动锁定
|
|
||||||
// 5. 旧版明文库迁移到加密库
|
|
||||||
|
|
||||||
importScripts("crypto-store.js");
|
importScripts("crypto-store.js");
|
||||||
|
|
||||||
// 内存中的派生密钥,解锁后才有值,锁定/SW 重启后清空
|
let masterKey = null; // 解锁后持有,锁定或 SW 重启后清空
|
||||||
let masterKey = null;
|
|
||||||
// 闲置自动锁定计时器
|
|
||||||
let autoLockTimer = null;
|
let autoLockTimer = null;
|
||||||
const AUTO_LOCK_MS = 10 * 60 * 1000; // 10 分钟无操作自动锁定
|
const AUTO_LOCK_MS = 10 * 60 * 1000; // 10 分钟无操作自动锁定
|
||||||
|
|
||||||
// 全局启用 Side Panel
|
|
||||||
chrome.runtime.onInstalled.addListener(() => {
|
chrome.runtime.onInstalled.addListener(() => {
|
||||||
chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true })
|
chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true })
|
||||||
.catch((err) => console.warn("[AutoLogin] 设置 sidePanel 行为失败:", err));
|
.catch((err) => console.warn("[AutoLogin] 设置 sidePanel 行为失败:", err));
|
||||||
// 安装/升级时迁移旧版明文库
|
|
||||||
migrateLegacyVault();
|
migrateLegacyVault();
|
||||||
console.log("[AutoLogin] 扩展已安装");
|
console.log("[AutoLogin] 扩展已安装");
|
||||||
});
|
});
|
||||||
@@ -28,17 +17,12 @@ chrome.action.onClicked.addListener((tab) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
// 明文数据无法在无口令下加密,暂存为 pendingImport,待用户设置口令时由 setupVault 导入
|
||||||
* 旧版迁移:如果 local 中存在明文 environments 但没有 vaultMeta,
|
|
||||||
* 则提示用户需要先设置口令(无法在无口令下加密)。
|
|
||||||
* 这里把明文数据暂存,等用户设置口令时由 setupVault 导入。
|
|
||||||
*/
|
|
||||||
async function migrateLegacyVault() {
|
async function migrateLegacyVault() {
|
||||||
const data = await chrome.storage.local.get(["environments", "loginConfigs", "vaultMeta"]);
|
const data = await chrome.storage.local.get(["environments", "loginConfigs", "vaultMeta"]);
|
||||||
const hasVault = !!data.vaultMeta;
|
const hasVault = !!data.vaultMeta;
|
||||||
if (hasVault) return;
|
if (hasVault) return;
|
||||||
|
|
||||||
// 有旧版明文数据:迁移为待导入状态
|
|
||||||
const legacyEnvs = data.environments;
|
const legacyEnvs = data.environments;
|
||||||
const legacyConfigs = data.loginConfigs;
|
const legacyConfigs = data.loginConfigs;
|
||||||
if (legacyEnvs || legacyConfigs) {
|
if (legacyEnvs || legacyConfigs) {
|
||||||
@@ -60,7 +44,7 @@ async function migrateLegacyVault() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 重置闲置自动锁定计时器 */
|
|
||||||
function resetAutoLockTimer() {
|
function resetAutoLockTimer() {
|
||||||
if (autoLockTimer) clearTimeout(autoLockTimer);
|
if (autoLockTimer) clearTimeout(autoLockTimer);
|
||||||
if (masterKey) {
|
if (masterKey) {
|
||||||
@@ -77,10 +61,7 @@ async function doLock() {
|
|||||||
await CryptoStore.lock();
|
await CryptoStore.lock();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// session 中 environments 变化时用内存密钥重新加密写回 local
|
||||||
* 监听 session 中 environments 变化:用内存密钥重新加密写回 local。
|
|
||||||
* 这是"加密透明化"的核心——EnvStore 只管 session,加密由这里自动完成。
|
|
||||||
*/
|
|
||||||
chrome.storage.onChanged.addListener(async (changes, areaName) => {
|
chrome.storage.onChanged.addListener(async (changes, areaName) => {
|
||||||
if (areaName !== "session") return;
|
if (areaName !== "session") return;
|
||||||
if (!changes.environments || !masterKey) return;
|
if (!changes.environments || !masterKey) return;
|
||||||
@@ -93,7 +74,7 @@ chrome.storage.onChanged.addListener(async (changes, areaName) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 消息处理:来自 popup / content script
|
|
||||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -107,13 +88,12 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case "setupVault": {
|
case "setupVault": {
|
||||||
// 首次设置口令。如果有待导入的旧数据,一并加密。
|
// 首次设置口令,若有待导入的旧数据则一并加密
|
||||||
const pendingData = await chrome.storage.local.get("pendingImport");
|
const pendingData = await chrome.storage.local.get("pendingImport");
|
||||||
let initialEnvs = [];
|
let initialEnvs = [];
|
||||||
if (pendingData.pendingImport) {
|
if (pendingData.pendingImport) {
|
||||||
initialEnvs = pendingData.pendingImport.data;
|
initialEnvs = pendingData.pendingImport.data;
|
||||||
} else {
|
} else {
|
||||||
// 预置 3 个环境
|
|
||||||
const PRESET = ["个人", "测试", "生产"];
|
const PRESET = ["个人", "测试", "生产"];
|
||||||
initialEnvs = PRESET.map((name) => ({
|
initialEnvs = PRESET.map((name) => ({
|
||||||
id: "env_" + Date.now().toString(36) + Math.random().toString(36).slice(2, 8),
|
id: "env_" + Date.now().toString(36) + Math.random().toString(36).slice(2, 8),
|
||||||
@@ -147,7 +127,7 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case "getActivity": {
|
case "getActivity": {
|
||||||
// 供 popup 显示闲置倒计时
|
|
||||||
sendResponse({
|
sendResponse({
|
||||||
unlocked: !!masterKey,
|
unlocked: !!masterKey,
|
||||||
autoLockMs: AUTO_LOCK_MS,
|
autoLockMs: AUTO_LOCK_MS,
|
||||||
@@ -156,7 +136,7 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case "ping": {
|
case "ping": {
|
||||||
// 任何消息都算用户活动,重置自动锁定计时
|
// 任何消息都视为用户活动,顺带重置自动锁定计时
|
||||||
resetAutoLockTimer();
|
resetAutoLockTimer();
|
||||||
sendResponse({ pong: true });
|
sendResponse({ pong: true });
|
||||||
break;
|
break;
|
||||||
|
|||||||
+18
-24
@@ -1,19 +1,17 @@
|
|||||||
// isDomainMatch 由 utils.js 提供(全局函数)
|
|
||||||
|
|
||||||
// i18n:content script 里取文案统一通过 chrome.i18n.getMessage
|
|
||||||
const t = (key, ...args) => chrome.i18n.getMessage(key, args) || key;
|
const t = (key, ...args) => chrome.i18n.getMessage(key, args) || key;
|
||||||
|
|
||||||
// 2FA / 验证码检测:检测到则不自动提交,仅填充
|
// 2FA / 验证码检测:检测到则不自动提交,仅填充
|
||||||
function detectCaptcha() {
|
function detectCaptcha() {
|
||||||
// 1. reCAPTCHA
|
// reCAPTCHA
|
||||||
if (document.querySelector(".g-recaptcha, [data-sitekey], iframe[src*='recaptcha'], iframe[src*='google.com/recaptcha']")) {
|
if (document.querySelector(".g-recaptcha, [data-sitekey], iframe[src*='recaptcha'], iframe[src*='google.com/recaptcha']")) {
|
||||||
return { type: "reCAPTCHA", reason: t("captchaTitleRe") };
|
return { type: "reCAPTCHA", reason: t("captchaTitleRe") };
|
||||||
}
|
}
|
||||||
// 2. hCaptcha
|
// hCaptcha
|
||||||
if (document.querySelector(".h-captcha, iframe[src*='hcaptcha']")) {
|
if (document.querySelector(".h-captcha, iframe[src*='hcaptcha']")) {
|
||||||
return { type: "hCaptcha", reason: t("captchaTitleH") };
|
return { type: "hCaptcha", reason: t("captchaTitleH") };
|
||||||
}
|
}
|
||||||
// 3. 国内常见滑块/验证码容器
|
// 国内常见滑块/验证码容器
|
||||||
const sliderSelectors = [
|
const sliderSelectors = [
|
||||||
"#slider", ".slider", ".nc_wrapper", ".nc_iconfont",
|
"#slider", ".slider", ".nc_wrapper", ".nc_iconfont",
|
||||||
"#captcha", ".captcha", ".geetest", "#geetest",
|
"#captcha", ".captcha", ".geetest", "#geetest",
|
||||||
@@ -28,7 +26,7 @@ function detectCaptcha() {
|
|||||||
}
|
}
|
||||||
} catch { /* selector 可能无效 */ }
|
} catch { /* selector 可能无效 */ }
|
||||||
}
|
}
|
||||||
// 4. 明显的"验证码"文字输入框(带图形码)
|
// 明显的"验证码"文字输入框(带图形码)
|
||||||
const codeInputs = document.querySelectorAll(
|
const codeInputs = document.querySelectorAll(
|
||||||
"input[name*='captcha' i], input[name*='verify' i], input[name*='code' i], input[id*='captcha' i], input[id*='verify' i], input[placeholder*='验证码' i], input[placeholder*='验证' i]"
|
"input[name*='captcha' i], input[name*='verify' i], input[name*='code' i], input[id*='captcha' i], input[id*='verify' i], input[placeholder*='验证码' i], input[placeholder*='验证' i]"
|
||||||
);
|
);
|
||||||
@@ -206,20 +204,19 @@ function fillForm(config, canSubmit = true) {
|
|||||||
setNativeValue(passwordField, config.password);
|
setNativeValue(passwordField, config.password);
|
||||||
console.log("[AutoLogin]", t("logFilledPassword"));
|
console.log("[AutoLogin]", t("logFilledPassword"));
|
||||||
|
|
||||||
// 验证码检测:检测到则强制不自动提交,提示用户手动操作
|
// 检测到验证码则强制不自动提交,提示用户手动操作
|
||||||
const captcha = detectCaptcha();
|
const captcha = detectCaptcha();
|
||||||
let actualCanSubmit = canSubmit;
|
let actualCanSubmit = canSubmit;
|
||||||
if (captcha && config.autoSubmit) {
|
if (captcha && config.autoSubmit) {
|
||||||
actualCanSubmit = false;
|
actualCanSubmit = false;
|
||||||
console.warn("[AutoLogin]", t("logCaptchaSkip", captcha.reason));
|
console.warn("[AutoLogin]", t("logCaptchaSkip", captcha.reason));
|
||||||
// 在页面右上角显示提示浮窗
|
|
||||||
showCaptchaToast(captcha.reason);
|
showCaptchaToast(captcha.reason);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.autoSubmit && actualCanSubmit) {
|
if (config.autoSubmit && actualCanSubmit) {
|
||||||
const submitBtn = findSubmitButton(config, passwordField);
|
const submitBtn = findSubmitButton(config, passwordField);
|
||||||
if (submitBtn) {
|
if (submitBtn) {
|
||||||
// 延迟提交:config.delayMs 给某些需要 JS 初始化的页面留时间
|
// delayMs 给需要 JS 初始化的页面留出时间
|
||||||
const delay = Math.max(300, Number(config.delayMs) || 300);
|
const delay = Math.max(300, Number(config.delayMs) || 300);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
console.log("[AutoLogin]", t("logAutoClick", String(delay)));
|
console.log("[AutoLogin]", t("logAutoClick", String(delay)));
|
||||||
@@ -251,7 +248,7 @@ function fillForm(config, canSubmit = true) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 验证码提示浮窗:在页面右上角显示,几秒后自动消失
|
// 验证码提示浮窗,8 秒后自动消失
|
||||||
let captchaToastEl = null;
|
let captchaToastEl = null;
|
||||||
function showCaptchaToast(reason) {
|
function showCaptchaToast(reason) {
|
||||||
if (captchaToastEl && captchaToastEl.parentNode) {
|
if (captchaToastEl && captchaToastEl.parentNode) {
|
||||||
@@ -286,7 +283,7 @@ function showCaptchaToast(reason) {
|
|||||||
const MAX_LOGIN_ATTEMPTS = 3;
|
const MAX_LOGIN_ATTEMPTS = 3;
|
||||||
const ATTEMPT_COOLDOWN = 5 * 60 * 1000;
|
const ATTEMPT_COOLDOWN = 5 * 60 * 1000;
|
||||||
|
|
||||||
// 失败计数按 envId+domain 索引,支持同 domain 在不同环境下独立计数
|
// 失败计数按 envId+domain 索引,同 domain 在不同环境独立计数
|
||||||
function attemptKey(envId, domain) {
|
function attemptKey(envId, domain) {
|
||||||
return `${envId}::${domain}`;
|
return `${envId}::${domain}`;
|
||||||
}
|
}
|
||||||
@@ -320,7 +317,7 @@ async function resetDomainAttempts(envId, domain) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 从 session 中扁平化读取所有配置(解锁后明文存于 session,未解锁时为空)
|
|
||||||
async function loadAllConfigs() {
|
async function loadAllConfigs() {
|
||||||
const data = await chrome.storage.session.get("environments");
|
const data = await chrome.storage.session.get("environments");
|
||||||
const environments = data.environments || [];
|
const environments = data.environments || [];
|
||||||
@@ -337,32 +334,31 @@ async function autoFillOnLoad() {
|
|||||||
const allConfigs = await loadAllConfigs();
|
const allConfigs = await loadAllConfigs();
|
||||||
if (allConfigs.length === 0) return;
|
if (allConfigs.length === 0) return;
|
||||||
|
|
||||||
// 跳过 enabled === false 的配置(独立开关)
|
// 每个配置有独立开关 enabled
|
||||||
const enabledConfigs = allConfigs.filter((c) => c.enabled !== false);
|
const enabledConfigs = allConfigs.filter((c) => c.enabled !== false);
|
||||||
if (enabledConfigs.length === 0) return;
|
if (enabledConfigs.length === 0) return;
|
||||||
|
|
||||||
const matched = enabledConfigs.filter((c) => isDomainMatch(window.location.href, c.domain));
|
const matched = enabledConfigs.filter((c) => isDomainMatch(window.location.href, c.domain));
|
||||||
if (matched.length === 0) return;
|
if (matched.length === 0) return;
|
||||||
|
|
||||||
// 单个匹配:直接填充
|
|
||||||
if (matched.length === 1) {
|
if (matched.length === 1) {
|
||||||
await fillSingleConfig(matched[0]);
|
await fillSingleConfig(matched[0]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 多个匹配(跨环境):弹浮窗让用户选择
|
// 多环境命中:弹浮窗让用户选择
|
||||||
console.log("[AutoLogin]", t("logMatchedMulti", String(matched.length)));
|
console.log("[AutoLogin]", t("logMatchedMulti", String(matched.length)));
|
||||||
showEnvPicker(matched);
|
showEnvPicker(matched);
|
||||||
}
|
}
|
||||||
|
|
||||||
// SPA 动态表单:尝试填充,表单未出现时持续重试 30 秒
|
// SPA 表单可能晚于脚本加载,填不到时每 500ms 重试,30 秒超时
|
||||||
const SPA_FILL_TIMEOUT_MS = 30000;
|
const SPA_FILL_TIMEOUT_MS = 30000;
|
||||||
const SPA_RETRY_INTERVAL_MS = 500;
|
const SPA_RETRY_INTERVAL_MS = 500;
|
||||||
|
|
||||||
async function fillSingleConfig(config) {
|
async function fillSingleConfig(config) {
|
||||||
console.log("[AutoLogin]", t("logStartFillEnv", config.envName || t("defaultEnvName")));
|
console.log("[AutoLogin]", t("logStartFillEnv", config.envName || t("defaultEnvName")));
|
||||||
|
|
||||||
// 检查自动登录尝试次数,超过上限则仅填充不自动提交
|
// 超限后仅填充不自动提交
|
||||||
let canSubmit = config.autoSubmit;
|
let canSubmit = config.autoSubmit;
|
||||||
if (config.autoSubmit) {
|
if (config.autoSubmit) {
|
||||||
const attempts = await getDomainAttempts(config.envId, config.domain);
|
const attempts = await getDomainAttempts(config.envId, config.domain);
|
||||||
@@ -387,7 +383,7 @@ async function fillSingleConfig(config) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SPA 重试:表单未出现时持续重试,30 秒超时
|
// SPA 重试填充,30 秒超时
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
const tryFill = () => {
|
const tryFill = () => {
|
||||||
const elapsed = Date.now() - startTime;
|
const elapsed = Date.now() - startTime;
|
||||||
@@ -404,10 +400,9 @@ async function fillSingleConfig(config) {
|
|||||||
tryFill();
|
tryFill();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 多环境命中时,在页面右上角插入选择浮窗
|
// 多环境命中时在页面右上角插入选择浮窗
|
||||||
let envPickerEl = null;
|
let envPickerEl = null;
|
||||||
function showEnvPicker(configs) {
|
function showEnvPicker(configs) {
|
||||||
// 移除已有浮窗
|
|
||||||
if (envPickerEl && envPickerEl.parentNode) {
|
if (envPickerEl && envPickerEl.parentNode) {
|
||||||
envPickerEl.parentNode.removeChild(envPickerEl);
|
envPickerEl.parentNode.removeChild(envPickerEl);
|
||||||
envPickerEl = null;
|
envPickerEl = null;
|
||||||
@@ -499,16 +494,14 @@ function showEnvPicker(configs) {
|
|||||||
envPickerEl = overlay;
|
envPickerEl = overlay;
|
||||||
}
|
}
|
||||||
|
|
||||||
// escapeHtml 由 utils.js 提供(全局函数)
|
|
||||||
|
|
||||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||||
if (message.action === "manualFill" && message.config) {
|
if (message.action === "manualFill" && message.config) {
|
||||||
// 手动触发填充,重置该环境+域名的失败计数
|
// 手动触发填充,重置失败计数
|
||||||
resetDomainAttempts(message.config.envId, message.config.domain);
|
resetDomainAttempts(message.config.envId, message.config.domain);
|
||||||
const filled = fillForm(message.config, true);
|
const filled = fillForm(message.config, true);
|
||||||
sendResponse({ success: filled });
|
sendResponse({ success: filled });
|
||||||
} else if (message.action === "manualFillMulti" && Array.isArray(message.configs)) {
|
} else if (message.action === "manualFillMulti" && Array.isArray(message.configs)) {
|
||||||
// popup 多环境命中:弹出选择菜单
|
|
||||||
showEnvPicker(message.configs);
|
showEnvPicker(message.configs);
|
||||||
sendResponse({ success: true });
|
sendResponse({ success: true });
|
||||||
}
|
}
|
||||||
@@ -517,12 +510,13 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|||||||
|
|
||||||
autoFillOnLoad();
|
autoFillOnLoad();
|
||||||
|
|
||||||
|
// URL 变化后若新页面不再匹配任何配置,重置之前匹配环境的失败计数
|
||||||
let lastUrl = window.location.href;
|
let lastUrl = window.location.href;
|
||||||
const observer = new MutationObserver(() => {
|
const observer = new MutationObserver(() => {
|
||||||
if (window.location.href !== lastUrl) {
|
if (window.location.href !== lastUrl) {
|
||||||
const prevUrl = lastUrl;
|
const prevUrl = lastUrl;
|
||||||
lastUrl = window.location.href;
|
lastUrl = window.location.href;
|
||||||
// URL 变化后,如果新页面不再匹配任何配置,重置之前匹配环境的失败计数
|
|
||||||
loadAllConfigs().then((allConfigs) => {
|
loadAllConfigs().then((allConfigs) => {
|
||||||
const stillMatched = allConfigs.some((c) => isDomainMatch(window.location.href, c.domain));
|
const stillMatched = allConfigs.some((c) => isDomainMatch(window.location.href, c.domain));
|
||||||
if (!stillMatched) {
|
if (!stillMatched) {
|
||||||
|
|||||||
+11
-31
@@ -1,18 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* 加密存储模块:主口令 + AES-GCM 加密整库。
|
* 加密库:主口令 + AES-GCM 整库加密。
|
||||||
*
|
* local 存 vaultMeta / encryptedVault;解锁后明文放 session,不进 local;
|
||||||
* 存储布局:
|
* 派生密钥仅由 background 在内存中持有。
|
||||||
* chrome.storage.local:
|
|
||||||
* - vaultMeta: { salt(base64), iterations, version } 口令派生参数
|
|
||||||
* - encryptedVault: { iv(base64), ciphertext(base64) } AES-GCM 密文
|
|
||||||
* chrome.storage.session(仅解锁后存在,内存中、不落盘):
|
|
||||||
* - environments: 解密后的环境数组
|
|
||||||
* - activeEnvId: 当前激活环境 id
|
|
||||||
*
|
|
||||||
* 主口令不存储。派生的 CryptoKey 仅在解锁后存放于 background SW 内存中,
|
|
||||||
* 用于每次 session 数据变化时重新加密写回 local。
|
|
||||||
*
|
|
||||||
* 同时在浏览器(Service Worker / 扩展页面)和 Node 测试环境下可用。
|
|
||||||
*/
|
*/
|
||||||
(function (root, factory) {
|
(function (root, factory) {
|
||||||
if (typeof module === "object" && module.exports) {
|
if (typeof module === "object" && module.exports) {
|
||||||
@@ -58,7 +47,7 @@
|
|||||||
return arr;
|
return arr;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 从口令派生 AES-GCM 密钥 */
|
|
||||||
async function deriveKey(password, salt, iterations) {
|
async function deriveKey(password, salt, iterations) {
|
||||||
const enc = new TextEncoder();
|
const enc = new TextEncoder();
|
||||||
const keyMaterial = await getCrypto().subtle.importKey(
|
const keyMaterial = await getCrypto().subtle.importKey(
|
||||||
@@ -73,7 +62,7 @@
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 加密任意 JSON 对象,返回 { iv, ciphertext } base64 */
|
|
||||||
async function encryptJson(obj, key) {
|
async function encryptJson(obj, key) {
|
||||||
const enc = new TextEncoder();
|
const enc = new TextEncoder();
|
||||||
const iv = randomBytes(12);
|
const iv = randomBytes(12);
|
||||||
@@ -83,7 +72,7 @@
|
|||||||
return { iv: b64encode(iv), ciphertext: b64encode(ciphertext) };
|
return { iv: b64encode(iv), ciphertext: b64encode(ciphertext) };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 解密为 JSON 对象,口令错误/数据损坏抛错 */
|
/** 解密为 JSON 对象,口令错误或数据损坏时抛错 */
|
||||||
async function decryptJson({ iv, ciphertext }, key) {
|
async function decryptJson({ iv, ciphertext }, key) {
|
||||||
const dec = new TextDecoder();
|
const dec = new TextDecoder();
|
||||||
const plain = await getCrypto().subtle.decrypt(
|
const plain = await getCrypto().subtle.decrypt(
|
||||||
@@ -96,23 +85,20 @@
|
|||||||
return (await getStorage("local").get(key))[key];
|
return (await getStorage("local").get(key))[key];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 是否已创建过口令 */
|
|
||||||
async function hasVault() {
|
async function hasVault() {
|
||||||
const meta = await getLocal(VAULT_META_KEY);
|
const meta = await getLocal(VAULT_META_KEY);
|
||||||
const cipher = await getLocal(ENCRYPTED_VAULT_KEY);
|
const cipher = await getLocal(ENCRYPTED_VAULT_KEY);
|
||||||
return !!(meta && cipher);
|
return !!(meta && cipher);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 是否处于解锁状态(session 中有明文环境数据) */
|
|
||||||
async function isUnlocked() {
|
async function isUnlocked() {
|
||||||
const data = await getStorage("session").get(SESSION_ENV_KEY);
|
const data = await getStorage("session").get(SESSION_ENV_KEY);
|
||||||
return !!data[SESSION_ENV_KEY];
|
return !!data[SESSION_ENV_KEY];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 首次设置口令:生成盐、加密初始环境写入 local,并把明文写入 session,返回派生密钥 */
|
||||||
* 首次创建口令:生成盐,用口令加密初始环境,写入 local。
|
|
||||||
* 同时把环境写入 session,视为已解锁。返回派生密钥(供调用方内存持有)。
|
|
||||||
*/
|
|
||||||
async function setupVault(password, environments) {
|
async function setupVault(password, environments) {
|
||||||
if (!password) throw new Error("口令不能为空");
|
if (!password) throw new Error("口令不能为空");
|
||||||
const salt = randomBytes(16);
|
const salt = randomBytes(16);
|
||||||
@@ -135,10 +121,7 @@
|
|||||||
return key;
|
return key;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 解锁:解密 local 密文写入 session,口令错误抛错,返回明文与密钥 */
|
||||||
* 解锁:用口令解密 local 密文,写入 session。
|
|
||||||
* 返回 { environments, key },口令错误抛错。
|
|
||||||
*/
|
|
||||||
async function unlock(password) {
|
async function unlock(password) {
|
||||||
const meta = await getLocal(VAULT_META_KEY);
|
const meta = await getLocal(VAULT_META_KEY);
|
||||||
const cipher = await getLocal(ENCRYPTED_VAULT_KEY);
|
const cipher = await getLocal(ENCRYPTED_VAULT_KEY);
|
||||||
@@ -159,22 +142,19 @@
|
|||||||
return { environments, key };
|
return { environments, key };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 锁定:清除 session 中的明文 */
|
|
||||||
async function lock() {
|
async function lock() {
|
||||||
await getStorage("session").remove([SESSION_ENV_KEY, SESSION_ACTIVE_KEY]);
|
await getStorage("session").remove([SESSION_ENV_KEY, SESSION_ACTIVE_KEY]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 读取 vaultMeta(供 background 派生密钥用) */
|
|
||||||
async function getVaultMeta() {
|
async function getVaultMeta() {
|
||||||
return getLocal(VAULT_META_KEY);
|
return getLocal(VAULT_META_KEY);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 读取加密 vault 密文 */
|
|
||||||
async function getEncryptedVault() {
|
async function getEncryptedVault() {
|
||||||
return getLocal(ENCRYPTED_VAULT_KEY);
|
return getLocal(ENCRYPTED_VAULT_KEY);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 用给定密钥加密 environments 并写回 local(background 内存密钥调用) */
|
/** 用给定密钥加密 environments 并写回 local */
|
||||||
async function persistEncrypted(environments, key) {
|
async function persistEncrypted(environments, key) {
|
||||||
const cipher = await encryptJson(environments, key);
|
const cipher = await encryptJson(environments, key);
|
||||||
await getStorage("local").set({ [ENCRYPTED_VAULT_KEY]: cipher });
|
await getStorage("local").set({ [ENCRYPTED_VAULT_KEY]: cipher });
|
||||||
|
|||||||
+7
-14
@@ -1,11 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* 环境数据层:管理多环境配置。
|
* 环境数据层:环境/配置的读写,数据存于 chrome.storage.session(解锁后明文)。
|
||||||
*
|
* 首次建口令由 crypto-store.setupVault 完成;加密回写由 background 监听完成。
|
||||||
* 数据存放在 chrome.storage.session(解锁后明文,内存中、不落盘)。
|
|
||||||
* 首次创建口令由 crypto-store.setupVault 完成(含预置环境)。
|
|
||||||
* 锁定时 session 被清空,本层返回空数据。
|
|
||||||
*
|
|
||||||
* 浏览器中通过全局 chrome.storage.session 存取;Node 测试中注入 mock。
|
|
||||||
*/
|
*/
|
||||||
(function (root, factory) {
|
(function (root, factory) {
|
||||||
if (typeof module === "object" && module.exports) {
|
if (typeof module === "object" && module.exports) {
|
||||||
@@ -38,9 +33,7 @@
|
|||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 加载所有环境。未解锁时 session 为空,返回空数组。
|
|
||||||
*/
|
|
||||||
async function loadEnvironments() {
|
async function loadEnvironments() {
|
||||||
const data = await storage().get([ENV_KEY, ACTIVE_ENV_KEY]);
|
const data = await storage().get([ENV_KEY, ACTIVE_ENV_KEY]);
|
||||||
const environments = data[ENV_KEY] || [];
|
const environments = data[ENV_KEY] || [];
|
||||||
@@ -96,7 +89,7 @@
|
|||||||
environments.splice(idx, 1);
|
environments.splice(idx, 1);
|
||||||
const newActive = activeEnvId === envId ? environments[0].id : activeEnvId;
|
const newActive = activeEnvId === envId ? environments[0].id : activeEnvId;
|
||||||
await saveEnvironments(environments, newActive);
|
await saveEnvironments(environments, newActive);
|
||||||
// 清理该环境的失败计数(失败计数非敏感,存于 local 持久化)
|
// 失败计数存于 local(非敏感),删除环境时一并清理
|
||||||
const local = getLocalStorage();
|
const local = getLocalStorage();
|
||||||
if (local) {
|
if (local) {
|
||||||
const attemptsData = await local.get("loginAttempts");
|
const attemptsData = await local.get("loginAttempts");
|
||||||
@@ -108,13 +101,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 当前激活环境的配置列表 */
|
|
||||||
async function loadConfigs() {
|
async function loadConfigs() {
|
||||||
const env = await getActiveEnv();
|
const env = await getActiveEnv();
|
||||||
return env?.configs || [];
|
return env?.configs || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 覆盖保存当前激活环境的配置 */
|
|
||||||
async function saveConfigs(configs) {
|
async function saveConfigs(configs) {
|
||||||
const { environments, activeEnvId } = await loadEnvironments();
|
const { environments, activeEnvId } = await loadEnvironments();
|
||||||
const env = environments.find((e) => e.id === activeEnvId);
|
const env = environments.find((e) => e.id === activeEnvId);
|
||||||
@@ -123,7 +116,7 @@
|
|||||||
await saveEnvironments(environments);
|
await saveEnvironments(environments);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 扁平化所有环境的配置,附带 envId / envName(用于全局匹配) */
|
|
||||||
async function loadAllConfigs() {
|
async function loadAllConfigs() {
|
||||||
const { environments } = await loadEnvironments();
|
const { environments } = await loadEnvironments();
|
||||||
const all = [];
|
const all = [];
|
||||||
|
|||||||
@@ -154,6 +154,22 @@ h2 {
|
|||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.add-section-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-section-header h2 {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#cancelEditBtn {
|
||||||
|
padding: 4px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.list-section {
|
.list-section {
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
|
|||||||
+4
-1
@@ -43,7 +43,10 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="add-section" id="addSection">
|
<section class="add-section" id="addSection">
|
||||||
<h2 data-i18n="addConfigTitle">添加域名配置</h2>
|
<div class="add-section-header">
|
||||||
|
<h2 id="addConfigTitle" data-i18n="addConfigTitle">添加域名配置</h2>
|
||||||
|
<button type="button" id="cancelEditBtn" class="btn-edit" data-i18n="cancelBtn" hidden>取消</button>
|
||||||
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="domain" data-i18n="domainLabel">域名 / URL 匹配规则</label>
|
<label for="domain" data-i18n="domainLabel">域名 / URL 匹配规则</label>
|
||||||
<div class="domain-wrapper">
|
<div class="domain-wrapper">
|
||||||
|
|||||||
@@ -1,32 +1,24 @@
|
|||||||
document.addEventListener("DOMContentLoaded", () => {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
const $ = (id) => document.getElementById(id);
|
const $ = (id) => document.getElementById(id);
|
||||||
|
|
||||||
// ---------- i18n ----------
|
// i18n
|
||||||
// t(key, ...args) 简易封装:取 chrome.i18n.getMessage,可带 $1/$2 参数
|
|
||||||
const t = (key, ...args) => {
|
const t = (key, ...args) => {
|
||||||
const msg = chrome.i18n.getMessage(key, args);
|
const msg = chrome.i18n.getMessage(key, args);
|
||||||
return msg || key;
|
return msg || key;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 初始化:遍历 data-i18n / data-i18n-placeholder / data-i18n-title / data-i18n-aria 应用翻译
|
// 遍历 data-i18n* 属性应用翻译;含嵌套标签的元素只替换首个文本节点
|
||||||
function applyI18n(root = document) {
|
function applyI18n(root = document) {
|
||||||
root.querySelectorAll("[data-i18n]").forEach((el) => {
|
root.querySelectorAll("[data-i18n]").forEach((el) => {
|
||||||
const key = el.dataset.i18n;
|
const key = el.dataset.i18n;
|
||||||
// 部分元素含 <strong>/<kbd>/<span>/<code> 内嵌标签,统一用 message 文案覆盖 textContent
|
|
||||||
// 仅在没有子标签时直接 textContent;有子标签时跳过(保留原 HTML 结构)
|
|
||||||
if (el.children.length === 0) {
|
if (el.children.length === 0) {
|
||||||
const msg = chrome.i18n.getMessage(key);
|
const msg = chrome.i18n.getMessage(key);
|
||||||
if (msg) el.textContent = msg;
|
if (msg) el.textContent = msg;
|
||||||
} else {
|
} else {
|
||||||
// 含嵌套标签:尝试只替换首个文本节点
|
|
||||||
const msg = chrome.i18n.getMessage(key);
|
const msg = chrome.i18n.getMessage(key);
|
||||||
if (msg) {
|
if (msg) {
|
||||||
// 简化:如果有子标签,整体替换为纯文本(保持现有视觉靠 CSS)
|
|
||||||
// 这里保留原行为:不动结构,仅替换首文本节点
|
|
||||||
const firstText = el.firstChild;
|
const firstText = el.firstChild;
|
||||||
if (firstText && firstText.nodeType === Node.TEXT_NODE) {
|
if (firstText && firstText.nodeType === Node.TEXT_NODE) {
|
||||||
// 找到第一个非空白文本节点,整体替换为 message 文案中第一个空白前部分
|
|
||||||
// 简化处理:含子标签的元素文案在 messages.json 里已写为纯文本(无标签),直接用
|
|
||||||
firstText.nodeValue = msg;
|
firstText.nodeValue = msg;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -62,6 +54,8 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
const passwordSelectorInput = $("passwordSelector");
|
const passwordSelectorInput = $("passwordSelector");
|
||||||
const submitSelectorInput = $("submitSelector");
|
const submitSelectorInput = $("submitSelector");
|
||||||
const addBtn = $("addBtn");
|
const addBtn = $("addBtn");
|
||||||
|
const addConfigTitleEl = $("addConfigTitle");
|
||||||
|
const cancelEditBtn = $("cancelEditBtn");
|
||||||
const configList = $("configList");
|
const configList = $("configList");
|
||||||
const addSection = $("addSection");
|
const addSection = $("addSection");
|
||||||
const listSection = $("listSection");
|
const listSection = $("listSection");
|
||||||
@@ -117,12 +111,9 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
const crackTime = $("crackTime");
|
const crackTime = $("crackTime");
|
||||||
|
|
||||||
let editingId = null;
|
let editingId = null;
|
||||||
// modal 确认后的回调
|
|
||||||
let modalResolve = null;
|
let modalResolve = null;
|
||||||
|
|
||||||
// ---------- 文件密码强度 / 破解时间估算 ----------
|
// 密码强度估算:按 GPU 集群约 10^7 次/秒(PBKDF2-SHA256)计
|
||||||
// 假设攻击者使用 GPU 集群(PBKDF2-SHA256 10 万次迭代,约 1000 万次/秒,即 10^7 次/秒)
|
|
||||||
// 这是中等规模攻击者的合理估算(数十块高端 GPU)
|
|
||||||
const ATTACK_RATE = 1e7;
|
const ATTACK_RATE = 1e7;
|
||||||
|
|
||||||
function estimateStrength(pwd) {
|
function estimateStrength(pwd) {
|
||||||
@@ -131,11 +122,11 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
if (/[a-z]/.test(pwd)) pool += 26;
|
if (/[a-z]/.test(pwd)) pool += 26;
|
||||||
if (/[A-Z]/.test(pwd)) pool += 26;
|
if (/[A-Z]/.test(pwd)) pool += 26;
|
||||||
if (/[0-9]/.test(pwd)) pool += 10;
|
if (/[0-9]/.test(pwd)) pool += 10;
|
||||||
if (/[^a-zA-Z0-9]/.test(pwd)) pool += 32; // 符号集估算
|
if (/[^a-zA-Z0-9]/.test(pwd)) pool += 32;
|
||||||
if (pool === 0) pool = 1;
|
if (pool === 0) pool = 1;
|
||||||
const entropy = pwd.length * Math.log2(pool);
|
const entropy = pwd.length * Math.log2(pool);
|
||||||
const combinations = Math.pow(2, entropy);
|
const combinations = Math.pow(2, entropy);
|
||||||
// 平均破解时间 = 一半的搜索空间
|
// 平均破解时间约为搜索空间的一半
|
||||||
const seconds = combinations / 2 / ATTACK_RATE;
|
const seconds = combinations / 2 / ATTACK_RATE;
|
||||||
let level, label;
|
let level, label;
|
||||||
if (seconds < 1) { level = "very-weak"; label = t("strengthVeryWeak"); }
|
if (seconds < 1) { level = "very-weak"; label = t("strengthVeryWeak"); }
|
||||||
@@ -180,7 +171,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
|
|
||||||
modalPwd.addEventListener("input", () => updateStrength(modalPwd.value));
|
modalPwd.addEventListener("input", () => updateStrength(modalPwd.value));
|
||||||
|
|
||||||
// ---------- Web Crypto 工具:AES-GCM + PBKDF2 ----------
|
// Web Crypto 工具:AES-GCM + PBKDF2
|
||||||
const enc = new TextEncoder();
|
const enc = new TextEncoder();
|
||||||
const dec = new TextDecoder();
|
const dec = new TextDecoder();
|
||||||
|
|
||||||
@@ -252,8 +243,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
return JSON.parse(dec.decode(plainBuf));
|
return JSON.parse(dec.decode(plainBuf));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 文件密码 modal ----------
|
// 文件密码 modal:export 显示加密勾选 + 两次输入,import 只输入一次
|
||||||
// mode: "export" 显示加密勾选 + 两次文件密码; "import" 仅显示一次文件密码
|
|
||||||
function openPwdModal(mode) {
|
function openPwdModal(mode) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
modalResolve = resolve;
|
modalResolve = resolve;
|
||||||
@@ -305,7 +295,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
|
|
||||||
modalConfirm.addEventListener("click", () => {
|
modalConfirm.addEventListener("click", () => {
|
||||||
if (!modalResolve) return;
|
if (!modalResolve) return;
|
||||||
// 通过 modal 当前模式标记判断(openPwdModal 时 mode 已确定 export/import 文案)
|
|
||||||
const isExport = modalTitle.textContent === t("pwdModalExportTitle");
|
const isExport = modalTitle.textContent === t("pwdModalExportTitle");
|
||||||
if (isExport) {
|
if (isExport) {
|
||||||
if (encryptToggle.checked) {
|
if (encryptToggle.checked) {
|
||||||
@@ -377,7 +367,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// 表单内容实时保存为草稿,关闭弹窗后不丢失
|
// 表单内容实时存入草稿,弹窗关闭后不丢失
|
||||||
const draftFields = [
|
const draftFields = [
|
||||||
domainInput, aliasInput, usernameInput, passwordInput, autoSubmitSelect,
|
domainInput, aliasInput, usernameInput, passwordInput, autoSubmitSelect,
|
||||||
usernameSelectorInput, passwordSelectorInput, submitSelectorInput,
|
usernameSelectorInput, passwordSelectorInput, submitSelectorInput,
|
||||||
@@ -388,7 +378,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
el.addEventListener("change", saveDraft);
|
el.addEventListener("change", saveDraft);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------- 环境数据层(委托给 env-store.js 全局 EnvStore) ----------
|
// 环境数据层(EnvStore)
|
||||||
const {
|
const {
|
||||||
loadEnvironments,
|
loadEnvironments,
|
||||||
saveEnvironments,
|
saveEnvironments,
|
||||||
@@ -405,7 +395,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
} = EnvStore;
|
} = EnvStore;
|
||||||
// isDomainMatch 由 utils.js 提供(全局函数)
|
// isDomainMatch 由 utils.js 提供(全局函数)
|
||||||
|
|
||||||
// 草稿:弹窗关闭后保留未提交的表单内容
|
|
||||||
const DRAFT_KEY = "formDraft";
|
const DRAFT_KEY = "formDraft";
|
||||||
|
|
||||||
function saveDraft() {
|
function saveDraft() {
|
||||||
@@ -441,13 +431,15 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
delayMsInput.value = draft.delayMs || "";
|
delayMsInput.value = draft.delayMs || "";
|
||||||
editingId = draft.editingId || null;
|
editingId = draft.editingId || null;
|
||||||
addBtn.textContent = editingId ? t("updateConfigBtn") : t("addConfigBtn");
|
addBtn.textContent = editingId ? t("updateConfigBtn") : t("addConfigBtn");
|
||||||
|
addConfigTitleEl.textContent = editingId ? t("editConfigTitle") : t("addConfigTitle");
|
||||||
|
cancelEditBtn.hidden = !editingId;
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearDraft() {
|
function clearDraft() {
|
||||||
chrome.storage.local.remove(DRAFT_KEY);
|
chrome.storage.local.remove(DRAFT_KEY);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 环境栏渲染与交互 ----------
|
// 环境栏
|
||||||
let envModalResolve = null;
|
let envModalResolve = null;
|
||||||
// mode: "create" | "rename"
|
// mode: "create" | "rename"
|
||||||
function openEnvModal(mode, currentName = "") {
|
function openEnvModal(mode, currentName = "") {
|
||||||
@@ -534,7 +526,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
showToast(t("toastSwitchEnv"));
|
showToast(t("toastSwitchEnv"));
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------- 环境管理对话框 ----------
|
// 环境管理对话框
|
||||||
function openEnvManageModal() {
|
function openEnvManageModal() {
|
||||||
envManageWarn.textContent = "";
|
envManageWarn.textContent = "";
|
||||||
envManageWarn.style.display = "none";
|
envManageWarn.style.display = "none";
|
||||||
@@ -640,16 +632,14 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
const configs = await loadConfigs();
|
const configs = await loadConfigs();
|
||||||
configList.innerHTML = "";
|
configList.innerHTML = "";
|
||||||
|
|
||||||
// 配置为 0:禁用导出,列表在前;非 0:启用导出,添加表单在前
|
// 无配置时禁用导出,此时表单在上、空状态的列表在下;有配置时反过来
|
||||||
const isEmpty = configs.length === 0;
|
const isEmpty = configs.length === 0;
|
||||||
exportBtn.disabled = isEmpty;
|
exportBtn.disabled = isEmpty;
|
||||||
exportBtn.classList.toggle("btn-disabled", isEmpty);
|
exportBtn.classList.toggle("btn-disabled", isEmpty);
|
||||||
const parent = addSection.parentElement;
|
const parent = addSection.parentElement;
|
||||||
if (isEmpty) {
|
if (isEmpty) {
|
||||||
// 空状态:add-section 在上,list-section 在下(已是默认顺序,确保如此)
|
|
||||||
if (parent.children[0] !== addSection) parent.insertBefore(addSection, listSection);
|
if (parent.children[0] !== addSection) parent.insertBefore(addSection, listSection);
|
||||||
} else {
|
} else {
|
||||||
// 非空:list-section 在上,add-section 在下
|
|
||||||
if (parent.children[0] !== listSection) parent.insertBefore(listSection, addSection);
|
if (parent.children[0] !== listSection) parent.insertBefore(listSection, addSection);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -695,7 +685,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
const actions = document.createElement("div");
|
const actions = document.createElement("div");
|
||||||
actions.className = "config-actions";
|
actions.className = "config-actions";
|
||||||
|
|
||||||
// 启用/禁用切换按钮(不删除配置)
|
// 启用/禁用切换
|
||||||
const toggleBtn = document.createElement("button");
|
const toggleBtn = document.createElement("button");
|
||||||
toggleBtn.className = "btn-toggle";
|
toggleBtn.className = "btn-toggle";
|
||||||
const isDisabled = cfg.enabled === false;
|
const isDisabled = cfg.enabled === false;
|
||||||
@@ -728,6 +718,8 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
enabledCheckbox.checked = cfg.enabled !== false;
|
enabledCheckbox.checked = cfg.enabled !== false;
|
||||||
delayMsInput.value = cfg.delayMs || "";
|
delayMsInput.value = cfg.delayMs || "";
|
||||||
addBtn.textContent = t("updateConfigBtn");
|
addBtn.textContent = t("updateConfigBtn");
|
||||||
|
addConfigTitleEl.textContent = t("editConfigTitle");
|
||||||
|
cancelEditBtn.hidden = false;
|
||||||
saveDraft();
|
saveDraft();
|
||||||
domainInput.focus();
|
domainInput.focus();
|
||||||
});
|
});
|
||||||
@@ -769,14 +761,15 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
enabledCheckbox.checked = true;
|
enabledCheckbox.checked = true;
|
||||||
delayMsInput.value = "";
|
delayMsInput.value = "";
|
||||||
addBtn.textContent = t("addConfigBtn");
|
addBtn.textContent = t("addConfigBtn");
|
||||||
|
addConfigTitleEl.textContent = t("addConfigTitle");
|
||||||
|
cancelEditBtn.hidden = true;
|
||||||
clearDraft();
|
clearDraft();
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(str) {
|
|
||||||
const div = document.createElement("div");
|
cancelEditBtn.addEventListener("click", () => {
|
||||||
div.textContent = str;
|
resetForm();
|
||||||
return div.innerHTML;
|
});
|
||||||
}
|
|
||||||
|
|
||||||
addBtn.addEventListener("click", async () => {
|
addBtn.addEventListener("click", async () => {
|
||||||
const domain = domainInput.value.trim();
|
const domain = domainInput.value.trim();
|
||||||
@@ -830,7 +823,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 跨所有环境查找匹配的配置
|
// 跨所有环境查找匹配当前标签页的配置
|
||||||
const allConfigs = await loadAllConfigs();
|
const allConfigs = await loadAllConfigs();
|
||||||
const matched = allConfigs.filter((c) => isDomainMatch(tab.url, c.domain));
|
const matched = allConfigs.filter((c) => isDomainMatch(tab.url, c.domain));
|
||||||
|
|
||||||
@@ -861,7 +854,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 多个环境命中:发到 content.js 由它弹选择菜单
|
|
||||||
try {
|
try {
|
||||||
await chrome.tabs.sendMessage(tab.id, {
|
await chrome.tabs.sendMessage(tab.id, {
|
||||||
action: "manualFillMulti",
|
action: "manualFillMulti",
|
||||||
@@ -898,7 +891,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 导出配置:导出整个 environments 结构(含环境名)
|
// 导出整个 environments 结构(含环境名)
|
||||||
exportBtn.addEventListener("click", async () => {
|
exportBtn.addEventListener("click", async () => {
|
||||||
const { environments } = await loadEnvironments();
|
const { environments } = await loadEnvironments();
|
||||||
const totalConfigs = environments.reduce((s, e) => s + (e.configs?.length || 0), 0);
|
const totalConfigs = environments.reduce((s, e) => s + (e.configs?.length || 0), 0);
|
||||||
@@ -1097,9 +1090,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------- 消息发送工具:带重试 + 上下文失效检测 ----------
|
// 消息发送:SW 刚休眠唤醒时首次 sendMessage 会失败,需重试
|
||||||
// MV3 service worker 可能刚休眠唤醒 / 刚加载,首次 sendMessage 会失败
|
|
||||||
// "Could not establish connection. Receiving end does not exist." 属于此类
|
|
||||||
function sendMessage(message, retries = 3) {
|
function sendMessage(message, retries = 3) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
let attempt = 0;
|
let attempt = 0;
|
||||||
@@ -1129,7 +1120,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 锁定 / 解锁 ----------
|
// 锁定 / 解锁
|
||||||
let lockMode = "setup"; // "setup" | "unlock"
|
let lockMode = "setup"; // "setup" | "unlock"
|
||||||
|
|
||||||
function showLockScreen(mode) {
|
function showLockScreen(mode) {
|
||||||
@@ -1244,7 +1235,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
showToast(t("toastLocked"));
|
showToast(t("toastLocked"));
|
||||||
});
|
});
|
||||||
|
|
||||||
// 用户在弹窗内的操作视为活动,通知 background 重置自动锁定计时
|
// 弹窗内的点击视为用户活动,重置 background 自动锁定计时
|
||||||
document.addEventListener("click", () => {
|
document.addEventListener("click", () => {
|
||||||
if (lockScreen.style.display === "none") {
|
if (lockScreen.style.display === "none") {
|
||||||
sendMessage({ action: "ping" }, 1).catch(() => {});
|
sendMessage({ action: "ping" }, 1).catch(() => {});
|
||||||
|
|||||||
@@ -1,8 +1,4 @@
|
|||||||
/**
|
// URL 匹配 + HTML 转义,浏览器与 Node 通用
|
||||||
* 共享纯函数模块:URL 匹配 + HTML 转义
|
|
||||||
* 同时在浏览器(content script / popup)和 Node 测试环境下可用。
|
|
||||||
* 浏览器中挂载到全局作用域,Node 中通过 module.exports 暴露。
|
|
||||||
*/
|
|
||||||
(function (root, factory) {
|
(function (root, factory) {
|
||||||
if (typeof module === "object" && module.exports) {
|
if (typeof module === "object" && module.exports) {
|
||||||
module.exports = factory();
|
module.exports = factory();
|
||||||
@@ -15,18 +11,9 @@
|
|||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 判断当前页面 URL 是否匹配配置的 pattern。
|
* 判断 URL 是否匹配规则。
|
||||||
* 兼容两种格式:
|
* 纯 hostname:example.com / *.example.com
|
||||||
* 1. 纯 hostname:"example.com" / "*.example.com" —— 只匹配域名
|
* hostname+path:example.com/login/*,路径部分支持 * 通配
|
||||||
* 2. hostname + path:"example.com/login" / "*.example.com/admin/*" —— 域名和路径都要匹配
|
|
||||||
* 路径部分支持 "*" 通配:
|
|
||||||
* - "login" 精确匹配 pathname 为 /login
|
|
||||||
* - "login/*" 前缀匹配:/login 或 /login/xxx
|
|
||||||
* - "*" 匹配任意路径
|
|
||||||
* - "a/* /b" 路径内任意位置通配(转正则)
|
|
||||||
* @param {string} url 当前页面完整 URL
|
|
||||||
* @param {string} pattern 配置的匹配规则
|
|
||||||
* @returns {boolean}
|
|
||||||
*/
|
*/
|
||||||
function isDomainMatch(url, pattern) {
|
function isDomainMatch(url, pattern) {
|
||||||
if (!url || !pattern) return false;
|
if (!url || !pattern) return false;
|
||||||
@@ -35,7 +22,6 @@
|
|||||||
const hostname = u.hostname;
|
const hostname = u.hostname;
|
||||||
const rawPattern = String(pattern).trim();
|
const rawPattern = String(pattern).trim();
|
||||||
|
|
||||||
// 带 / 的规则走 hostname + path 匹配
|
|
||||||
if (rawPattern.includes("/")) {
|
if (rawPattern.includes("/")) {
|
||||||
const slashIdx = rawPattern.indexOf("/");
|
const slashIdx = rawPattern.indexOf("/");
|
||||||
const hostPart = rawPattern.slice(0, slashIdx);
|
const hostPart = rawPattern.slice(0, slashIdx);
|
||||||
@@ -44,7 +30,6 @@
|
|||||||
return matchPath(u.pathname, pathPart);
|
return matchPath(u.pathname, pathPart);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 纯 hostname 匹配
|
|
||||||
return matchHost(hostname, rawPattern);
|
return matchHost(hostname, rawPattern);
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
@@ -64,11 +49,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function matchPath(pathname, pathPart) {
|
function matchPath(pathname, pathPart) {
|
||||||
// pathname 始终以 / 开头,去掉前导 / 再比较
|
|
||||||
const pagePath = pathname.startsWith("/") ? pathname.slice(1) : pathname;
|
const pagePath = pathname.startsWith("/") ? pathname.slice(1) : pathname;
|
||||||
if (!pathPart) return true;
|
if (!pathPart) return true;
|
||||||
if (pathPart === "*") return true;
|
if (pathPart === "*") return true;
|
||||||
// 末尾 /* 表示前缀匹配
|
|
||||||
if (pathPart.endsWith("/*")) {
|
if (pathPart.endsWith("/*")) {
|
||||||
const prefix = pathPart.slice(0, -2);
|
const prefix = pathPart.slice(0, -2);
|
||||||
return pagePath === prefix || pagePath.startsWith(prefix + "/");
|
return pagePath === prefix || pagePath.startsWith(prefix + "/");
|
||||||
@@ -80,11 +63,7 @@
|
|||||||
return pagePath === pathPart;
|
return pagePath === pathPart;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** HTML 转义,防止配置中的特殊字符破坏页面 */
|
||||||
* HTML 转义,防止配置中的特殊字符破坏页面结构。
|
|
||||||
* @param {*} str
|
|
||||||
* @returns {string}
|
|
||||||
*/
|
|
||||||
function escapeHtml(str) {
|
function escapeHtml(str) {
|
||||||
if (str == null) return "";
|
if (str == null) return "";
|
||||||
return String(str)
|
return String(str)
|
||||||
|
|||||||
Reference in New Issue
Block a user