init
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
};
|
||||
});
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 5.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 631 B |
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 5.3 KiB |
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
@@ -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><input></code> 或 <code><button></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>
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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("<"), "<");
|
||||
assert.equal(escapeHtml(">"), ">");
|
||||
assert.equal(escapeHtml('"'), """);
|
||||
assert.equal(escapeHtml("'"), "'");
|
||||
assert.equal(escapeHtml("&"), "&");
|
||||
});
|
||||
|
||||
it("组合转义", () => {
|
||||
assert.equal(
|
||||
escapeHtml('<a href="x" onclick="alert(\'hi\')">text & more</a>'),
|
||||
"<a href="x" onclick="alert('hi')">text & more</a>"
|
||||
);
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
return { isDomainMatch, escapeHtml };
|
||||
});
|
||||
Reference in New Issue
Block a user