This commit is contained in:
yinjun.chen1
2026-09-14 13:45:17 +08:00
commit 1eef16a332
18 changed files with 3964 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
{"rootHash":"600a90143372585d3c7a3af167cbbd2bad795f0f1c461e63f7353e89894af27f","timestamp":1789350873097,"fileCount":3,"files":[{"path":"background.js","hash":"dcc2abf00969514e6fd92f5eb8472fa217c0d7703e0a14c8b90cc7f8c8be7171","size":97,"modified":1789350549320.4058},{"path":"content.js","hash":"7c7759128d4a3a3006154ca7e2091c9e5bdb5a61788b52820e837f5a7ffbc22a","size":7706,"modified":1789350542017.5798},{"path":"popup.js","hash":"3ff86d3b464ffb7a3ca88e98389d3f5934b484422da8a2f0446ef0c72a214dc8","size":6165,"modified":1789350515120.318}]}
+173
View File
@@ -0,0 +1,173 @@
// background.js —— Service Worker,负责:
// 1. Side Panel 打开行为
// 2. 口令锁状态管理(解锁后内存持有 AES 密钥)
// 3. session 数据变化时自动加密回写 local
// 4. 闲置自动锁定
// 5. 旧版明文库迁移到加密库
importScripts("crypto-store.js");
// 内存中的派生密钥,解锁后才有值,锁定/SW 重启后清空
let masterKey = null;
// 闲置自动锁定计时器
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] 扩展已安装");
});
chrome.action.onClicked.addListener((tab) => {
if (tab?.id !== undefined) {
chrome.sidePanel.open({ tabId: tab.id }).catch(() => {});
}
});
/**
* 旧版迁移:如果 local 中存在明文 environments 但没有 vaultMeta,
* 则提示用户需要先设置口令(无法在无口令下加密)。
* 这里把明文数据暂存,等用户设置口令时由 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) {
let pending = null;
if (Array.isArray(legacyEnvs) && legacyEnvs.length > 0) {
pending = { type: "environments", data: legacyEnvs };
} else if (Array.isArray(legacyConfigs) && legacyConfigs.length > 0) {
pending = {
type: "configs",
data: [{ id: "env_legacy", name: "默认", configs: legacyConfigs }],
};
}
if (pending) {
await chrome.storage.local.set({ pendingImport: pending });
// 清掉明文,避免继续以明文存在
await chrome.storage.local.remove(["environments", "loginConfigs"]);
console.log("[AutoLogin] 旧版明文数据已标记为待导入,等待设置口令后加密");
}
}
}
/** 重置闲置自动锁定计时器 */
function resetAutoLockTimer() {
if (autoLockTimer) clearTimeout(autoLockTimer);
if (masterKey) {
autoLockTimer = setTimeout(() => {
console.log("[AutoLogin] 闲置超时,自动锁定");
doLock();
}, AUTO_LOCK_MS);
}
}
async function doLock() {
masterKey = null;
if (autoLockTimer) { clearTimeout(autoLockTimer); autoLockTimer = null; }
await CryptoStore.lock();
}
/**
* 监听 session 中 environments 变化:用内存密钥重新加密写回 local。
* 这是"加密透明化"的核心——EnvStore 只管 session,加密由这里自动完成。
*/
chrome.storage.onChanged.addListener(async (changes, areaName) => {
if (areaName !== "session") return;
if (!changes.environments || !masterKey) return;
try {
const environments = changes.environments.newValue;
if (!environments) return; // 锁定时被清除,无需加密
await CryptoStore.persistEncrypted(environments, masterKey);
} catch (err) {
console.warn("[AutoLogin] 加密回写失败:", err);
}
});
// 消息处理:来自 popup / content script
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
(async () => {
try {
switch (message.action) {
case "getVaultStatus": {
const hasVault = await CryptoStore.hasVault();
const unlocked = hasVault ? !!masterKey : false;
const pending = (await chrome.storage.local.get("pendingImport")).pendingImport || null;
sendResponse({ hasVault, unlocked, pendingImport: !!pending });
break;
}
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),
name,
configs: [],
}));
}
masterKey = await CryptoStore.setupVault(message.password, initialEnvs);
await chrome.storage.local.remove("pendingImport");
resetAutoLockTimer();
sendResponse({ success: true });
break;
}
case "unlock": {
try {
const { key } = await CryptoStore.unlock(message.password);
masterKey = key;
resetAutoLockTimer();
sendResponse({ success: true });
} catch (err) {
sendResponse({ success: false, error: err.message });
}
break;
}
case "lock": {
await doLock();
sendResponse({ success: true });
break;
}
case "getActivity": {
// 供 popup 显示闲置倒计时
sendResponse({
unlocked: !!masterKey,
autoLockMs: AUTO_LOCK_MS,
});
break;
}
case "ping": {
// 任何消息都算用户活动,重置自动锁定计时
resetAutoLockTimer();
sendResponse({ pong: true });
break;
}
default:
sendResponse({ error: "unknown action" });
}
} catch (err) {
sendResponse({ success: false, error: err.message });
}
})();
return true; // 异步响应
});
+528
View File
@@ -0,0 +1,528 @@
// isDomainMatch 由 utils.js 提供(全局函数)
// 2FA / 验证码检测:检测到则不自动提交,仅填充
function detectCaptcha() {
// 1. reCAPTCHA
if (document.querySelector(".g-recaptcha, [data-sitekey], iframe[src*='recaptcha'], iframe[src*='google.com/recaptcha']")) {
return { type: "reCAPTCHA", reason: "检测到 reCAPTCHA" };
}
// 2. hCaptcha
if (document.querySelector(".h-captcha, iframe[src*='hcaptcha']")) {
return { type: "hCaptcha", reason: "检测到 hCaptcha" };
}
// 3. 国内常见滑块/验证码容器
const sliderSelectors = [
"#slider", ".slider", ".nc_wrapper", ".nc_iconfont",
"#captcha", ".captcha", ".geetest", "#geetest",
"[class*='slider']", "[id*='slider']", "[id*='captcha']", "[class*='captcha']",
"canvas[id*='captcha']", "canvas[id*='verify']"
];
for (const sel of sliderSelectors) {
try {
const el = document.querySelector(sel);
if (el && el.offsetParent !== null) {
return { type: "slider/captcha", reason: `检测到验证码元素 (${sel})` };
}
} 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]"
);
for (const input of codeInputs) {
if (input.offsetParent !== null && input.type !== "hidden") {
return { type: "captcha-input", reason: "检测到验证码输入框" };
}
}
return null;
}
function setNativeValue(el, value) {
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
"value"
)?.set || Object.getOwnPropertyDescriptor(
window.HTMLTextAreaElement.prototype,
"value"
)?.set;
if (nativeInputValueSetter) {
nativeInputValueSetter.call(el, value);
} else {
el.value = value;
}
el.dispatchEvent(new Event("input", { bubbles: true }));
el.dispatchEvent(new Event("change", { bubbles: true }));
}
function findUsernameField(config) {
if (config.usernameSelector) {
const el = document.querySelector(config.usernameSelector);
if (el) return el;
}
const passwordField = findPasswordField(config);
if (!passwordField) return null;
const form = passwordField.closest("form");
const searchScope = form || document;
const selectors = [
"input[type='text']",
"input[type='email']",
"input[type='tel']",
"input:not([type='password']):not([type='hidden']):not([type='submit']):not([type='button']):not([type='checkbox']):not([type='radio'])",
];
for (const sel of selectors) {
const inputs = searchScope.querySelectorAll(sel);
for (const input of inputs) {
if (input.offsetParent !== null || input.offsetWidth > 0) {
const name = (input.name || "").toLowerCase();
const id = (input.id || "").toLowerCase();
const placeholder = (input.placeholder || "").toLowerCase();
const autocomplete = (input.getAttribute("autocomplete") || "").toLowerCase();
if (
name.includes("user") || name.includes("name") || name.includes("account") ||
name.includes("email") || name.includes("login") || name.includes("mail") ||
id.includes("user") || id.includes("name") || id.includes("account") ||
id.includes("email") || id.includes("login") ||
placeholder.includes("user") || placeholder.includes("name") ||
placeholder.includes("email") || placeholder.includes("account") ||
placeholder.includes("登录") || placeholder.includes("账号") ||
placeholder.includes("用户") || placeholder.includes("邮箱") ||
autocomplete.includes("username") || autocomplete.includes("email")
) {
return input;
}
}
}
}
const allInputs = searchScope.querySelectorAll(
"input[type='text'], input[type='email'], input:not([type='password']):not([type='hidden']):not([type='submit']):not([type='button']):not([type='checkbox']):not([type='radio'])"
);
for (const input of allInputs) {
if (input.offsetParent !== null || input.offsetWidth > 0) {
return input;
}
}
return null;
}
function findPasswordField(config) {
if (config.passwordSelector) {
const el = document.querySelector(config.passwordSelector);
if (el) return el;
}
const passwordInputs = document.querySelectorAll("input[type='password']");
for (const input of passwordInputs) {
if (input.offsetParent !== null || input.offsetWidth > 0) {
return input;
}
}
return passwordInputs.length > 0 ? passwordInputs[0] : null;
}
function findSubmitButton(config, passwordField) {
if (config.submitSelector) {
const el = document.querySelector(config.submitSelector);
if (el) return el;
}
const form = passwordField ? passwordField.closest("form") : null;
const searchScope = form || document;
const submitSelectors = [
"button[type='submit']",
"input[type='submit']",
"button[type='button']",
"input[type='button']",
];
for (const sel of submitSelectors) {
const btns = searchScope.querySelectorAll(sel);
for (const btn of btns) {
if (btn.offsetParent !== null || btn.offsetWidth > 0) {
const text = (btn.textContent || btn.value || "").toLowerCase().trim();
if (
text.includes("登录") || text.includes("login") || text.includes("log in") ||
text.includes("sign in") || text.includes("提交") || text.includes("submit") ||
text.includes("确定") || text.includes("ok") || text.includes("go")
) {
return btn;
}
}
}
}
for (const sel of submitSelectors) {
const btn = searchScope.querySelector(sel);
if (btn && (btn.offsetParent !== null || btn.offsetWidth > 0)) {
return btn;
}
}
const allButtons = searchScope.querySelectorAll("button, input[type='button'], a[role='button']");
for (const btn of allButtons) {
if (btn.offsetParent !== null || btn.offsetWidth > 0) {
const text = (btn.textContent || btn.value || "").toLowerCase().trim();
if (
text.includes("登录") || text.includes("login") || text.includes("log in") ||
text.includes("sign in")
) {
return btn;
}
}
}
return null;
}
function fillForm(config, canSubmit = true) {
const passwordField = findPasswordField(config);
if (!passwordField) {
console.log("[AutoLogin] 未找到密码输入框");
return false;
}
const usernameField = findUsernameField(config);
if (usernameField) {
setNativeValue(usernameField, config.username);
console.log("[AutoLogin] 已填充用户名");
} else {
console.log("[AutoLogin] 未找到用户名输入框,跳过用户名填充");
}
setNativeValue(passwordField, config.password);
console.log("[AutoLogin] 已填充密码");
// 验证码检测:检测到则强制不自动提交,提示用户手动操作
const captcha = detectCaptcha();
let actualCanSubmit = canSubmit;
if (captcha && config.autoSubmit) {
actualCanSubmit = false;
console.warn(`[AutoLogin] ${captcha.reason},已跳过自动提交,请手动完成验证码后点击登录`);
// 在页面右上角显示提示浮窗
showCaptchaToast(captcha.reason);
}
if (config.autoSubmit && actualCanSubmit) {
const submitBtn = findSubmitButton(config, passwordField);
if (submitBtn) {
// 延迟提交:config.delayMs 给某些需要 JS 初始化的页面留时间
const delay = Math.max(300, Number(config.delayMs) || 300);
setTimeout(() => {
console.log(`[AutoLogin] 自动点击登录按钮(延迟 ${delay}ms)`);
submitBtn.click();
}, delay);
return true;
}
if (passwordField.form) {
const delay = Math.max(300, Number(config.delayMs) || 300);
console.log("[AutoLogin] 未找到登录按钮,尝试提交表单");
setTimeout(() => {
passwordField.form.requestSubmit
? passwordField.form.requestSubmit()
: passwordField.form.submit();
}, delay);
return true;
}
console.log("[AutoLogin] 未找到登录按钮,也无法提交表单");
} else if (config.autoSubmit && !actualCanSubmit && captcha) {
console.warn("[AutoLogin] 已检测到验证码,仅填充不自动提交,请手动完成验证码后点击登录");
} else if (config.autoSubmit && !canSubmit) {
console.warn("[AutoLogin] 自动登录失败次数已达上限,仅填充不自动提交,请手动确认后点击登录");
} else {
console.log("[AutoLogin] 自动提交已关闭,仅填充");
}
return true;
}
// 验证码提示浮窗:在页面右上角显示,几秒后自动消失
let captchaToastEl = null;
function showCaptchaToast(reason) {
if (captchaToastEl && captchaToastEl.parentNode) {
captchaToastEl.parentNode.removeChild(captchaToastEl);
}
const el = document.createElement("div");
el.style.cssText = `
position: fixed;
top: 16px;
right: 16px;
z-index: 2147483647;
background: #1a1a2e;
border: 1px solid #e94560;
border-radius: 8px;
padding: 10px 14px;
color: #e0e0e0;
font-size: 13px;
font-family: "Segoe UI","Microsoft YaHei",sans-serif;
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
max-width: 300px;
`;
el.innerHTML = `<div style="font-weight:600;color:#e94560;margin-bottom:4px;">需要手动操作</div>` +
`<div style="font-size:11px;color:#a0a0b0;">${reason},已为你填充账密,请完成验证码后点击登录</div>`;
document.body.appendChild(el);
captchaToastEl = el;
setTimeout(() => {
if (el && el.parentNode) el.parentNode.removeChild(el);
captchaToastEl = null;
}, 8000);
}
const MAX_LOGIN_ATTEMPTS = 3;
const ATTEMPT_COOLDOWN = 5 * 60 * 1000;
// 失败计数按 envId+domain 索引,支持同 domain 在不同环境下独立计数
function attemptKey(envId, domain) {
return `${envId}::${domain}`;
}
async function getDomainAttempts(envId, domain) {
const key = attemptKey(envId, domain);
const data = await chrome.storage.local.get("loginAttempts");
const attempts = data.loginAttempts || {};
return attempts[key] || { count: 0, lastAttempt: 0 };
}
async function incrementDomainAttempts(envId, domain) {
const key = attemptKey(envId, domain);
const data = await chrome.storage.local.get("loginAttempts");
const attempts = data.loginAttempts || {};
const cur = attempts[key] || { count: 0, lastAttempt: 0 };
cur.count += 1;
cur.lastAttempt = Date.now();
attempts[key] = cur;
await chrome.storage.local.set({ loginAttempts: attempts });
return cur;
}
async function resetDomainAttempts(envId, domain) {
const key = attemptKey(envId, domain);
const data = await chrome.storage.local.get("loginAttempts");
const attempts = data.loginAttempts || {};
if (attempts[key]) {
delete attempts[key];
await chrome.storage.local.set({ loginAttempts: attempts });
}
}
// 从 session 中扁平化读取所有配置(解锁后明文存于 session,未解锁时为空)
async function loadAllConfigs() {
const data = await chrome.storage.session.get("environments");
const environments = data.environments || [];
const all = [];
environments.forEach((env) => {
(env.configs || []).forEach((cfg) => {
all.push({ ...cfg, envId: env.id, envName: env.name });
});
});
return all;
}
async function autoFillOnLoad() {
const allConfigs = await loadAllConfigs();
if (allConfigs.length === 0) return;
// 跳过 enabled === false 的配置(独立开关)
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] 匹配到 ${matched.length} 个环境配置,弹出选择菜单`);
showEnvPicker(matched);
}
// SPA 动态表单:尝试填充,表单未出现时持续重试 30 秒
const SPA_FILL_TIMEOUT_MS = 30000;
const SPA_RETRY_INTERVAL_MS = 500;
async function fillSingleConfig(config) {
console.log(`[AutoLogin] 使用环境「${config.envName || "默认"}」的配置开始填充`);
// 检查自动登录尝试次数,超过上限则仅填充不自动提交
let canSubmit = config.autoSubmit;
if (config.autoSubmit) {
const attempts = await getDomainAttempts(config.envId, config.domain);
const now = Date.now();
if (now - attempts.lastAttempt > ATTEMPT_COOLDOWN) {
attempts.count = 0;
}
if (attempts.count >= MAX_LOGIN_ATTEMPTS) {
canSubmit = false;
console.warn(
`[AutoLogin] 环境「${config.envName}」域名 ${config.domain} 自动登录已失败 ${attempts.count} 次,` +
`达到上限 ${MAX_LOGIN_ATTEMPTS} 次,停止自动提交。` +
`请手动确认账号密码后点击登录,或 ${Math.ceil(ATTEMPT_COOLDOWN / 60000)} 分钟后重试。`
);
} else {
await incrementDomainAttempts(config.envId, config.domain);
}
}
// SPA 重试:表单未出现时持续重试,30 秒超时
const startTime = Date.now();
const tryFill = () => {
const elapsed = Date.now() - startTime;
if (elapsed > SPA_FILL_TIMEOUT_MS) {
console.warn(`[AutoLogin] SPA 动态表单等待超时(${SPA_FILL_TIMEOUT_MS / 1000}s),已停止尝试`);
return;
}
const filled = fillForm(config, canSubmit);
if (!filled) {
setTimeout(tryFill, SPA_RETRY_INTERVAL_MS);
}
};
tryFill();
}
// 多环境命中时,在页面右上角插入选择浮窗
let envPickerEl = null;
function showEnvPicker(configs) {
// 移除已有浮窗
if (envPickerEl && envPickerEl.parentNode) {
envPickerEl.parentNode.removeChild(envPickerEl);
envPickerEl = null;
}
const overlay = document.createElement("div");
overlay.id = "auto-login-env-picker";
overlay.style.cssText = `
position: fixed;
top: 16px;
right: 16px;
z-index: 2147483647;
background: #1a1a2e;
border: 1px solid #e94560;
border-radius: 10px;
padding: 14px 16px;
min-width: 240px;
max-width: 320px;
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
font-family: "Segoe UI","Microsoft YaHei",sans-serif;
color: #e0e0e0;
font-size: 13px;
`;
const title = document.createElement("div");
title.textContent = "选择要使用哪个环境";
title.style.cssText = "font-weight:600;color:#e94560;margin-bottom:10px;font-size:14px;";
overlay.appendChild(title);
const list = document.createElement("div");
list.style.cssText = "display:flex;flex-direction:column;gap:6px;";
configs.forEach((cfg) => {
const btn = document.createElement("button");
btn.style.cssText = `
text-align:left;
background:#16213e;
border:1px solid #0f3460;
border-radius:6px;
padding:8px 10px;
color:#e0e0e0;
cursor:pointer;
font-size:12px;
font-family:inherit;
transition:background 0.15s;
`;
const envLabel = escapeHtml(cfg.envName || "默认");
const domainLabel = escapeHtml(cfg.alias || cfg.domain);
const userLabel = escapeHtml(cfg.username);
btn.innerHTML = `
<div style="font-weight:600;color:#e94560;">${envLabel}</div>
<div style="color:#888;font-size:11px;margin-top:2px;">${domainLabel} · ${userLabel}</div>
`;
btn.addEventListener("mouseenter", () => {
btn.style.background = "#0f3460";
});
btn.addEventListener("mouseleave", () => {
btn.style.background = "#16213e";
});
btn.addEventListener("click", async () => {
overlay.parentNode && overlay.parentNode.removeChild(overlay);
envPickerEl = null;
await fillSingleConfig(cfg);
});
list.appendChild(btn);
});
overlay.appendChild(list);
const closeBtn = document.createElement("button");
closeBtn.textContent = "取消";
closeBtn.style.cssText = `
margin-top:10px;
width:100%;
padding:6px;
background:transparent;
border:1px solid #0f3460;
border-radius:6px;
color:#888;
cursor:pointer;
font-size:11px;
font-family:inherit;
`;
closeBtn.addEventListener("click", () => {
overlay.parentNode && overlay.parentNode.removeChild(overlay);
envPickerEl = null;
});
overlay.appendChild(closeBtn);
document.body.appendChild(overlay);
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 });
}
return true;
});
autoFillOnLoad();
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) {
const prevMatched = allConfigs.filter((c) => isDomainMatch(prevUrl, c.domain));
prevMatched.forEach((c) => resetDomainAttempts(c.envId, c.domain));
}
});
setTimeout(autoFillOnLoad, 1000);
}
});
observer.observe(document.body, { childList: true, subtree: true });
+204
View File
@@ -0,0 +1,204 @@
/**
* 加密存储模块:主口令 + 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 测试环境下可用。
*/
(function (root, factory) {
if (typeof module === "object" && module.exports) {
module.exports = factory({
getCrypto: () => globalThis.crypto,
getStorage: (area) => {
const c = typeof chrome !== "undefined" ? chrome : null;
return c?.storage?.[area] || null;
},
});
} else {
const api = factory({
getCrypto: () => crypto,
getStorage: (area) => chrome.storage[area],
});
root.CryptoStore = api;
}
})(typeof globalThis !== "undefined" ? globalThis : this, function ({ getCrypto, getStorage }) {
const VAULT_META_KEY = "vaultMeta";
const ENCRYPTED_VAULT_KEY = "encryptedVault";
const SESSION_ENV_KEY = "environments";
const SESSION_ACTIVE_KEY = "activeEnvId";
const PBKDF2_ITERATIONS = 200000;
const VAULT_VERSION = 1;
function b64encode(buf) {
const bytes = new Uint8Array(buf);
let s = "";
for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);
return btoa(s);
}
function b64decode(str) {
const s = atob(str);
const bytes = new Uint8Array(s.length);
for (let i = 0; i < s.length; i++) bytes[i] = s.charCodeAt(i);
return bytes;
}
function randomBytes(n) {
const arr = new Uint8Array(n);
getCrypto().getRandomValues(arr);
return arr;
}
/** 从口令派生 AES-GCM 密钥 */
async function deriveKey(password, salt, iterations) {
const enc = new TextEncoder();
const keyMaterial = await getCrypto().subtle.importKey(
"raw", enc.encode(password), "PBKDF2", false, ["deriveKey"]
);
return getCrypto().subtle.deriveKey(
{ name: "PBKDF2", salt, iterations, hash: "SHA-256" },
keyMaterial,
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"]
);
}
/** 加密任意 JSON 对象,返回 { iv, ciphertext } base64 */
async function encryptJson(obj, key) {
const enc = new TextEncoder();
const iv = randomBytes(12);
const ciphertext = await getCrypto().subtle.encrypt(
{ name: "AES-GCM", iv }, key, enc.encode(JSON.stringify(obj))
);
return { iv: b64encode(iv), ciphertext: b64encode(ciphertext) };
}
/** 解密为 JSON 对象,口令错误/数据损坏抛错 */
async function decryptJson({ iv, ciphertext }, key) {
const dec = new TextDecoder();
const plain = await getCrypto().subtle.decrypt(
{ name: "AES-GCM", iv: b64decode(iv) }, key, b64decode(ciphertext)
);
return JSON.parse(dec.decode(plain));
}
async function getLocal(key) {
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,视为已解锁。返回派生密钥(供调用方内存持有)。
*/
async function setupVault(password, environments) {
if (!password) throw new Error("口令不能为空");
const salt = randomBytes(16);
const key = await deriveKey(password, salt, PBKDF2_ITERATIONS);
const cipher = await encryptJson(environments || [], key);
const meta = {
version: VAULT_VERSION,
iterations: PBKDF2_ITERATIONS,
salt: b64encode(salt),
};
await getStorage("local").set({
[VAULT_META_KEY]: meta,
[ENCRYPTED_VAULT_KEY]: cipher,
});
const envs = environments || [];
await getStorage("session").set({
[SESSION_ENV_KEY]: envs,
[SESSION_ACTIVE_KEY]: envs[0]?.id || null,
});
return key;
}
/**
* 解锁:用口令解密 local 密文,写入 session。
* 返回 { environments, key },口令错误抛错。
*/
async function unlock(password) {
const meta = await getLocal(VAULT_META_KEY);
const cipher = await getLocal(ENCRYPTED_VAULT_KEY);
if (!meta || !cipher) throw new Error("尚未设置口令");
const key = await deriveKey(password, b64decode(meta.salt), meta.iterations);
let environments;
try {
environments = await decryptJson(cipher, 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 };
}
/** 锁定:清除 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 内存密钥调用) */
async function persistEncrypted(environments, key) {
const cipher = await encryptJson(environments, key);
await getStorage("local").set({ [ENCRYPTED_VAULT_KEY]: cipher });
}
return {
VAULT_META_KEY,
ENCRYPTED_VAULT_KEY,
SESSION_ENV_KEY,
SESSION_ACTIVE_KEY,
PBKDF2_ITERATIONS,
hasVault,
isUnlocked,
setupVault,
unlock,
lock,
deriveKey,
encryptJson,
decryptJson,
getVaultMeta,
getEncryptedVault,
persistEncrypted,
b64encode,
b64decode,
randomBytes,
};
});
+149
View File
@@ -0,0 +1,149 @@
/**
* 环境数据层:管理多环境配置。
*
* 数据存放在 chrome.storage.session(解锁后明文,内存中、不落盘)。
* 首次创建口令由 crypto-store.setupVault 完成(含预置环境)。
* 锁定时 session 被清空,本层返回空数据。
*
* 浏览器中通过全局 chrome.storage.session 存取;Node 测试中注入 mock。
*/
(function (root, factory) {
if (typeof module === "object" && module.exports) {
module.exports = factory({
getStorage: () => (typeof chrome !== "undefined" ? chrome.storage.session : null),
getLocalStorage: () => (typeof chrome !== "undefined" ? chrome.storage.local : null),
});
} else {
const api = factory({
getStorage: () => chrome.storage.session,
getLocalStorage: () => chrome.storage.local,
});
root.EnvStore = api;
}
})(typeof globalThis !== "undefined" ? globalThis : this, function ({ getStorage, getLocalStorage }) {
const ENV_KEY = "environments";
const ACTIVE_ENV_KEY = "activeEnvId";
function genId() {
return "env_" + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
}
function genCfgId() {
return Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
}
function storage() {
const s = getStorage();
if (!s) throw new Error("chrome.storage.session 不可用");
return s;
}
/**
* 加载所有环境。未解锁时 session 为空,返回空数组。
*/
async function loadEnvironments() {
const data = await storage().get([ENV_KEY, ACTIVE_ENV_KEY]);
const environments = data[ENV_KEY] || [];
const activeEnvId = data[ACTIVE_ENV_KEY] || environments[0]?.id || null;
return { environments, activeEnvId };
}
async function saveEnvironments(environments, activeEnvId) {
const patch = { [ENV_KEY]: environments };
if (activeEnvId !== undefined) patch[ACTIVE_ENV_KEY] = activeEnvId;
await storage().set(patch);
}
async function getActiveEnv() {
const { environments, activeEnvId } = await loadEnvironments();
return environments.find((e) => e.id === activeEnvId) || environments[0] || null;
}
async function setActiveEnv(envId) {
await storage().set({ [ACTIVE_ENV_KEY]: envId });
}
async function createEnvironment(name) {
const { environments } = await loadEnvironments();
const env = { id: genId(), name, configs: [] };
environments.push(env);
await saveEnvironments(environments, env.id);
return env;
}
async function renameEnvironment(envId, newName) {
const { environments } = await loadEnvironments();
const env = environments.find((e) => e.id === envId);
if (env) {
env.name = newName;
await saveEnvironments(environments);
}
return env;
}
async function deleteEnvironment(envId) {
const { environments, activeEnvId } = await loadEnvironments();
if (environments.length <= 1) {
throw new Error("至少保留一个环境");
}
const idx = environments.findIndex((e) => e.id === envId);
if (idx === -1) return;
environments.splice(idx, 1);
const newActive = activeEnvId === envId ? environments[0].id : activeEnvId;
await saveEnvironments(environments, newActive);
// 清理该环境的失败计数(失败计数非敏感,存于 local 持久化)
const local = getLocalStorage();
if (local) {
const attemptsData = await local.get("loginAttempts");
const attempts = attemptsData.loginAttempts || {};
Object.keys(attempts).forEach((k) => {
if (k.startsWith(envId + "::")) delete attempts[k];
});
await local.set({ loginAttempts: attempts });
}
}
/** 当前激活环境的配置列表 */
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);
if (!env) throw new Error("未找到当前环境");
env.configs = configs;
await saveEnvironments(environments);
}
/** 扁平化所有环境的配置,附带 envId / envName(用于全局匹配) */
async function loadAllConfigs() {
const { environments } = await loadEnvironments();
const all = [];
environments.forEach((env) => {
(env.configs || []).forEach((cfg) => {
all.push({ ...cfg, envId: env.id, envName: env.name });
});
});
return all;
}
return {
ENV_KEY,
ACTIVE_ENV_KEY,
loadEnvironments,
saveEnvironments,
getActiveEnv,
setActiveEnv,
createEnvironment,
renameEnvironment,
deleteEnvironment,
loadConfigs,
saveConfigs,
loadAllConfigs,
genId,
genCfgId,
};
});
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 631 B

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

+33
View File
@@ -0,0 +1,33 @@
{
"manifest_version": 3,
"name": "Auto Login Manager",
"version": "1.0.0",
"description": "针对特定域名自动填充用户名密码并完成登录,支持界面管理域名配置",
"permissions": ["storage", "activeTab", "scripting", "tabs", "sidePanel"],
"host_permissions": ["<all_urls>"],
"action": {
"default_icon": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
},
"side_panel": {
"default_path": "popup.html"
},
"icons": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
},
"background": {
"service_worker": "background.js"
},
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["utils.js", "content.js"],
"run_at": "document_idle"
}
]
}
+850
View File
@@ -0,0 +1,850 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
width: 100%;
min-width: 0;
margin: 0;
font-family: "Segoe UI", "Microsoft YaHei", sans-serif;
background: #1a1a2e;
color: #e0e0e0;
font-size: 13px;
}
.container {
padding: 16px;
display: flex;
flex-direction: column;
min-height: 100vh;
box-sizing: border-box;
}
header h1 {
font-size: 18px;
text-align: center;
color: #e94560;
margin-bottom: 12px;
padding-bottom: 10px;
border-bottom: 1px solid #16213e;
}
.env-bar {
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid #16213e;
}
.env-label {
display: block;
font-size: 11px;
color: #888;
margin-bottom: 4px;
}
.env-controls {
display: flex;
gap: 6px;
}
.env-select {
flex: 1;
padding: 6px 8px;
background: #16213e;
border: 1px solid #0f3460;
border-radius: 6px;
color: #e0e0e0;
font-size: 13px;
outline: none;
cursor: pointer;
transition: border-color 0.2s;
}
.env-select:focus {
border-color: #e94560;
}
.btn-env {
flex-shrink: 0;
width: 30px;
background: #0f3460;
border: none;
border-radius: 6px;
color: #e0e0e0;
cursor: pointer;
font-size: 14px;
transition: background 0.2s;
display: flex;
align-items: center;
justify-content: center;
}
.btn-env:hover {
background: #1a4a8a;
}
.btn-env-danger:hover {
background: #c0392b;
}
.preset-env-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 6px;
}
.preset-tag {
display: inline-block;
padding: 3px 10px;
background: #0f3460;
border: 1px solid #16213e;
border-radius: 12px;
color: #a0a0b0;
font-size: 11px;
cursor: pointer;
transition: all 0.2s;
}
.preset-tag:hover {
background: #1a4a8a;
color: #e0e0e0;
border-color: #e94560;
}
.header-title {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
}
.header-logo {
width: 36px;
height: 36px;
border-radius: 8px;
}
header .powered-by,
.footer .powered-by {
text-align: center;
color: #e94560;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.5px;
}
.footer {
margin-top: 16px;
padding-top: 10px;
border-top: 1px solid #16213e;
}
h2 {
font-size: 14px;
color: #e94560;
margin-bottom: 10px;
}
.add-section,
.current-section {
margin-bottom: 20px;
}
.list-section {
margin-bottom: 20px;
flex: 1 1 auto;
display: flex;
flex-direction: column;
min-height: 0;
}
.list-header {
flex-shrink: 0;
}
.form-group {
margin-bottom: 12px;
}
.form-row {
display: flex;
gap: 12px;
margin-bottom: 12px;
}
.form-group-inline {
flex: 1;
margin-bottom: 0;
display: flex;
flex-direction: column;
}
.form-group-inline label:not(.toggle-switch) {
margin-bottom: 4px;
}
/* 自定义 toggle 开关 */
.toggle-switch {
position: relative;
display: inline-block;
width: 36px;
height: 20px;
margin: 4px 0;
cursor: pointer;
}
.toggle-switch input {
opacity: 0;
width: 0;
height: 0;
}
.toggle-slider {
position: absolute;
inset: 0;
background: #0f3460;
border-radius: 20px;
transition: background 0.2s;
}
.toggle-slider::before {
content: "";
position: absolute;
width: 14px;
height: 14px;
left: 3px;
bottom: 3px;
background: #e0e0e0;
border-radius: 50%;
transition: transform 0.2s;
}
.toggle-switch input:checked + .toggle-slider {
background: #e94560;
}
.toggle-switch input:checked + .toggle-slider::before {
transform: translateX(16px);
}
/* 列表中切换启用状态的按钮 */
.btn-toggle {
background: #0f3460;
color: #e0e0e0;
border: none;
border-radius: 4px;
padding: 4px 8px;
font-size: 11px;
cursor: pointer;
transition: background 0.2s;
}
.btn-toggle:hover {
background: #1a4a8a;
}
/* 列表中标签 */
.tag-disabled {
display: inline-block;
padding: 1px 6px;
background: #3a2a2a;
color: #c0392b;
border-radius: 8px;
font-size: 10px;
margin-left: 4px;
vertical-align: middle;
}
.tag-delay {
display: inline-block;
padding: 1px 6px;
background: #2a2a3a;
color: #f1c40f;
border-radius: 8px;
font-size: 10px;
margin-left: 4px;
vertical-align: middle;
}
.config-disabled {
opacity: 0.55;
}
/* ---------- 锁定界面 ---------- */
.lock-screen {
position: fixed;
inset: 0;
background: #0f0f1e;
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
}
.lock-card {
width: 85%;
max-width: 320px;
padding: 28px 24px;
text-align: center;
box-shadow: 0 8px 32px rgba(233, 69, 96, 0.15);
}
.lock-logo {
width: 56px;
height: 56px;
border-radius: 12px;
margin-bottom: 14px;
}
.lock-card h2 {
color: #e94560;
font-size: 20px;
margin: 0 0 8px;
}
.lock-hint {
color: #888;
font-size: 12px;
margin: 0 0 20px;
line-height: 1.5;
}
.lock-card .form-group {
text-align: left;
margin-bottom: 14px;
}
.lock-card label {
display: block;
margin-bottom: 4px;
color: #a0a0b0;
font-size: 12px;
}
.lock-card input[type="password"] {
width: 100%;
padding: 10px 12px;
background: #16213e;
border: 1px solid #0f3460;
border-radius: 6px;
color: #e0e0e0;
font-size: 14px;
outline: none;
box-sizing: border-box;
}
.lock-card input[type="password"]:focus {
border-color: #e94560;
}
.lock-warn {
color: #e74c3c;
font-size: 12px;
min-height: 16px;
margin: 8px 0;
display: none;
}
.lock-submit {
width: 100%;
padding: 10px;
font-size: 14px;
margin-top: 4px;
}
.lock-footnote {
color: #555;
font-size: 10px;
margin: 12px 0 0;
}
/* 锁定按钮 */
.btn-lock {
background: #0f3460;
border: 1px solid #1a4a8a;
color: #e0e0e0;
padding: 4px 10px;
border-radius: 6px;
font-size: 11px;
cursor: pointer;
margin-left: auto;
transition: background 0.2s;
}
.btn-lock:hover {
background: #1a4a8a;
}
.header-title {
display: flex;
align-items: center;
gap: 8px;
justify-content: center;
}
.form-group label {
display: block;
margin-bottom: 4px;
color: #a0a0b0;
font-size: 12px;
}
.form-group input,
.form-group select {
width: 100%;
padding: 8px 10px;
background: #16213e;
border: 1px solid #0f3460;
border-radius: 6px;
color: #e0e0e0;
font-size: 13px;
outline: none;
transition: border-color 0.2s;
}
.form-group input:focus,
.form-group select:focus {
border-color: #e94560;
}
.form-group input::placeholder {
color: #555;
}
.form-group small {
display: block;
margin-top: 3px;
color: #666;
font-size: 11px;
}
.selector-help {
background: #16213e;
border: 1px solid #0f3460;
border-radius: 6px;
padding: 8px 10px;
margin-bottom: 12px;
}
.selector-help summary {
cursor: pointer;
color: #a0a0b0;
font-size: 12px;
user-select: none;
list-style: none;
}
.selector-help summary::-webkit-details-marker {
display: none;
}
.selector-help summary::before {
content: "▶ ";
color: #e94560;
font-size: 10px;
transition: transform 0.2s;
display: inline-block;
}
.selector-help[open] summary::before {
content: "▼ ";
}
.selector-help-body {
margin-top: 10px;
padding-top: 8px;
border-top: 1px dashed #0f3460;
}
.selector-help-body p {
font-size: 11px;
line-height: 1.6;
color: #a0a0b0;
margin-bottom: 6px;
}
.selector-help-body .help-tip {
color: #e94560;
font-weight: 500;
}
.selector-help-body code {
background: #0f1d3a;
padding: 1px 4px;
border-radius: 3px;
color: #f1c40f;
font-size: 11px;
font-family: Consolas, Monaco, monospace;
}
.selector-help-body kbd {
background: #0f1d3a;
border: 1px solid #0f3460;
border-radius: 3px;
padding: 1px 5px;
color: #e0e0e0;
font-size: 11px;
font-family: Consolas, Monaco, monospace;
}
.selector-help-body .icon-arrow {
display: inline-block;
padding: 1px 5px;
background: #0f3460;
border-radius: 3px;
color: #e94560;
font-size: 11px;
}
.selector-help-body .rule-tag {
display: inline-block;
padding: 1px 5px;
margin: 0 2px;
background: #0f1d3a;
border: 1px solid #0f3460;
border-radius: 3px;
color: #f1c40f;
font-size: 11px;
font-family: Consolas, Monaco, monospace;
}
.password-wrapper {
display: flex;
gap: 6px;
}
.password-wrapper input {
flex: 1;
}
.domain-wrapper {
display: flex;
gap: 6px;
}
.domain-wrapper input {
flex: 1;
}
.btn-fill-domain {
padding: 8px 12px;
background: #0f3460;
border: none;
border-radius: 6px;
color: #e0e0e0;
cursor: pointer;
font-size: 12px;
white-space: nowrap;
transition: background 0.2s;
}
.btn-fill-domain:hover {
background: #1a4a8a;
}
.toggle-pwd {
padding: 8px 12px;
background: #0f3460;
border: none;
border-radius: 6px;
color: #e0e0e0;
cursor: pointer;
font-size: 12px;
white-space: nowrap;
transition: background 0.2s;
}
.toggle-pwd:hover {
background: #1a4a8a;
}
.btn-primary,
.btn-secondary,
.btn-danger,
.btn-edit {
padding: 8px 14px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
font-weight: 600;
transition: opacity 0.2s;
}
.btn-primary {
width: 100%;
background: #e94560;
color: #fff;
}
.btn-primary:hover {
opacity: 0.85;
}
.btn-secondary {
width: 100%;
background: #0f3460;
color: #e0e0e0;
}
.btn-secondary:hover {
opacity: 0.85;
}
.btn-danger {
background: #c0392b;
color: #fff;
padding: 4px 10px;
font-size: 11px;
}
.btn-edit {
background: #0f3460;
color: #e0e0e0;
padding: 4px 10px;
font-size: 11px;
}
.config-list {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
max-height: none;
}
.list-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.list-header h2 {
margin-bottom: 0;
}
.list-actions {
display: flex;
gap: 6px;
}
.btn-io {
padding: 4px 10px;
background: #0f3460;
border: none;
border-radius: 6px;
color: #e0e0e0;
cursor: pointer;
font-size: 11px;
transition: background 0.2s;
}
.btn-io:hover {
background: #1a4a8a;
}
.config-item {
background: #16213e;
border: 1px solid #0f3460;
border-radius: 8px;
padding: 10px 12px;
margin-bottom: 8px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
}
.config-info {
flex: 1;
min-width: 0;
}
.config-domain {
font-weight: 600;
color: #e94560;
font-size: 13px;
word-break: break-all;
}
.config-domain-sub {
color: #888;
font-size: 11px;
word-break: break-all;
margin-top: 2px;
}
.config-detail {
color: #888;
font-size: 11px;
margin-top: 3px;
}
.config-actions {
display: flex;
gap: 6px;
flex-shrink: 0;
}
.empty-msg {
text-align: center;
color: #555;
padding: 20px;
font-size: 13px;
}
.toast {
position: fixed;
bottom: 10px;
left: 50%;
transform: translateX(-50%) translateY(80px);
background: #e94560;
color: #fff;
padding: 8px 20px;
border-radius: 20px;
font-size: 13px;
opacity: 0;
transition: all 0.3s;
z-index: 9999;
pointer-events: none;
}
.toast.show {
transform: translateX(-50%) translateY(0);
opacity: 1;
}
.config-list::-webkit-scrollbar {
width: 5px;
}
.config-list::-webkit-scrollbar-track {
background: #1a1a2e;
}
.config-list::-webkit-scrollbar-thumb {
background: #0f3460;
border-radius: 3px;
}
.modal {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
display: none;
align-items: center;
justify-content: center;
z-index: 10000;
padding: 16px;
box-sizing: border-box;
}
.modal.show {
display: flex;
}
.modal-content {
background: #16213e;
border: 1px solid #0f3460;
border-radius: 10px;
padding: 18px;
width: 100%;
max-width: 360px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
}
.modal-content h3 {
font-size: 15px;
color: #e94560;
margin-bottom: 12px;
text-align: center;
}
.modal-toggle {
margin-bottom: 12px;
}
.toggle-label {
display: flex;
align-items: center;
gap: 6px;
color: #e0e0e0;
font-size: 13px;
cursor: pointer;
margin-bottom: 4px;
}
.toggle-label input[type="checkbox"] {
width: auto;
margin: 0;
cursor: pointer;
accent-color: #e94560;
}
.modal-fields {
display: block;
}
.modal-fields.hidden {
display: none;
}
.modal-warn {
color: #c0392b;
font-size: 11px;
margin: 4px 0 8px;
text-align: center;
}
.modal-actions {
display: flex;
gap: 8px;
margin-top: 8px;
}
.modal-actions button {
flex: 1;
}
.strength-block {
margin-top: 10px;
}
.strength-bar-bg {
width: 100%;
height: 6px;
background: #0f3460;
border-radius: 3px;
overflow: hidden;
}
.strength-bar-fg {
height: 100%;
width: 0;
border-radius: 3px;
transition: width 0.25s ease, background 0.25s ease;
background: #555;
}
.strength-bar-fg.very-weak { background: #c0392b; }
.strength-bar-fg.weak { background: #e67e22; }
.strength-bar-fg.medium { background: #f1c40f; }
.strength-bar-fg.strong { background: #27ae60; }
.strength-bar-fg.very-strong { background: #2ecc71; }
.strength-info {
display: flex;
flex-direction: column;
gap: 2px;
margin-top: 6px;
font-size: 11px;
color: #a0a0b0;
}
.strength-info #crackTime {
color: #888;
}
+217
View File
@@ -0,0 +1,217 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="popup.css">
<title>Auto Login Manager</title>
</head>
<body>
<!-- 锁定/设置口令覆盖层 -->
<div id="lockScreen" class="lock-screen">
<div class="lock-card">
<img src="icons/icon128.png" alt="logo" class="lock-logo">
<h2 id="lockTitle">设置口令</h2>
<p id="lockHint" class="lock-hint">口令用于加密存储所有配置,忘记将无法恢复</p>
<div class="form-group">
<input type="password" id="lockPassword" placeholder="请输入口令" autocomplete="off">
</div>
<div class="form-group" id="lockConfirmGroup">
<label for="lockPasswordConfirm">确认口令</label>
<input type="password" id="lockPasswordConfirm" placeholder="再次输入口令" autocomplete="off">
</div>
<div class="lock-warn" id="lockWarn"></div>
<button type="button" id="lockSubmitBtn" class="btn-primary lock-submit">设置口令</button>
<p class="lock-footnote">口令仅用于本地加密,不会上传到任何服务器</p>
</div>
</div>
<div class="container" id="mainContainer" style="display:none;">
<header>
<div class="header-title">
<img src="icons/icon128.png" alt="logo" class="header-logo">
<h1>Auto Login Manager</h1>
<button type="button" id="lockBtn" class="btn-lock" title="锁定(清除内存中的明文)">🔒 锁定</button>
</div>
<div class="env-bar">
<label class="env-label">当前环境</label>
<div class="env-controls">
<select id="envSelect" class="env-select"></select>
<button type="button" id="addEnvBtn" class="btn-env" title="新建环境">+</button>
<button type="button" id="renameEnvBtn" class="btn-env" title="重命名当前环境">✎</button>
<button type="button" id="deleteEnvBtn" class="btn-env btn-env-danger" title="删除当前环境">🗑</button>
</div>
</div>
</header>
<section class="add-section">
<h2>添加域名配置</h2>
<div class="form-group">
<label for="domain">域名 / URL 匹配规则</label>
<div class="domain-wrapper">
<input type="text" id="domain" placeholder="example.com 或 *.example.com 或 site.com/admin/*">
<button type="button" id="fillDomain" class="btn-fill-domain" title="填入当前标签页域名">填入当前域名</button>
</div>
<small>支持通配符 *。带 / 时按 hostname+path 匹配,如 oa.com/login/*;不带 / 时只匹配域名</small>
</div>
<div class="form-group">
<label for="alias">别名(可选)</label>
<input type="text" id="alias" placeholder="例如: 公司OA、GitLab、邮箱">
<small>方便识别该配置,留空则显示域名</small>
</div>
<div class="form-group">
<label for="username">用户名</label>
<input type="text" id="username" placeholder="输入用户名">
</div>
<div class="form-group">
<label for="password">密码</label>
<div class="password-wrapper">
<input type="password" id="password" placeholder="输入密码">
<button class="toggle-pwd" type="button" data-target="password">显示</button>
</div>
</div>
<div class="form-row">
<div class="form-group form-group-inline">
<label for="autoSubmit">自动提交登录</label>
<select id="autoSubmit">
<option value="true">是 - 自动点击登录按钮</option>
<option value="false">否 - 仅填充不提交</option>
</select>
</div>
<div class="form-group form-group-inline">
<label for="enabled">启用</label>
<label class="toggle-switch">
<input type="checkbox" id="enabled" checked>
<span class="toggle-slider"></span>
</label>
<small>关闭后该配置不再参与匹配(保留数据)</small>
</div>
</div>
<div class="form-group">
<label for="delayMs">填充延迟(毫秒,可选)</label>
<input type="number" id="delayMs" min="0" max="10000" step="100" placeholder="留空=默认 300ms,可填 500-2000 用于 SPA 页面">
<small>某些页面(Ant Design、ASP.NET)需 JS 初始化,立即填充会失效</small>
</div>
<details class="selector-help">
<summary>高级选项:自定义输入框和登录按钮位置(选填)</summary>
<div class="selector-help-body">
<p class="help-tip">下面三项通常<strong>留空即可</strong>,插件会自动找到登录框。只有当自动填充失败、或填错位置时才需要填。</p>
<p class="help-step">1. 在登录页面按 <kbd>F12</kbd> 打开开发者工具,切到"Elements(元素)"标签</p>
<p class="help-step">2. 点开发者工具左上角的 <span class="icon-arrow">箭头图标</span>,再去点页面上的输入框/按钮</p>
<p class="help-step">3. 工具会跳到对应的 HTML 行,在 <code>&lt;input&gt;</code> 或 <code>&lt;button&gt;</code> 标签上看 <code>id</code>、<code>name</code>、<code>class</code>、<code>type</code> 属性</p>
<p class="help-step">4. 按 <span class="rule-tag">#id</span>、<span class="rule-tag">.class</span>、<span class="rule-tag">[name=xxx]</span>、<span class="rule-tag">tag[属性=值]</span> 规则组合填写即可</p>
</div>
</details>
<div class="form-group">
<label>用户名输入框位置</label>
<input type="text" id="usernameSelector" placeholder="留空=自动查找,如 #username 或 input[name=account]">
<small>留空即可。填错会导致用户名填不进框。</small>
</div>
<div class="form-group">
<label>密码输入框位置</label>
<input type="text" id="passwordSelector" placeholder="留空=自动查找,如 #password 或 input[type=password]">
<small>留空即可。填错会导致密码填不进框。</small>
</div>
<div class="form-group">
<label>登录按钮位置</label>
<input type="text" id="submitSelector" placeholder="留空=自动查找,如 #loginBtn 或 button[type=submit]">
<small>留空即可。填错会导致不会自动点击登录。</small>
</div>
<button id="addBtn" class="btn-primary">添加配置</button>
</section>
<section class="list-section">
<div class="list-header">
<h2>已配置域名</h2>
<div class="list-actions">
<button type="button" id="exportBtn" class="btn-io">导出</button>
<button type="button" id="importBtn" class="btn-io">导入</button>
<input type="file" id="importFile" accept="application/json,.json" hidden>
</div>
</div>
<div id="configList" class="config-list">
</div>
</section>
<section class="current-section">
<button id="fillCurrent" class="btn-secondary">在当前页面填充</button>
</section>
<footer class="footer">
<p class="powered-by">power by chenxuan</p>
</footer>
</div>
<div id="toast" class="toast"></div>
<div id="envModal" class="modal">
<div class="modal-content">
<h3 id="envModalTitle">新建环境</h3>
<div class="form-group">
<label for="envNameInput">环境名称</label>
<input type="text" id="envNameInput" placeholder="例如: 个人 / 测试 / 生产 / 客户A">
</div>
<div class="preset-env-tags" id="presetEnvTags">
<span class="preset-tag" data-name="个人">个人</span>
<span class="preset-tag" data-name="测试">测试</span>
<span class="preset-tag" data-name="生产">生产</span>
<span class="preset-tag" data-name="开发">开发</span>
<span class="preset-tag" data-name="预发">预发</span>
</div>
<div class="modal-warn" id="envModalWarn" style="display:none;"></div>
<div class="modal-actions">
<button type="button" id="envModalCancel" class="btn-edit">取消</button>
<button type="button" id="envModalConfirm" class="btn-primary">确认</button>
</div>
</div>
</div>
<div id="pwdModal" class="modal">
<div class="modal-content">
<h3 id="modalTitle">加密导出</h3>
<div id="encryptToggleGroup" class="form-group modal-toggle">
<label class="toggle-label">
<input type="checkbox" id="encryptToggle" checked>
<span>加密导出文件</span>
</label>
<small>勾选后用口令加密;不勾选则导出明文 JSON</small>
</div>
<div id="pwdFields" class="modal-fields">
<div class="form-group">
<label for="modalPwd">口令</label>
<div class="password-wrapper">
<input type="password" id="modalPwd" placeholder="输入口令">
<button class="toggle-pwd" type="button" data-target="modalPwd">显示</button>
</div>
</div>
<div class="form-group" id="confirmGroup">
<label for="modalPwdConfirm">确认口令</label>
<div class="password-wrapper">
<input type="password" id="modalPwdConfirm" placeholder="再次输入口令">
<button class="toggle-pwd" type="button" data-target="modalPwdConfirm">显示</button>
</div>
</div>
<div class="modal-warn" id="modalWarn">口令将用于 AES-GCM 加密,无法找回,请妥善保存</div>
<div class="strength-block" id="strengthBlock">
<div class="strength-bar-bg">
<div class="strength-bar-fg" id="strengthBar"></div>
</div>
<div class="strength-info">
<span id="strengthLabel">尚未输入</span>
<span id="crackTime"></span>
</div>
</div>
</div>
<div class="modal-actions">
<button type="button" id="modalCancel" class="btn-edit">取消</button>
<button type="button" id="modalConfirm" class="btn-primary">确认</button>
</div>
</div>
</div>
<script src="utils.js"></script>
<script src="env-store.js"></script>
<script src="crypto-store.js"></script>
<script src="popup.js"></script>
</body>
</html>
+1083
View File
File diff suppressed because it is too large Load Diff
+154
View File
@@ -0,0 +1,154 @@
// tests/crypto-store.test.js
// 测试 crypto-store.js:主口令派生、AES-GCM 加解密、设置/解锁/锁定
// 运行:node --test tests/crypto-store.test.js
const { describe, it, beforeEach } = require("node:test");
const assert = require("node:assert/strict");
function createMemoryStorage() {
const local = {};
const session = {};
const makeArea = (store) => ({
async get(keys) {
const result = {};
if (Array.isArray(keys)) keys.forEach((k) => { if (k in store) result[k] = store[k]; });
else if (typeof keys === "string") { if (keys in store) result[keys] = store[keys]; }
else Object.assign(result, store);
return result;
},
async set(items) { Object.assign(store, items); },
async remove(keys) {
if (Array.isArray(keys)) keys.forEach((k) => delete store[k]);
else delete store[keys];
},
});
return { local: makeArea(local), session: makeArea(session), localStore: local, sessionStore: session };
}
function loadCryptoStore(storage) {
global.chrome = { storage };
delete require.cache[require.resolve("../crypto-store.js")];
return require("../crypto-store.js");
}
describe("crypto-store - 基础加解密", () => {
it("encryptJson + decryptJson 往返一致", async () => {
const storage = createMemoryStorage();
const C = loadCryptoStore(storage);
const salt = C.randomBytes(16);
const key = await C.deriveKey("mypassword", salt, C.PBKDF2_ITERATIONS);
const data = [{ id: "e1", name: "个人", configs: [{ domain: "a.com" }] }];
const cipher = await C.encryptJson(data, key);
const decrypted = await C.decryptJson(cipher, key);
assert.deepEqual(decrypted, data);
});
it("不同口令派生的密钥无法解密对方数据", async () => {
const storage = createMemoryStorage();
const C = loadCryptoStore(storage);
const salt = C.randomBytes(16);
const key1 = await C.deriveKey("password1", salt, C.PBKDF2_ITERATIONS);
const key2 = await C.deriveKey("password2", salt, C.PBKDF2_ITERATIONS);
const cipher = await C.encryptJson({ secret: "hello" }, key1);
await assert.rejects(() => C.decryptJson(cipher, key2));
});
it("b64encode / b64decode 往返一致", () => {
const C = loadCryptoStore(createMemoryStorage());
const buf = new Uint8Array([0, 1, 2, 253, 254, 255]);
const encoded = C.b64encode(buf);
const decoded = C.b64decode(encoded);
assert.deepEqual(Array.from(decoded), Array.from(buf));
});
});
describe("crypto-store - 设置/解锁/锁定", () => {
let C, storage;
beforeEach(() => {
storage = createMemoryStorage();
C = loadCryptoStore(storage);
});
it("hasVault 初始为 false", async () => {
assert.equal(await C.hasVault(), false);
assert.equal(await C.isUnlocked(), false);
});
it("setupVault 加密并解锁", async () => {
const envs = [{ id: "e1", name: "个人", configs: [] }];
const key = await C.setupVault("mypass123", envs);
assert.ok(key);
assert.equal(await C.hasVault(), true);
assert.equal(await C.isUnlocked(), true);
// local 中是密文,不是明文
assert.ok(storage.localStore.vaultMeta);
assert.ok(storage.localStore.encryptedVault);
assert.equal(storage.localStore.environments, undefined);
// session 中是明文
assert.ok(storage.sessionStore.environments);
});
it("setupVault 口令不能为空", async () => {
await assert.rejects(() => C.setupVault("", []), /口令不能为空/);
});
it("unlock 正确口令可解密", async () => {
await C.setupVault("correct horse", [{ id: "e1", name: "x", configs: [] }]);
// 先锁定
await C.lock();
assert.equal(await C.isUnlocked(), false);
const { environments, key } = await C.unlock("correct horse");
assert.ok(key);
assert.equal(environments.length, 1);
assert.equal(environments[0].name, "x");
assert.equal(await C.isUnlocked(), true);
});
it("unlock 错误口令抛错", async () => {
await C.setupVault("correct", []);
await C.lock();
await assert.rejects(() => C.unlock("wrong"), /口令错误/);
// 错误口令后仍未解锁
assert.equal(await C.isUnlocked(), false);
});
it("lock 清除 session 明文但保留 local 密文", async () => {
await C.setupVault("pw", [{ id: "e1", name: "x", configs: [] }]);
assert.ok(storage.sessionStore.environments);
await C.lock();
assert.equal(storage.sessionStore.environments, undefined);
// 密文还在
assert.ok(storage.localStore.encryptedVault);
});
it("unlock 未设置口令时抛错", async () => {
await assert.rejects(() => C.unlock("anything"), /尚未设置口令/);
});
it("lock 后可再次 unlock", async () => {
await C.setupVault("pw", [{ id: "e1", name: "x", configs: [] }]);
await C.lock();
await C.unlock("pw");
assert.equal(await C.isUnlocked(), true);
const envs = (await C.unlock("pw")).environments;
assert.equal(envs[0].name, "x");
});
});
describe("crypto-store - persistEncrypted", () => {
it("用密钥重新加密 environments 写回 local", async () => {
const storage = createMemoryStorage();
const C = loadCryptoStore(storage);
const key = await C.setupVault("pw", [{ id: "e1", name: "x", configs: [] }]);
// 修改环境
const newEnvs = [{ id: "e1", name: "x", configs: [{ domain: "a.com" }] }];
await C.persistEncrypted(newEnvs, key);
// 锁定后重新解锁,验证持久化生效
await C.lock();
const { environments } = await C.unlock("pw");
assert.equal(environments[0].configs.length, 1);
assert.equal(environments[0].configs[0].domain, "a.com");
});
});
+190
View File
@@ -0,0 +1,190 @@
// tests/detect-captcha.test.js
// 测试 content.js 中的 detectCaptcha:各类验证码场景识别
// 通过 vm 在沙箱中加载 content.js,注入 mock document
// 运行:node --test tests/detect-captcha.test.js
const { describe, it, beforeEach } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const vm = require("node:vm");
// 创建一个最小化的 mock document,支持 querySelector / querySelectorAll
// elements 是一个选择器 -> 元素对象的映射;querySelector 返回第一个匹配
function createMockDoc(elements) {
return {
querySelector(selector) {
// 简化:把逗号分隔的选择器拆开,返回第一个命中的
const parts = selector.split(",").map((s) => s.trim());
for (const sel of parts) {
if (elements[sel]) return elements[sel];
}
return null;
},
querySelectorAll(selector) {
const parts = selector.split(",").map((s) => s.trim());
const result = [];
for (const sel of parts) {
if (elements[sel]) result.push(elements[sel]);
}
return result;
},
body: {},
};
}
// 加载 content.js 到 vm 沙箱,返回沙箱上下文
function loadContentJs(mockDoc) {
const code = fs.readFileSync(path.join(__dirname, "..", "content.js"), "utf8");
const sandbox = {
URL: globalThis.URL,
window: { location: { href: "https://example.com/login" } },
document: mockDoc,
chrome: {
runtime: { onMessage: { addListener() {} } },
storage: {
local: { get: async () => ({}), set: async () => {}, remove: async () => {} },
session: { get: async () => ({}), set: async () => {}, remove: async () => {} },
},
},
MutationObserver: class {
observe() {}
},
setTimeout,
clearTimeout,
console,
};
// 让 utils.js 提供的 isDomainMatch / escapeHtml 在沙箱中可用
sandbox.globalThis = sandbox;
vm.createContext(sandbox);
// 先加载 utils.js
const utilsCode = fs.readFileSync(path.join(__dirname, "..", "utils.js"), "utf8");
vm.runInContext(utilsCode, sandbox);
vm.runInContext(code, sandbox);
return sandbox;
}
describe("detectCaptcha", () => {
let sandbox;
beforeEach(() => {
// 默认 mock:无任何验证码元素
const doc = createMockDoc({});
sandbox = loadContentJs(doc);
});
it("无验证码元素时返回 null", () => {
assert.equal(sandbox.detectCaptcha(), null);
});
it("检测到 reCAPTCHA(.g-recaptcha)", () => {
const doc = createMockDoc({
".g-recaptcha": { offsetParent: {} },
});
const sb = loadContentJs(doc);
const r = sb.detectCaptcha();
assert.ok(r);
assert.equal(r.type, "reCAPTCHA");
assert.match(r.reason, /reCAPTCHA/);
});
it("检测到 reCAPTCHA iframe", () => {
const doc = createMockDoc({
"iframe[src*='recaptcha']": { offsetParent: {} },
});
const sb = loadContentJs(doc);
const r = sb.detectCaptcha();
assert.equal(r.type, "reCAPTCHA");
});
it("检测到 hCaptcha", () => {
const doc = createMockDoc({
".h-captcha": { offsetParent: {} },
});
const sb = loadContentJs(doc);
const r = sb.detectCaptcha();
assert.equal(r.type, "hCaptcha");
});
it("检测到滑块验证码(#slider)", () => {
const doc = createMockDoc({
"#slider": { offsetParent: {} },
});
const sb = loadContentJs(doc);
const r = sb.detectCaptcha();
assert.equal(r.type, "slider/captcha");
});
it("检测到图形验证码容器(.captcha)", () => {
const doc = createMockDoc({
".captcha": { offsetParent: {} },
});
const sb = loadContentJs(doc);
const r = sb.detectCaptcha();
assert.equal(r.type, "slider/captcha");
});
it("检测到 geetest 极验验证码", () => {
const doc = createMockDoc({
".geetest": { offsetParent: {} },
});
const sb = loadContentJs(doc);
const r = sb.detectCaptcha();
assert.equal(r.type, "slider/captcha");
});
it("检测到验证码输入框(name=captcha)", () => {
const doc = createMockDoc({
"input[name*='captcha' i]": { offsetParent: {}, type: "text" },
});
const sb = loadContentJs(doc);
const r = sb.detectCaptcha();
assert.equal(r.type, "captcha-input");
});
it("检测到 placeholder 含「验证码」的输入框", () => {
const doc = createMockDoc({
"input[placeholder*='验证码' i]": { offsetParent: {}, type: "text" },
});
const sb = loadContentJs(doc);
const r = sb.detectCaptcha();
assert.equal(r.type, "captcha-input");
});
it("hidden 类型的验证码输入框不被识别(忽略隐藏域)", () => {
const doc = createMockDoc({
"input[name*='captcha' i]": { offsetParent: {}, type: "hidden" },
});
const sb = loadContentJs(doc);
const r = sb.detectCaptcha();
assert.equal(r, null);
});
it("offsetParent 为 null 的滑块元素不可见,不被识别", () => {
// 滑块/输入框分支会检查 offsetParent !== null,不可见元素不触发
const doc = createMockDoc({
"#slider": { offsetParent: null },
});
const sb = loadContentJs(doc);
const r = sb.detectCaptcha();
assert.equal(r, null);
});
it("reCAPTCHA 优先级高于滑块/输入框", () => {
const doc = createMockDoc({
".g-recaptcha": { offsetParent: {} },
"#slider": { offsetParent: {} },
"input[name*='captcha' i]": { offsetParent: {}, type: "text" },
});
const sb = loadContentJs(doc);
const r = sb.detectCaptcha();
assert.equal(r.type, "reCAPTCHA");
});
it("返回对象包含 reason 字段说明原因", () => {
const doc = createMockDoc({ "#captcha": { offsetParent: {} } });
const sb = loadContentJs(doc);
const r = sb.detectCaptcha();
assert.ok(r && typeof r.reason === "string" && r.reason.length > 0);
});
});
+170
View File
@@ -0,0 +1,170 @@
// tests/env-store.test.js
// 测试 env-store.js:环境 CRUD(数据存于 chrome.storage.session)
// 运行:node --test tests/env-store.test.js
const { describe, it, beforeEach } = require("node:test");
const assert = require("node:assert/strict");
// 内存版 storage,同时模拟 local 和 session
function createMemoryStorage() {
const local = {};
const session = {};
const makeArea = (store) => ({
async get(keys) {
const result = {};
if (Array.isArray(keys)) {
keys.forEach((k) => { if (k in store) result[k] = store[k]; });
} else if (typeof keys === "string") {
if (keys in store) result[keys] = store[keys];
} else {
Object.assign(result, store);
}
return result;
},
async set(items) { Object.assign(store, items); },
async remove(keys) {
if (Array.isArray(keys)) keys.forEach((k) => delete store[k]);
else delete store[keys];
},
_dump() { return store; },
});
return { local: makeArea(local), session: makeArea(session), localStore: local, sessionStore: session };
}
function loadEnvStore(storage) {
global.chrome = { storage };
delete require.cache[require.resolve("../env-store.js")];
return require("../env-store.js");
}
describe("env-store - 读写 session", () => {
it("未解锁(session 空)时返回空环境", async () => {
const storage = createMemoryStorage();
const EnvStore = loadEnvStore(storage);
const { environments, activeEnvId } = await EnvStore.loadEnvironments();
assert.deepEqual(environments, []);
assert.equal(activeEnvId, null);
});
it("saveEnvironments / loadEnvironments 读写 session", async () => {
const storage = createMemoryStorage();
const EnvStore = loadEnvStore(storage);
const envs = [{ id: "e1", name: "个人", configs: [] }];
await EnvStore.saveEnvironments(envs, "e1");
const loaded = await EnvStore.loadEnvironments();
assert.equal(loaded.environments.length, 1);
assert.equal(loaded.environments[0].name, "个人");
assert.equal(loaded.activeEnvId, "e1");
// 数据落在 session,不在 local
assert.ok(storage.sessionStore.environments);
assert.equal(storage.localStore.environments, undefined);
});
});
describe("env-store - 环境 CRUD", () => {
let EnvStore, storage;
beforeEach(async () => {
storage = createMemoryStorage();
EnvStore = loadEnvStore(storage);
await EnvStore.saveEnvironments(
[
{ id: "e1", name: "个人", configs: [] },
{ id: "e2", name: "测试", configs: [] },
{ id: "e3", name: "生产", configs: [] },
],
"e1"
);
});
it("createEnvironment 新增并激活", async () => {
const env = await EnvStore.createEnvironment("客户A");
assert.equal(env.name, "客户A");
const { environments, activeEnvId } = await EnvStore.loadEnvironments();
assert.equal(environments.length, 4);
assert.equal(activeEnvId, env.id);
});
it("renameEnvironment", async () => {
await EnvStore.renameEnvironment("e1", "个人-改");
const { environments } = await EnvStore.loadEnvironments();
assert.equal(environments[0].name, "个人-改");
});
it("deleteEnvironment 非当前环境,active 不变", async () => {
await EnvStore.deleteEnvironment("e2");
const { environments, activeEnvId } = await EnvStore.loadEnvironments();
assert.equal(environments.length, 2);
assert.equal(activeEnvId, "e1");
});
it("deleteEnvironment 当前环境,自动切到第一个", async () => {
await EnvStore.deleteEnvironment("e1");
const { environments, activeEnvId } = await EnvStore.loadEnvironments();
assert.equal(environments.length, 2);
assert.equal(activeEnvId, environments[0].id);
});
it("deleteEnvironment 只剩一个时抛错", async () => {
await EnvStore.deleteEnvironment("e1");
await EnvStore.deleteEnvironment("e2");
const { environments } = await EnvStore.loadEnvironments();
assert.equal(environments.length, 1);
await assert.rejects(() => EnvStore.deleteEnvironment(environments[0].id), /至少保留一个环境/);
});
it("setActiveEnv + getActiveEnv", async () => {
await EnvStore.setActiveEnv("e3");
const env = await EnvStore.getActiveEnv();
assert.equal(env.id, "e3");
});
});
describe("env-store - 配置 CRUD", () => {
let EnvStore, storage;
beforeEach(async () => {
storage = createMemoryStorage();
EnvStore = loadEnvStore(storage);
await EnvStore.saveEnvironments(
[
{ id: "e1", name: "个人", configs: [] },
{ id: "e2", name: "测试", configs: [] },
],
"e1"
);
});
it("saveConfigs / loadConfigs 读写当前环境", async () => {
const cfg = { id: "c1", domain: "a.com", username: "u", password: "p" };
await EnvStore.saveConfigs([cfg]);
const configs = await EnvStore.loadConfigs();
assert.equal(configs.length, 1);
assert.equal(configs[0].domain, "a.com");
});
it("loadAllConfigs 扁平化并附带 envId/envName", async () => {
await EnvStore.saveConfigs([{ id: "c1", domain: "a.com", username: "u1", password: "p1" }]);
await EnvStore.setActiveEnv("e2");
await EnvStore.saveConfigs([{ id: "c2", domain: "b.com", username: "u2", password: "p2" }]);
const all = await EnvStore.loadAllConfigs();
assert.equal(all.length, 2);
assert.ok(all.every((c) => c.envId && c.envName));
assert.deepEqual(all.map((c) => c.domain).sort(), ["a.com", "b.com"]);
});
it("deleteEnvironment 清理 local 中该环境的失败计数", async () => {
// 失败计数存于 local
await storage.local.set({
loginAttempts: {
"e2::a.com": { count: 3, lastAttempt: Date.now() },
"other::b.com": { count: 1, lastAttempt: Date.now() },
},
});
await EnvStore.deleteEnvironment("e2");
const attempts = (await storage.local.get("loginAttempts")).loginAttempts;
assert.equal(attempts["e2::a.com"], undefined);
assert.ok(attempts["other::b.com"]);
});
});
+113
View File
@@ -0,0 +1,113 @@
// tests/utils.test.js
// 测试 utils.js 中的纯函数:isDomainMatch(URL 路径匹配)和 escapeHtml(HTML 转义)
// 运行:node --test tests/utils.test.js
const { describe, it } = require("node:test");
const assert = require("node:assert/strict");
const { isDomainMatch, escapeHtml } = require("../utils.js");
describe("isDomainMatch - 纯 hostname 匹配", () => {
it("精确域名匹配", () => {
assert.equal(isDomainMatch("https://example.com/login", "example.com"), true);
assert.equal(isDomainMatch("https://example.com/", "example.com"), true);
assert.equal(isDomainMatch("https://other.com/", "example.com"), false);
});
it("子域名通配符 *.example.com", () => {
assert.equal(isDomainMatch("https://oa.example.com/", "*.example.com"), true);
assert.equal(isDomainMatch("https://a.b.example.com/", "*.example.com"), true);
assert.equal(isDomainMatch("https://example.com/", "*.example.com"), true); // 根域也匹配
assert.equal(isDomainMatch("https://notexample.com/", "*.example.com"), false);
});
it("任意位置通配符", () => {
assert.equal(isDomainMatch("https://api.dev.example.com/", "api.*.example.com"), true);
assert.equal(isDomainMatch("https://api.prod.example.com/", "api.*.example.com"), true);
assert.equal(isDomainMatch("https://web.dev.example.com/", "api.*.example.com"), false);
});
it("端口和协议不影响匹配", () => {
assert.equal(isDomainMatch("http://example.com:8080/path", "example.com"), true);
assert.equal(isDomainMatch("https://example.com:443/", "example.com"), true);
});
it("非法 URL 返回 false", () => {
assert.equal(isDomainMatch("not-a-url", "example.com"), false);
assert.equal(isDomainMatch("", "example.com"), false);
assert.equal(isDomainMatch("https://example.com/", ""), false);
});
});
describe("isDomainMatch - hostname + path 匹配", () => {
it("精确路径匹配", () => {
assert.equal(isDomainMatch("https://oa.com/login", "oa.com/login"), true);
assert.equal(isDomainMatch("https://oa.com/admin/login", "oa.com/login"), false);
assert.equal(isDomainMatch("https://oa.com/", "oa.com/login"), false);
});
it("路径前缀通配 login/*", () => {
assert.equal(isDomainMatch("https://oa.com/login", "oa.com/login/*"), true);
assert.equal(isDomainMatch("https://oa.com/login/step2", "oa.com/login/*"), true);
assert.equal(isDomainMatch("https://oa.com/login/sub/deep", "oa.com/login/*"), true);
assert.equal(isDomainMatch("https://oa.com/logins", "oa.com/login/*"), false);
assert.equal(isDomainMatch("https://oa.com/admin", "oa.com/login/*"), false);
});
it("路径全通配 *", () => {
assert.equal(isDomainMatch("https://oa.com/anything", "oa.com/*"), true);
assert.equal(isDomainMatch("https://oa.com/", "oa.com/*"), true);
});
it("仅 hostname + 空路径匹配该域名所有页面", () => {
assert.equal(isDomainMatch("https://oa.com/any/path", "oa.com/"), true);
assert.equal(isDomainMatch("https://oa.com/", "oa.com/"), true);
});
it("路径内任意位置通配", () => {
assert.equal(isDomainMatch("https://site.com/a/123/b", "site.com/a/*/b"), true);
assert.equal(isDomainMatch("https://site.com/a/xyz/b", "site.com/a/*/b"), true);
assert.equal(isDomainMatch("https://site.com/a/123/c", "site.com/a/*/b"), false);
});
it("path 匹配与子域名通配组合", () => {
assert.equal(isDomainMatch("https://api.dev.com/login", "*.dev.com/login"), true);
assert.equal(isDomainMatch("https://web.dev.com/admin", "*.dev.com/login"), false);
assert.equal(isDomainMatch("https://api.dev.com/login/step2", "*.dev.com/login/*"), true);
});
it("带 query 和 hash 的 URL 不影响 path 匹配", () => {
assert.equal(isDomainMatch("https://oa.com/login?next=/home", "oa.com/login"), true);
assert.equal(isDomainMatch("https://oa.com/login#section", "oa.com/login"), true);
});
});
describe("escapeHtml", () => {
it("转义 5 个特殊字符", () => {
assert.equal(escapeHtml("<"), "&lt;");
assert.equal(escapeHtml(">"), "&gt;");
assert.equal(escapeHtml('"'), "&quot;");
assert.equal(escapeHtml("'"), "&#39;");
assert.equal(escapeHtml("&"), "&amp;");
});
it("组合转义", () => {
assert.equal(
escapeHtml('<a href="x" onclick="alert(\'hi\')">text & more</a>'),
"&lt;a href=&quot;x&quot; onclick=&quot;alert(&#39;hi&#39;)&quot;&gt;text &amp; more&lt;/a&gt;"
);
});
it("null 和 undefined 返回空串", () => {
assert.equal(escapeHtml(null), "");
assert.equal(escapeHtml(undefined), "");
});
it("数字会被转成字符串", () => {
assert.equal(escapeHtml(123), "123");
});
it("普通文本不变", () => {
assert.equal(escapeHtml("hello world"), "hello world");
assert.equal(escapeHtml("中文测试123"), "中文测试123");
});
});
+99
View File
@@ -0,0 +1,99 @@
/**
* 共享纯函数模块:URL 匹配 + HTML 转义
* 同时在浏览器(content script / popup)和 Node 测试环境下可用。
* 浏览器中挂载到全局作用域,Node 中通过 module.exports 暴露。
*/
(function (root, factory) {
if (typeof module === "object" && module.exports) {
module.exports = factory();
} else {
const api = factory();
root.isDomainMatch = api.isDomainMatch;
root.escapeHtml = api.escapeHtml;
}
})(typeof globalThis !== "undefined" ? globalThis : this, function () {
"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}
*/
function isDomainMatch(url, pattern) {
if (!url || !pattern) return false;
try {
const u = new URL(url);
const hostname = u.hostname;
const rawPattern = String(pattern).trim();
// 带 / 的规则走 hostname + path 匹配
if (rawPattern.includes("/")) {
const slashIdx = rawPattern.indexOf("/");
const hostPart = rawPattern.slice(0, slashIdx);
const pathPart = rawPattern.slice(slashIdx + 1) || "";
if (!matchHost(hostname, hostPart)) return false;
return matchPath(u.pathname, pathPart);
}
// 纯 hostname 匹配
return matchHost(hostname, rawPattern);
} catch {
return false;
}
}
function matchHost(hostname, hostPart) {
if (hostPart.startsWith("*.")) {
const suffix = hostPart.slice(2);
return hostname === suffix || hostname.endsWith("." + suffix);
}
if (hostPart.includes("*")) {
const regexStr = hostPart.replace(/\./g, "\\.").replace(/\*/g, ".*");
return new RegExp("^" + regexStr + "$").test(hostname);
}
return hostname === hostPart;
}
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 + "/");
}
if (pathPart.includes("*")) {
const regexStr = pathPart.replace(/\//g, "\\/").replace(/\*/g, ".*");
return new RegExp("^" + regexStr + "$").test(pagePath);
}
return pagePath === pathPart;
}
/**
* HTML 转义,防止配置中的特殊字符破坏页面结构。
* @param {*} str
* @returns {string}
*/
function escapeHtml(str) {
if (str == null) return "";
return String(str)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
return { isDomainMatch, escapeHtml };
});