From 991cfe3c35d052eec2dae6466db95f19246da2c5 Mon Sep 17 00:00:00 2001 From: "yinjun.chen1" Date: Mon, 14 Sep 2026 17:51:40 +0800 Subject: [PATCH] no message --- .arts/settings.json | 2 + _locales/en/messages.json | 4 ++ _locales/zh_CN/messages.json | 4 ++ background.js | 36 ++++------------- content.js | 42 +++++++++----------- crypto-store.js | 42 ++++++-------------- env-store.js | 21 ++++------ popup.css | 16 ++++++++ popup.html | 5 ++- popup.js | 75 ++++++++++++++++-------------------- utils.js | 31 +++------------ 11 files changed, 112 insertions(+), 166 deletions(-) create mode 100644 .arts/settings.json diff --git a/.arts/settings.json b/.arts/settings.json new file mode 100644 index 0000000..7a73a41 --- /dev/null +++ b/.arts/settings.json @@ -0,0 +1,2 @@ +{ +} \ No newline at end of file diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 2e53ec7..1fe06d5 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -103,6 +103,10 @@ "message": "Add Domain Configuration", "description": "Add config section title" }, + "editConfigTitle": { + "message": "Edit Domain Configuration", + "description": "Edit config section title" + }, "domainLabel": { "message": "Domain / URL Match Rule", "description": "Domain input label" diff --git a/_locales/zh_CN/messages.json b/_locales/zh_CN/messages.json index 5c9d0dd..8cd6070 100644 --- a/_locales/zh_CN/messages.json +++ b/_locales/zh_CN/messages.json @@ -103,6 +103,10 @@ "message": "添加域名配置", "description": "添加配置区标题" }, + "editConfigTitle": { + "message": "编辑域名配置", + "description": "编辑配置区标题" + }, "domainLabel": { "message": "域名 / URL 匹配规则", "description": "域名输入框 label" diff --git a/background.js b/background.js index 06923f0..85678c3 100644 --- a/background.js +++ b/background.js @@ -1,23 +1,12 @@ -// background.js —— Service Worker,负责: -// 1. Side Panel 打开行为 -// 2. 口令锁状态管理(解锁后内存持有 AES 密钥) -// 3. session 数据变化时自动加密回写 local -// 4. 闲置自动锁定 -// 5. 旧版明文库迁移到加密库 - importScripts("crypto-store.js"); -// 内存中的派生密钥,解锁后才有值,锁定/SW 重启后清空 -let masterKey = null; -// 闲置自动锁定计时器 +let masterKey = null; // 解锁后持有,锁定或 SW 重启后清空 let autoLockTimer = null; const AUTO_LOCK_MS = 10 * 60 * 1000; // 10 分钟无操作自动锁定 -// 全局启用 Side Panel chrome.runtime.onInstalled.addListener(() => { chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }) .catch((err) => console.warn("[AutoLogin] 设置 sidePanel 行为失败:", err)); - // 安装/升级时迁移旧版明文库 migrateLegacyVault(); console.log("[AutoLogin] 扩展已安装"); }); @@ -28,17 +17,12 @@ chrome.action.onClicked.addListener((tab) => { } }); -/** - * 旧版迁移:如果 local 中存在明文 environments 但没有 vaultMeta, - * 则提示用户需要先设置口令(无法在无口令下加密)。 - * 这里把明文数据暂存,等用户设置口令时由 setupVault 导入。 - */ +// 明文数据无法在无口令下加密,暂存为 pendingImport,待用户设置口令时由 setupVault 导入 async function migrateLegacyVault() { const data = await chrome.storage.local.get(["environments", "loginConfigs", "vaultMeta"]); const hasVault = !!data.vaultMeta; if (hasVault) return; - // 有旧版明文数据:迁移为待导入状态 const legacyEnvs = data.environments; const legacyConfigs = data.loginConfigs; if (legacyEnvs || legacyConfigs) { @@ -60,7 +44,7 @@ async function migrateLegacyVault() { } } -/** 重置闲置自动锁定计时器 */ + function resetAutoLockTimer() { if (autoLockTimer) clearTimeout(autoLockTimer); if (masterKey) { @@ -77,10 +61,7 @@ async function doLock() { await CryptoStore.lock(); } -/** - * 监听 session 中 environments 变化:用内存密钥重新加密写回 local。 - * 这是"加密透明化"的核心——EnvStore 只管 session,加密由这里自动完成。 - */ +// session 中 environments 变化时用内存密钥重新加密写回 local chrome.storage.onChanged.addListener(async (changes, areaName) => { if (areaName !== "session") 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) => { (async () => { try { @@ -107,13 +88,12 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { } case "setupVault": { - // 首次设置口令。如果有待导入的旧数据,一并加密。 + // 首次设置口令,若有待导入的旧数据则一并加密 const pendingData = await chrome.storage.local.get("pendingImport"); let initialEnvs = []; if (pendingData.pendingImport) { initialEnvs = pendingData.pendingImport.data; } else { - // 预置 3 个环境 const PRESET = ["个人", "测试", "生产"]; initialEnvs = PRESET.map((name) => ({ 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": { - // 供 popup 显示闲置倒计时 + sendResponse({ unlocked: !!masterKey, autoLockMs: AUTO_LOCK_MS, @@ -156,7 +136,7 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { } case "ping": { - // 任何消息都算用户活动,重置自动锁定计时 + // 任何消息都视为用户活动,顺带重置自动锁定计时 resetAutoLockTimer(); sendResponse({ pong: true }); break; diff --git a/content.js b/content.js index 0bc3cb6..0b6b149 100644 --- a/content.js +++ b/content.js @@ -1,19 +1,17 @@ -// isDomainMatch 由 utils.js 提供(全局函数) -// i18n:content script 里取文案统一通过 chrome.i18n.getMessage const t = (key, ...args) => chrome.i18n.getMessage(key, args) || key; // 2FA / 验证码检测:检测到则不自动提交,仅填充 function detectCaptcha() { - // 1. reCAPTCHA + // reCAPTCHA if (document.querySelector(".g-recaptcha, [data-sitekey], iframe[src*='recaptcha'], iframe[src*='google.com/recaptcha']")) { return { type: "reCAPTCHA", reason: t("captchaTitleRe") }; } - // 2. hCaptcha + // hCaptcha if (document.querySelector(".h-captcha, iframe[src*='hcaptcha']")) { return { type: "hCaptcha", reason: t("captchaTitleH") }; } - // 3. 国内常见滑块/验证码容器 + // 国内常见滑块/验证码容器 const sliderSelectors = [ "#slider", ".slider", ".nc_wrapper", ".nc_iconfont", "#captcha", ".captcha", ".geetest", "#geetest", @@ -28,7 +26,7 @@ function detectCaptcha() { } } catch { /* selector 可能无效 */ } } - // 4. 明显的"验证码"文字输入框(带图形码) + // 明显的"验证码"文字输入框(带图形码) 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]" ); @@ -206,20 +204,19 @@ function fillForm(config, canSubmit = true) { setNativeValue(passwordField, config.password); console.log("[AutoLogin]", t("logFilledPassword")); - // 验证码检测:检测到则强制不自动提交,提示用户手动操作 + // 检测到验证码则强制不自动提交,提示用户手动操作 const captcha = detectCaptcha(); let actualCanSubmit = canSubmit; if (captcha && config.autoSubmit) { actualCanSubmit = false; console.warn("[AutoLogin]", t("logCaptchaSkip", captcha.reason)); - // 在页面右上角显示提示浮窗 showCaptchaToast(captcha.reason); } if (config.autoSubmit && actualCanSubmit) { const submitBtn = findSubmitButton(config, passwordField); if (submitBtn) { - // 延迟提交:config.delayMs 给某些需要 JS 初始化的页面留时间 + // delayMs 给需要 JS 初始化的页面留出时间 const delay = Math.max(300, Number(config.delayMs) || 300); setTimeout(() => { console.log("[AutoLogin]", t("logAutoClick", String(delay))); @@ -251,7 +248,7 @@ function fillForm(config, canSubmit = true) { return true; } -// 验证码提示浮窗:在页面右上角显示,几秒后自动消失 +// 验证码提示浮窗,8 秒后自动消失 let captchaToastEl = null; function showCaptchaToast(reason) { if (captchaToastEl && captchaToastEl.parentNode) { @@ -286,7 +283,7 @@ function showCaptchaToast(reason) { const MAX_LOGIN_ATTEMPTS = 3; const ATTEMPT_COOLDOWN = 5 * 60 * 1000; -// 失败计数按 envId+domain 索引,支持同 domain 在不同环境下独立计数 +// 失败计数按 envId+domain 索引,同 domain 在不同环境独立计数 function attemptKey(envId, domain) { return `${envId}::${domain}`; } @@ -320,7 +317,7 @@ async function resetDomainAttempts(envId, domain) { } } -// 从 session 中扁平化读取所有配置(解锁后明文存于 session,未解锁时为空) + async function loadAllConfigs() { const data = await chrome.storage.session.get("environments"); const environments = data.environments || []; @@ -337,32 +334,31 @@ async function autoFillOnLoad() { const allConfigs = await loadAllConfigs(); if (allConfigs.length === 0) return; - // 跳过 enabled === false 的配置(独立开关) + // 每个配置有独立开关 enabled const enabledConfigs = allConfigs.filter((c) => c.enabled !== false); if (enabledConfigs.length === 0) return; const matched = enabledConfigs.filter((c) => isDomainMatch(window.location.href, c.domain)); if (matched.length === 0) return; - // 单个匹配:直接填充 if (matched.length === 1) { await fillSingleConfig(matched[0]); return; } - // 多个匹配(跨环境):弹浮窗让用户选择 + // 多环境命中:弹浮窗让用户选择 console.log("[AutoLogin]", t("logMatchedMulti", String(matched.length))); showEnvPicker(matched); } -// SPA 动态表单:尝试填充,表单未出现时持续重试 30 秒 +// SPA 表单可能晚于脚本加载,填不到时每 500ms 重试,30 秒超时 const SPA_FILL_TIMEOUT_MS = 30000; const SPA_RETRY_INTERVAL_MS = 500; async function fillSingleConfig(config) { console.log("[AutoLogin]", t("logStartFillEnv", config.envName || t("defaultEnvName"))); - // 检查自动登录尝试次数,超过上限则仅填充不自动提交 + // 超限后仅填充不自动提交 let canSubmit = config.autoSubmit; if (config.autoSubmit) { 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 tryFill = () => { const elapsed = Date.now() - startTime; @@ -404,10 +400,9 @@ async function fillSingleConfig(config) { tryFill(); } -// 多环境命中时,在页面右上角插入选择浮窗 +// 多环境命中时在页面右上角插入选择浮窗 let envPickerEl = null; function showEnvPicker(configs) { - // 移除已有浮窗 if (envPickerEl && envPickerEl.parentNode) { envPickerEl.parentNode.removeChild(envPickerEl); envPickerEl = null; @@ -499,16 +494,14 @@ function showEnvPicker(configs) { envPickerEl = overlay; } -// escapeHtml 由 utils.js 提供(全局函数) chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { if (message.action === "manualFill" && message.config) { - // 手动触发填充,重置该环境+域名的失败计数 + // 手动触发填充,重置失败计数 resetDomainAttempts(message.config.envId, message.config.domain); const filled = fillForm(message.config, true); sendResponse({ success: filled }); } else if (message.action === "manualFillMulti" && Array.isArray(message.configs)) { - // popup 多环境命中:弹出选择菜单 showEnvPicker(message.configs); sendResponse({ success: true }); } @@ -517,12 +510,13 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { autoFillOnLoad(); +// URL 变化后若新页面不再匹配任何配置,重置之前匹配环境的失败计数 let lastUrl = window.location.href; const observer = new MutationObserver(() => { if (window.location.href !== lastUrl) { const prevUrl = lastUrl; lastUrl = window.location.href; - // URL 变化后,如果新页面不再匹配任何配置,重置之前匹配环境的失败计数 + loadAllConfigs().then((allConfigs) => { const stillMatched = allConfigs.some((c) => isDomainMatch(window.location.href, c.domain)); if (!stillMatched) { diff --git a/crypto-store.js b/crypto-store.js index 3e4a227..64ec896 100644 --- a/crypto-store.js +++ b/crypto-store.js @@ -1,18 +1,7 @@ /** - * 加密存储模块:主口令 + AES-GCM 加密整库。 - * - * 存储布局: - * 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 测试环境下可用。 + * 加密库:主口令 + AES-GCM 整库加密。 + * local 存 vaultMeta / encryptedVault;解锁后明文放 session,不进 local; + * 派生密钥仅由 background 在内存中持有。 */ (function (root, factory) { if (typeof module === "object" && module.exports) { @@ -58,7 +47,7 @@ return arr; } - /** 从口令派生 AES-GCM 密钥 */ + async function deriveKey(password, salt, iterations) { const enc = new TextEncoder(); const keyMaterial = await getCrypto().subtle.importKey( @@ -73,7 +62,7 @@ ); } - /** 加密任意 JSON 对象,返回 { iv, ciphertext } base64 */ + async function encryptJson(obj, key) { const enc = new TextEncoder(); const iv = randomBytes(12); @@ -83,7 +72,7 @@ return { iv: b64encode(iv), ciphertext: b64encode(ciphertext) }; } - /** 解密为 JSON 对象,口令错误/数据损坏抛错 */ + /** 解密为 JSON 对象,口令错误或数据损坏时抛错 */ async function decryptJson({ iv, ciphertext }, key) { const dec = new TextDecoder(); const plain = await getCrypto().subtle.decrypt( @@ -96,23 +85,20 @@ return (await getStorage("local").get(key))[key]; } - /** 是否已创建过口令 */ + async function hasVault() { const meta = await getLocal(VAULT_META_KEY); const cipher = await getLocal(ENCRYPTED_VAULT_KEY); return !!(meta && cipher); } - /** 是否处于解锁状态(session 中有明文环境数据) */ + async function isUnlocked() { const data = await getStorage("session").get(SESSION_ENV_KEY); return !!data[SESSION_ENV_KEY]; } - /** - * 首次创建口令:生成盐,用口令加密初始环境,写入 local。 - * 同时把环境写入 session,视为已解锁。返回派生密钥(供调用方内存持有)。 - */ + /** 首次设置口令:生成盐、加密初始环境写入 local,并把明文写入 session,返回派生密钥 */ async function setupVault(password, environments) { if (!password) throw new Error("口令不能为空"); const salt = randomBytes(16); @@ -135,10 +121,7 @@ return key; } - /** - * 解锁:用口令解密 local 密文,写入 session。 - * 返回 { environments, key },口令错误抛错。 - */ + /** 解锁:解密 local 密文写入 session,口令错误抛错,返回明文与密钥 */ async function unlock(password) { const meta = await getLocal(VAULT_META_KEY); const cipher = await getLocal(ENCRYPTED_VAULT_KEY); @@ -159,22 +142,19 @@ return { environments, key }; } - /** 锁定:清除 session 中的明文 */ async function lock() { await getStorage("session").remove([SESSION_ENV_KEY, SESSION_ACTIVE_KEY]); } - /** 读取 vaultMeta(供 background 派生密钥用) */ async function getVaultMeta() { return getLocal(VAULT_META_KEY); } - /** 读取加密 vault 密文 */ async function getEncryptedVault() { return getLocal(ENCRYPTED_VAULT_KEY); } - /** 用给定密钥加密 environments 并写回 local(background 内存密钥调用) */ + /** 用给定密钥加密 environments 并写回 local */ async function persistEncrypted(environments, key) { const cipher = await encryptJson(environments, key); await getStorage("local").set({ [ENCRYPTED_VAULT_KEY]: cipher }); diff --git a/env-store.js b/env-store.js index 35af4b2..d490c80 100644 --- a/env-store.js +++ b/env-store.js @@ -1,11 +1,6 @@ /** - * 环境数据层:管理多环境配置。 - * - * 数据存放在 chrome.storage.session(解锁后明文,内存中、不落盘)。 - * 首次创建口令由 crypto-store.setupVault 完成(含预置环境)。 - * 锁定时 session 被清空,本层返回空数据。 - * - * 浏览器中通过全局 chrome.storage.session 存取;Node 测试中注入 mock。 + * 环境数据层:环境/配置的读写,数据存于 chrome.storage.session(解锁后明文)。 + * 首次建口令由 crypto-store.setupVault 完成;加密回写由 background 监听完成。 */ (function (root, factory) { if (typeof module === "object" && module.exports) { @@ -38,9 +33,7 @@ return s; } - /** - * 加载所有环境。未解锁时 session 为空,返回空数组。 - */ + async function loadEnvironments() { const data = await storage().get([ENV_KEY, ACTIVE_ENV_KEY]); const environments = data[ENV_KEY] || []; @@ -96,7 +89,7 @@ environments.splice(idx, 1); const newActive = activeEnvId === envId ? environments[0].id : activeEnvId; await saveEnvironments(environments, newActive); - // 清理该环境的失败计数(失败计数非敏感,存于 local 持久化) + // 失败计数存于 local(非敏感),删除环境时一并清理 const local = getLocalStorage(); if (local) { const attemptsData = await local.get("loginAttempts"); @@ -108,13 +101,13 @@ } } - /** 当前激活环境的配置列表 */ + async function loadConfigs() { const env = await getActiveEnv(); return env?.configs || []; } - /** 覆盖保存当前激活环境的配置 */ + async function saveConfigs(configs) { const { environments, activeEnvId } = await loadEnvironments(); const env = environments.find((e) => e.id === activeEnvId); @@ -123,7 +116,7 @@ await saveEnvironments(environments); } - /** 扁平化所有环境的配置,附带 envId / envName(用于全局匹配) */ + async function loadAllConfigs() { const { environments } = await loadEnvironments(); const all = []; diff --git a/popup.css b/popup.css index 042f61d..43f3ddb 100644 --- a/popup.css +++ b/popup.css @@ -154,6 +154,22 @@ h2 { 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 { margin-bottom: 20px; flex: 1 1 auto; diff --git a/popup.html b/popup.html index 4ea6139..3d47752 100644 --- a/popup.html +++ b/popup.html @@ -43,7 +43,10 @@
-

添加域名配置

+
+

添加域名配置

+ +
diff --git a/popup.js b/popup.js index 019856f..053051f 100644 --- a/popup.js +++ b/popup.js @@ -1,32 +1,24 @@ document.addEventListener("DOMContentLoaded", () => { const $ = (id) => document.getElementById(id); - // ---------- i18n ---------- - // t(key, ...args) 简易封装:取 chrome.i18n.getMessage,可带 $1/$2 参数 + // i18n const t = (key, ...args) => { const msg = chrome.i18n.getMessage(key, args); return msg || key; }; - // 初始化:遍历 data-i18n / data-i18n-placeholder / data-i18n-title / data-i18n-aria 应用翻译 + // 遍历 data-i18n* 属性应用翻译;含嵌套标签的元素只替换首个文本节点 function applyI18n(root = document) { root.querySelectorAll("[data-i18n]").forEach((el) => { const key = el.dataset.i18n; - // 部分元素含 /// 内嵌标签,统一用 message 文案覆盖 textContent - // 仅在没有子标签时直接 textContent;有子标签时跳过(保留原 HTML 结构) if (el.children.length === 0) { const msg = chrome.i18n.getMessage(key); if (msg) el.textContent = msg; } else { - // 含嵌套标签:尝试只替换首个文本节点 const msg = chrome.i18n.getMessage(key); if (msg) { - // 简化:如果有子标签,整体替换为纯文本(保持现有视觉靠 CSS) - // 这里保留原行为:不动结构,仅替换首文本节点 const firstText = el.firstChild; if (firstText && firstText.nodeType === Node.TEXT_NODE) { - // 找到第一个非空白文本节点,整体替换为 message 文案中第一个空白前部分 - // 简化处理:含子标签的元素文案在 messages.json 里已写为纯文本(无标签),直接用 firstText.nodeValue = msg; } } @@ -62,6 +54,8 @@ document.addEventListener("DOMContentLoaded", () => { const passwordSelectorInput = $("passwordSelector"); const submitSelectorInput = $("submitSelector"); const addBtn = $("addBtn"); + const addConfigTitleEl = $("addConfigTitle"); + const cancelEditBtn = $("cancelEditBtn"); const configList = $("configList"); const addSection = $("addSection"); const listSection = $("listSection"); @@ -117,12 +111,9 @@ document.addEventListener("DOMContentLoaded", () => { const crackTime = $("crackTime"); let editingId = null; - // modal 确认后的回调 let modalResolve = null; - // ---------- 文件密码强度 / 破解时间估算 ---------- - // 假设攻击者使用 GPU 集群(PBKDF2-SHA256 10 万次迭代,约 1000 万次/秒,即 10^7 次/秒) - // 这是中等规模攻击者的合理估算(数十块高端 GPU) + // 密码强度估算:按 GPU 集群约 10^7 次/秒(PBKDF2-SHA256)计 const ATTACK_RATE = 1e7; 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 (/[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; const entropy = pwd.length * Math.log2(pool); const combinations = Math.pow(2, entropy); - // 平均破解时间 = 一半的搜索空间 + // 平均破解时间约为搜索空间的一半 const seconds = combinations / 2 / ATTACK_RATE; let level, label; if (seconds < 1) { level = "very-weak"; label = t("strengthVeryWeak"); } @@ -180,7 +171,7 @@ document.addEventListener("DOMContentLoaded", () => { modalPwd.addEventListener("input", () => updateStrength(modalPwd.value)); - // ---------- Web Crypto 工具:AES-GCM + PBKDF2 ---------- + // Web Crypto 工具:AES-GCM + PBKDF2 const enc = new TextEncoder(); const dec = new TextDecoder(); @@ -252,8 +243,7 @@ document.addEventListener("DOMContentLoaded", () => { return JSON.parse(dec.decode(plainBuf)); } - // ---------- 文件密码 modal ---------- - // mode: "export" 显示加密勾选 + 两次文件密码; "import" 仅显示一次文件密码 + // 文件密码 modal:export 显示加密勾选 + 两次输入,import 只输入一次 function openPwdModal(mode) { return new Promise((resolve) => { modalResolve = resolve; @@ -305,7 +295,7 @@ document.addEventListener("DOMContentLoaded", () => { modalConfirm.addEventListener("click", () => { if (!modalResolve) return; - // 通过 modal 当前模式标记判断(openPwdModal 时 mode 已确定 export/import 文案) + const isExport = modalTitle.textContent === t("pwdModalExportTitle"); if (isExport) { if (encryptToggle.checked) { @@ -377,7 +367,7 @@ document.addEventListener("DOMContentLoaded", () => { }); }); - // 表单内容实时保存为草稿,关闭弹窗后不丢失 + // 表单内容实时存入草稿,弹窗关闭后不丢失 const draftFields = [ domainInput, aliasInput, usernameInput, passwordInput, autoSubmitSelect, usernameSelectorInput, passwordSelectorInput, submitSelectorInput, @@ -388,7 +378,7 @@ document.addEventListener("DOMContentLoaded", () => { el.addEventListener("change", saveDraft); }); - // ---------- 环境数据层(委托给 env-store.js 全局 EnvStore) ---------- + // 环境数据层(EnvStore) const { loadEnvironments, saveEnvironments, @@ -405,7 +395,7 @@ document.addEventListener("DOMContentLoaded", () => { } = EnvStore; // isDomainMatch 由 utils.js 提供(全局函数) - // 草稿:弹窗关闭后保留未提交的表单内容 + const DRAFT_KEY = "formDraft"; function saveDraft() { @@ -441,13 +431,15 @@ document.addEventListener("DOMContentLoaded", () => { delayMsInput.value = draft.delayMs || ""; editingId = draft.editingId || null; addBtn.textContent = editingId ? t("updateConfigBtn") : t("addConfigBtn"); + addConfigTitleEl.textContent = editingId ? t("editConfigTitle") : t("addConfigTitle"); + cancelEditBtn.hidden = !editingId; } function clearDraft() { chrome.storage.local.remove(DRAFT_KEY); } - // ---------- 环境栏渲染与交互 ---------- + // 环境栏 let envModalResolve = null; // mode: "create" | "rename" function openEnvModal(mode, currentName = "") { @@ -534,7 +526,7 @@ document.addEventListener("DOMContentLoaded", () => { showToast(t("toastSwitchEnv")); }); - // ---------- 环境管理对话框 ---------- + // 环境管理对话框 function openEnvManageModal() { envManageWarn.textContent = ""; envManageWarn.style.display = "none"; @@ -640,16 +632,14 @@ document.addEventListener("DOMContentLoaded", () => { const configs = await loadConfigs(); configList.innerHTML = ""; - // 配置为 0:禁用导出,列表在前;非 0:启用导出,添加表单在前 + // 无配置时禁用导出,此时表单在上、空状态的列表在下;有配置时反过来 const isEmpty = configs.length === 0; exportBtn.disabled = isEmpty; exportBtn.classList.toggle("btn-disabled", isEmpty); const parent = addSection.parentElement; if (isEmpty) { - // 空状态:add-section 在上,list-section 在下(已是默认顺序,确保如此) if (parent.children[0] !== addSection) parent.insertBefore(addSection, listSection); } else { - // 非空:list-section 在上,add-section 在下 if (parent.children[0] !== listSection) parent.insertBefore(listSection, addSection); } @@ -695,7 +685,7 @@ document.addEventListener("DOMContentLoaded", () => { const actions = document.createElement("div"); actions.className = "config-actions"; - // 启用/禁用切换按钮(不删除配置) + // 启用/禁用切换 const toggleBtn = document.createElement("button"); toggleBtn.className = "btn-toggle"; const isDisabled = cfg.enabled === false; @@ -728,6 +718,8 @@ document.addEventListener("DOMContentLoaded", () => { enabledCheckbox.checked = cfg.enabled !== false; delayMsInput.value = cfg.delayMs || ""; addBtn.textContent = t("updateConfigBtn"); + addConfigTitleEl.textContent = t("editConfigTitle"); + cancelEditBtn.hidden = false; saveDraft(); domainInput.focus(); }); @@ -769,14 +761,15 @@ document.addEventListener("DOMContentLoaded", () => { enabledCheckbox.checked = true; delayMsInput.value = ""; addBtn.textContent = t("addConfigBtn"); + addConfigTitleEl.textContent = t("addConfigTitle"); + cancelEditBtn.hidden = true; clearDraft(); } - function escapeHtml(str) { - const div = document.createElement("div"); - div.textContent = str; - return div.innerHTML; - } + + cancelEditBtn.addEventListener("click", () => { + resetForm(); + }); addBtn.addEventListener("click", async () => { const domain = domainInput.value.trim(); @@ -830,7 +823,7 @@ document.addEventListener("DOMContentLoaded", () => { return; } - // 跨所有环境查找匹配的配置 + // 跨所有环境查找匹配当前标签页的配置 const allConfigs = await loadAllConfigs(); const matched = allConfigs.filter((c) => isDomainMatch(tab.url, c.domain)); @@ -861,7 +854,7 @@ document.addEventListener("DOMContentLoaded", () => { return; } - // 多个环境命中:发到 content.js 由它弹选择菜单 + try { await chrome.tabs.sendMessage(tab.id, { action: "manualFillMulti", @@ -898,7 +891,7 @@ document.addEventListener("DOMContentLoaded", () => { } }); - // 导出配置:导出整个 environments 结构(含环境名) + // 导出整个 environments 结构(含环境名) exportBtn.addEventListener("click", async () => { const { environments } = await loadEnvironments(); const totalConfigs = environments.reduce((s, e) => s + (e.configs?.length || 0), 0); @@ -1097,9 +1090,7 @@ document.addEventListener("DOMContentLoaded", () => { } }); - // ---------- 消息发送工具:带重试 + 上下文失效检测 ---------- - // MV3 service worker 可能刚休眠唤醒 / 刚加载,首次 sendMessage 会失败 - // "Could not establish connection. Receiving end does not exist." 属于此类 + // 消息发送:SW 刚休眠唤醒时首次 sendMessage 会失败,需重试 function sendMessage(message, retries = 3) { return new Promise((resolve, reject) => { let attempt = 0; @@ -1129,7 +1120,7 @@ document.addEventListener("DOMContentLoaded", () => { }); } - // ---------- 锁定 / 解锁 ---------- + // 锁定 / 解锁 let lockMode = "setup"; // "setup" | "unlock" function showLockScreen(mode) { @@ -1244,7 +1235,7 @@ document.addEventListener("DOMContentLoaded", () => { showToast(t("toastLocked")); }); - // 用户在弹窗内的操作视为活动,通知 background 重置自动锁定计时 + // 弹窗内的点击视为用户活动,重置 background 自动锁定计时 document.addEventListener("click", () => { if (lockScreen.style.display === "none") { sendMessage({ action: "ping" }, 1).catch(() => {}); diff --git a/utils.js b/utils.js index 56d2ad3..f22f38b 100644 --- a/utils.js +++ b/utils.js @@ -1,8 +1,4 @@ -/** - * 共享纯函数模块:URL 匹配 + HTML 转义 - * 同时在浏览器(content script / popup)和 Node 测试环境下可用。 - * 浏览器中挂载到全局作用域,Node 中通过 module.exports 暴露。 - */ +// URL 匹配 + HTML 转义,浏览器与 Node 通用 (function (root, factory) { if (typeof module === "object" && module.exports) { module.exports = factory(); @@ -15,18 +11,9 @@ "use strict"; /** - * 判断当前页面 URL 是否匹配配置的 pattern。 - * 兼容两种格式: - * 1. 纯 hostname:"example.com" / "*.example.com" —— 只匹配域名 - * 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} + * 判断 URL 是否匹配规则。 + * 纯 hostname:example.com / *.example.com + * hostname+path:example.com/login/*,路径部分支持 * 通配 */ function isDomainMatch(url, pattern) { if (!url || !pattern) return false; @@ -35,7 +22,6 @@ const hostname = u.hostname; const rawPattern = String(pattern).trim(); - // 带 / 的规则走 hostname + path 匹配 if (rawPattern.includes("/")) { const slashIdx = rawPattern.indexOf("/"); const hostPart = rawPattern.slice(0, slashIdx); @@ -44,7 +30,6 @@ return matchPath(u.pathname, pathPart); } - // 纯 hostname 匹配 return matchHost(hostname, rawPattern); } catch { return false; @@ -64,11 +49,9 @@ } function matchPath(pathname, pathPart) { - // pathname 始终以 / 开头,去掉前导 / 再比较 const pagePath = pathname.startsWith("/") ? pathname.slice(1) : pathname; if (!pathPart) return true; if (pathPart === "*") return true; - // 末尾 /* 表示前缀匹配 if (pathPart.endsWith("/*")) { const prefix = pathPart.slice(0, -2); return pagePath === prefix || pagePath.startsWith(prefix + "/"); @@ -80,11 +63,7 @@ return pagePath === pathPart; } - /** - * HTML 转义,防止配置中的特殊字符破坏页面结构。 - * @param {*} str - * @returns {string} - */ + /** HTML 转义,防止配置中的特殊字符破坏页面 */ function escapeHtml(str) { if (str == null) return ""; return String(str)