引入 TypeScript strict 模式,拆分 popup 模块,编译输出 dist/
- popup.js 拆分为 i18n/crypto-file/strength/messaging/ui-utils 五个 ES 模块 - 所有源码迁移至 src/ 目录,tsconfig strict 模式零错误编译 - globals.d.ts 定义 EnvStore/CryptoStore 完整接口与全局类型 - manifest.json 与 popup.html 引用 dist/ 编译产物 - 删除无效的 sidePanel.setOptions panelWidth 调用
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
.codeartsdoer/
|
||||
.arts
|
||||
Vendored
+244
@@ -0,0 +1,244 @@
|
||||
"use strict";
|
||||
importScripts("crypto-store.js");
|
||||
let masterKey = null; // 解锁后持有,锁定或 SW 重启后清空
|
||||
let autoLockTimer = null;
|
||||
const AUTO_LOCK_MS = 10 * 60 * 1000; // 10 分钟无操作自动锁定
|
||||
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(() => { });
|
||||
}
|
||||
});
|
||||
// 旧版明文数据迁移:把 loginConfigs 转为 environments 明文结构
|
||||
async function migrateLegacyVault() {
|
||||
const data = await chrome.storage.local.get(["environments", "loginConfigs", "vaultMeta"]);
|
||||
if (data.vaultMeta)
|
||||
return;
|
||||
if (Array.isArray(data.environments) && data.environments.length > 0)
|
||||
return;
|
||||
if (Array.isArray(data.loginConfigs) && data.loginConfigs.length > 0) {
|
||||
const envs = [{ id: "env_legacy", name: "默认", configs: data.loginConfigs }];
|
||||
await chrome.storage.local.set({ environments: envs });
|
||||
await chrome.storage.local.remove("loginConfigs");
|
||||
console.log("[AutoLogin] 旧版 loginConfigs 已迁移为明文 environments");
|
||||
}
|
||||
}
|
||||
// 无 vault 明文模式:确保 session 有 environments(从 local 加载或创建预设)
|
||||
async function ensurePlainEnvironments() {
|
||||
if (await CryptoStore.hasVault())
|
||||
return; // 加密模式不处理
|
||||
const data = await chrome.storage.local.get("environments");
|
||||
let envs = data.environments;
|
||||
if (!Array.isArray(envs) || envs.length === 0) {
|
||||
const PRESET = ["个人", "测试", "生产"];
|
||||
envs = PRESET.map((name) => ({
|
||||
id: "env_" + Date.now().toString(36) + Math.random().toString(36).slice(2, 8),
|
||||
name,
|
||||
configs: [],
|
||||
}));
|
||||
await chrome.storage.local.set({ environments: envs });
|
||||
}
|
||||
const session = await chrome.storage.session.get("environments");
|
||||
if (!session.environments) {
|
||||
await chrome.storage.session.set({
|
||||
environments: envs,
|
||||
activeEnvId: envs[0]?.id || null,
|
||||
});
|
||||
}
|
||||
}
|
||||
// SW 启动即初始化明文环境到 session,供 content script 自动登录读取
|
||||
ensurePlainEnvironments().catch((err) => console.warn("[AutoLogin] 启动初始化失败:", err));
|
||||
autoRestoreUnlock().catch((err) => console.warn("[AutoLogin] 自动恢复解锁失败:", err));
|
||||
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();
|
||||
await chrome.storage.session.remove("__autoPwd");
|
||||
}
|
||||
// SW 重启后自动恢复解锁:从 session 取已存密码解锁 vault
|
||||
async function autoRestoreUnlock() {
|
||||
try {
|
||||
if (!(await CryptoStore.hasVault()))
|
||||
return;
|
||||
if (masterKey)
|
||||
return;
|
||||
const { __autoPwd } = await chrome.storage.session.get("__autoPwd");
|
||||
if (!__autoPwd)
|
||||
return;
|
||||
const { key } = await CryptoStore.unlock(__autoPwd);
|
||||
masterKey = key;
|
||||
resetAutoLockTimer();
|
||||
console.log("[AutoLogin] 自动恢复解锁成功");
|
||||
}
|
||||
catch (err) {
|
||||
await chrome.storage.session.remove("__autoPwd").catch(() => { });
|
||||
}
|
||||
}
|
||||
// session 中 environments 变化时用内存密钥重新加密写回 local
|
||||
chrome.storage.onChanged.addListener(async (changes, areaName) => {
|
||||
if (areaName !== "session")
|
||||
return;
|
||||
if (!changes.environments)
|
||||
return;
|
||||
const environments = changes.environments.newValue;
|
||||
if (!environments)
|
||||
return; // 锁定时被清除,无需持久化
|
||||
try {
|
||||
if (masterKey) {
|
||||
await CryptoStore.persistEncrypted(environments, masterKey);
|
||||
}
|
||||
else {
|
||||
// 无 vault 明文模式:明文回写 local
|
||||
await chrome.storage.local.set({ environments });
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
console.warn("[AutoLogin] 持久化失败:", err);
|
||||
}
|
||||
});
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
(async () => {
|
||||
try {
|
||||
switch (message.action) {
|
||||
case "getVaultStatus": {
|
||||
const hasVault = await CryptoStore.hasVault();
|
||||
if (!hasVault) {
|
||||
await ensurePlainEnvironments();
|
||||
const local = await chrome.storage.local.get("environments");
|
||||
const sess = await chrome.storage.session.get("environments");
|
||||
const cfgCount = (arr) => (arr || []).reduce((s, e) => s + (e.configs?.length || 0), 0);
|
||||
sendResponse({
|
||||
hasVault: false, unlocked: true, pendingImport: false,
|
||||
localEnvs: (local.environments || []).length, localCfgs: cfgCount(local.environments),
|
||||
sessEnvs: (sess.environments || []).length, sessCfgs: cfgCount(sess.environments),
|
||||
});
|
||||
break;
|
||||
}
|
||||
if (!masterKey)
|
||||
await autoRestoreUnlock();
|
||||
sendResponse({ hasVault, unlocked: !!masterKey, pendingImport: false });
|
||||
break;
|
||||
}
|
||||
case "loadAllConfigs": {
|
||||
if (!(await CryptoStore.hasVault()))
|
||||
await ensurePlainEnvironments();
|
||||
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 }));
|
||||
});
|
||||
sendResponse({ configs: all });
|
||||
break;
|
||||
}
|
||||
case "getDomainAttempts": {
|
||||
const key = `${message.envId}::${message.domain}`;
|
||||
const data = await chrome.storage.local.get("loginAttempts");
|
||||
const attempts = data.loginAttempts || {};
|
||||
sendResponse({ attempts: attempts[key] || { count: 0, lastAttempt: 0 } });
|
||||
break;
|
||||
}
|
||||
case "incrementDomainAttempts": {
|
||||
const key = `${message.envId}::${message.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 });
|
||||
sendResponse({ attempts: cur });
|
||||
break;
|
||||
}
|
||||
case "resetDomainAttempts": {
|
||||
const key = `${message.envId}::${message.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 });
|
||||
}
|
||||
sendResponse({ success: true });
|
||||
break;
|
||||
}
|
||||
case "setupVault": {
|
||||
// 设置口令:若已有明文 environments 则加密它,否则创建预设
|
||||
const localData = await chrome.storage.local.get("environments");
|
||||
let initialEnvs;
|
||||
if (Array.isArray(localData.environments) && localData.environments.length > 0) {
|
||||
initialEnvs = localData.environments;
|
||||
}
|
||||
else {
|
||||
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.session.set({ __autoPwd: message.password });
|
||||
await chrome.storage.local.remove("environments"); // 已加密,明文不再需要
|
||||
resetAutoLockTimer();
|
||||
sendResponse({ success: true });
|
||||
break;
|
||||
}
|
||||
case "unlock": {
|
||||
try {
|
||||
const { key } = await CryptoStore.unlock(message.password);
|
||||
masterKey = key;
|
||||
await chrome.storage.session.set({ __autoPwd: message.password });
|
||||
resetAutoLockTimer();
|
||||
sendResponse({ success: true });
|
||||
}
|
||||
catch (err) {
|
||||
sendResponse({ success: false, error: err.message });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "lock": {
|
||||
await doLock();
|
||||
sendResponse({ success: true });
|
||||
break;
|
||||
}
|
||||
case "getActivity": {
|
||||
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; // 异步响应
|
||||
});
|
||||
Vendored
+548
@@ -0,0 +1,548 @@
|
||||
"use strict";
|
||||
const t = (key, ...args) => chrome.i18n.getMessage(key, args) || key;
|
||||
const _sendMessage = chrome.runtime.sendMessage;
|
||||
// 2FA / 验证码检测:检测到则不自动提交,仅填充
|
||||
function detectCaptcha() {
|
||||
// reCAPTCHA
|
||||
if (document.querySelector(".g-recaptcha, [data-sitekey], iframe[src*='recaptcha'], iframe[src*='google.com/recaptcha']")) {
|
||||
return { type: "reCAPTCHA", reason: t("captchaTitleRe") };
|
||||
}
|
||||
// hCaptcha
|
||||
if (document.querySelector(".h-captcha, iframe[src*='hcaptcha']")) {
|
||||
return { type: "hCaptcha", reason: t("captchaTitleH") };
|
||||
}
|
||||
// 国内常见滑块/验证码容器
|
||||
const sliderSelectors = [
|
||||
"#slider", ".slider", ".nc_wrapper", ".nc_iconfont",
|
||||
"#captcha", ".captcha", ".geetest", "#geetest",
|
||||
"[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: t("captchaReasonSlider", sel) };
|
||||
}
|
||||
}
|
||||
catch { /* selector 可能无效 */ }
|
||||
}
|
||||
// 明显的"验证码"文字输入框(带图形码)
|
||||
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 Array.from(codeInputs)) {
|
||||
if (input.offsetParent !== null && input.type !== "hidden") {
|
||||
return { type: "captcha-input", reason: t("captchaReasonInput") };
|
||||
}
|
||||
}
|
||||
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 deepQuerySelectorAll(selector, root) {
|
||||
const result = [];
|
||||
const stack = [root || document];
|
||||
while (stack.length) {
|
||||
const r = stack.pop();
|
||||
result.push(...r.querySelectorAll(selector));
|
||||
for (const el of r.querySelectorAll("*")) {
|
||||
if (el.shadowRoot)
|
||||
stack.push(el.shadowRoot);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
function deepQuerySelector(selector, root) {
|
||||
return deepQuerySelectorAll(selector, root)[0] || null;
|
||||
}
|
||||
function resolveElement(value, multiple) {
|
||||
if (!value)
|
||||
return null;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed.startsWith("<")) {
|
||||
return multiple ? deepQuerySelectorAll(trimmed) : deepQuerySelector(trimmed);
|
||||
}
|
||||
const doc = new DOMParser().parseFromString(trimmed, "text/html");
|
||||
const el = doc.body.firstElementChild;
|
||||
if (!el)
|
||||
return null;
|
||||
const tag = el.tagName.toLowerCase();
|
||||
const id = el.id;
|
||||
const cls = Array.from(el.classList);
|
||||
const type = el.getAttribute("type");
|
||||
const nameAttr = el.getAttribute("name");
|
||||
const candidates = [];
|
||||
if (id)
|
||||
candidates.push(`${tag}#${CSS.escape(id)}`);
|
||||
if (cls.length)
|
||||
candidates.push(`${tag}.${cls.map(CSS.escape).join(".")}`);
|
||||
if (type)
|
||||
candidates.push(`${tag}[type="${type}"]`);
|
||||
if (nameAttr)
|
||||
candidates.push(`${tag}[name="${CSS.escape(nameAttr)}"]`);
|
||||
for (const c of cls)
|
||||
candidates.push(`${tag}.${CSS.escape(c)}`);
|
||||
candidates.push(tag);
|
||||
for (const sel of candidates) {
|
||||
if (multiple) {
|
||||
const els = deepQuerySelectorAll(sel);
|
||||
if (els.length)
|
||||
return els;
|
||||
}
|
||||
else {
|
||||
const found = deepQuerySelector(sel);
|
||||
if (found)
|
||||
return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function findUsernameField(config) {
|
||||
if (config.usernameSelector) {
|
||||
const el = resolveElement(config.usernameSelector);
|
||||
if (el)
|
||||
return el;
|
||||
}
|
||||
const passwordField = findPasswordField(config);
|
||||
if (!passwordField)
|
||||
return null;
|
||||
const searchScope = passwordField.closest("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 = deepQuerySelectorAll(sel, searchScope);
|
||||
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 = deepQuerySelectorAll("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'])", searchScope);
|
||||
for (const input of allInputs) {
|
||||
if (input.offsetParent !== null || input.offsetWidth > 0) {
|
||||
return input;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function findPasswordField(config) {
|
||||
if (config.passwordSelector) {
|
||||
const el = resolveElement(config.passwordSelector);
|
||||
if (el)
|
||||
return el;
|
||||
}
|
||||
const passwordInputs = deepQuerySelectorAll("input[type='password']");
|
||||
for (const input of passwordInputs) {
|
||||
if (input.offsetParent !== null || input.offsetWidth > 0) {
|
||||
return input;
|
||||
}
|
||||
}
|
||||
if (passwordInputs.length > 0)
|
||||
return passwordInputs[0];
|
||||
const allInputs = deepQuerySelectorAll("input");
|
||||
for (const input of allInputs) {
|
||||
if (input.type === "password")
|
||||
return input;
|
||||
const sig = ((input.name || "") + " " + (input.id || "") + " " +
|
||||
(input.placeholder || "") + " " + (input.getAttribute("autocomplete") || "")).toLowerCase();
|
||||
if (sig.includes("password") || sig.includes("passwd") || sig.includes("pwd")) {
|
||||
if (input.offsetParent !== null || input.offsetWidth > 0)
|
||||
return input;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function findSubmitButton(config, passwordField) {
|
||||
if (config.submitSelector) {
|
||||
const els = resolveElement(config.submitSelector, true);
|
||||
if (els && els.length === 1)
|
||||
return els[0];
|
||||
if (els && els.length > 1) {
|
||||
for (const el of els) {
|
||||
if (el.offsetParent === null && el.offsetWidth === 0)
|
||||
continue;
|
||||
const compact = (el.textContent || el.value || "").toLowerCase().replace(/\s/g, "");
|
||||
if (compact.includes("登录") || compact.includes("login") || compact.includes("signin")) {
|
||||
return el;
|
||||
}
|
||||
}
|
||||
for (const el of els) {
|
||||
if (el.offsetParent !== null || el.offsetWidth > 0)
|
||||
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 = deepQuerySelectorAll(sel, searchScope);
|
||||
for (const btn of btns) {
|
||||
if (btn.offsetParent !== null || btn.offsetWidth > 0) {
|
||||
const text = (btn.textContent || btn.value || "").toLowerCase().trim();
|
||||
const compact = text.replace(/\s/g, "");
|
||||
if (compact.includes("登录") || compact.includes("login") || compact.includes("login") ||
|
||||
compact.includes("signin") || compact.includes("提交") || compact.includes("submit") ||
|
||||
compact.includes("确定") || compact.includes("ok") || compact.includes("go")) {
|
||||
return btn;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const sel of submitSelectors) {
|
||||
const btn = deepQuerySelector(sel, searchScope);
|
||||
if (btn && (btn.offsetParent !== null || btn.offsetWidth > 0)) {
|
||||
return btn;
|
||||
}
|
||||
}
|
||||
const allButtons = deepQuerySelectorAll("button, input[type='button'], a[role='button']", searchScope);
|
||||
for (const btn of allButtons) {
|
||||
if (btn.offsetParent !== null || btn.offsetWidth > 0) {
|
||||
const text = (btn.textContent || btn.value || "").toLowerCase().trim();
|
||||
const compact = text.replace(/\s/g, "");
|
||||
if (compact.includes("登录") || compact.includes("login") || compact.includes("signin")) {
|
||||
return btn;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function fillForm(config, canSubmit = true) {
|
||||
const passwordField = findPasswordField(config);
|
||||
if (!passwordField) {
|
||||
console.log("[AutoLogin]", t("logNoPasswordField"));
|
||||
return false;
|
||||
}
|
||||
const usernameField = findUsernameField(config);
|
||||
if (usernameField) {
|
||||
setNativeValue(usernameField, config.username);
|
||||
console.log("[AutoLogin]", t("logFilledUsername"));
|
||||
}
|
||||
else {
|
||||
console.log("[AutoLogin]", t("logNoUsernameField"));
|
||||
}
|
||||
setNativeValue(passwordField, config.password);
|
||||
console.log("[AutoLogin]", t("logFilledPassword"));
|
||||
// 检测到验证码则强制不自动提交,提示用户手动操作
|
||||
const captcha = detectCaptcha();
|
||||
let actualCanSubmit = canSubmit;
|
||||
if (captcha && config.autoSubmit) {
|
||||
actualCanSubmit = false;
|
||||
console.warn("[AutoLogin]", t("logCaptchaSkip", captcha.reason));
|
||||
showCaptchaToast(captcha.reason);
|
||||
}
|
||||
if (config.autoSubmit && actualCanSubmit) {
|
||||
const submitBtn = findSubmitButton(config, passwordField);
|
||||
if (submitBtn) {
|
||||
// delayMs 给需要 JS 初始化的页面留出时间
|
||||
const delay = Math.max(300, Number(config.delayMs) || 300);
|
||||
setTimeout(() => {
|
||||
console.log("[AutoLogin]", t("logAutoClick", String(delay)));
|
||||
submitBtn.click();
|
||||
}, delay);
|
||||
return true;
|
||||
}
|
||||
if (passwordField.form) {
|
||||
const delay = Math.max(300, Number(config.delayMs) || 300);
|
||||
console.log("[AutoLogin]", t("logTryFormSubmit"));
|
||||
setTimeout(() => {
|
||||
passwordField.form.requestSubmit
|
||||
? passwordField.form.requestSubmit()
|
||||
: passwordField.form.submit();
|
||||
}, delay);
|
||||
return true;
|
||||
}
|
||||
console.log("[AutoLogin]", t("logNoSubmitAndForm"));
|
||||
}
|
||||
else if (config.autoSubmit && !actualCanSubmit && captcha) {
|
||||
console.warn("[AutoLogin]", t("logCaptchaFilled"));
|
||||
}
|
||||
else if (config.autoSubmit && !canSubmit) {
|
||||
console.warn("[AutoLogin]", t("logAttemptExceeded"));
|
||||
}
|
||||
else {
|
||||
console.log("[AutoLogin]", t("logNoAutoSubmit"));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// 验证码提示浮窗,8 秒后自动消失
|
||||
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;">${t("captchaToastTitle")}</div>` +
|
||||
`<div style="font-size:11px;color:#a0a0b0;">${t("captchaToastBody", 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;
|
||||
async function getDomainAttempts(envId, domain) {
|
||||
const resp = await _sendMessage({ action: "getDomainAttempts", envId, domain }).catch(() => null);
|
||||
return resp?.attempts || { count: 0, lastAttempt: 0 };
|
||||
}
|
||||
async function incrementDomainAttempts(envId, domain) {
|
||||
const resp = await _sendMessage({ action: "incrementDomainAttempts", envId, domain }).catch(() => null);
|
||||
return resp?.attempts || { count: 0, lastAttempt: 0 };
|
||||
}
|
||||
async function resetDomainAttempts(envId, domain) {
|
||||
await _sendMessage({ action: "resetDomainAttempts", envId, domain }).catch(() => { });
|
||||
}
|
||||
async function loadAllConfigs() {
|
||||
const resp = await _sendMessage({ action: "loadAllConfigs" }).catch(() => null);
|
||||
return resp?.configs || [];
|
||||
}
|
||||
async function autoFillOnLoad() {
|
||||
let allConfigs = await loadAllConfigs();
|
||||
if (allConfigs.length === 0) {
|
||||
// session 可能为空(明文模式 SW 重启后未初始化),触发 background 加载后重试
|
||||
try {
|
||||
await _sendMessage({ action: "getVaultStatus" });
|
||||
allConfigs = await loadAllConfigs();
|
||||
}
|
||||
catch (e) {
|
||||
console.warn("[AutoLogin] 初始化失败:", e.message || e);
|
||||
return;
|
||||
}
|
||||
if (allConfigs.length === 0)
|
||||
return;
|
||||
}
|
||||
// 每个配置有独立开关 enabled
|
||||
const enabledConfigs = allConfigs.filter((c) => c.enabled !== false);
|
||||
if (enabledConfigs.length === 0)
|
||||
return;
|
||||
const matched = enabledConfigs.filter((c) => isDomainMatch(window.location.href, c.domain));
|
||||
if (matched.length === 0)
|
||||
return;
|
||||
if (matched.length === 1) {
|
||||
await fillSingleConfig(matched[0]);
|
||||
return;
|
||||
}
|
||||
// 多环境命中:弹浮窗让用户选择
|
||||
console.log("[AutoLogin]", t("logMatchedMulti", String(matched.length)));
|
||||
showEnvPicker(matched);
|
||||
}
|
||||
// SPA 表单可能晚于脚本加载,填不到时每 500ms 重试,30 秒超时
|
||||
const SPA_FILL_TIMEOUT_MS = 30000;
|
||||
const SPA_RETRY_INTERVAL_MS = 500;
|
||||
async function fillSingleConfig(config) {
|
||||
console.log("[AutoLogin]", t("logStartFillEnv", config.envName || t("defaultEnvName")));
|
||||
// 超限后仅填充不自动提交
|
||||
let canSubmit = config.autoSubmit;
|
||||
if (config.autoSubmit) {
|
||||
const attempts = await getDomainAttempts(config.envId, config.domain);
|
||||
const now = Date.now();
|
||||
if (now - attempts.lastAttempt > ATTEMPT_COOLDOWN) {
|
||||
attempts.count = 0;
|
||||
}
|
||||
if (attempts.count >= MAX_LOGIN_ATTEMPTS) {
|
||||
canSubmit = false;
|
||||
console.warn("[AutoLogin] " + t("logAttemptExceededDetail", config.envName, config.domain, String(attempts.count), String(MAX_LOGIN_ATTEMPTS), String(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) {
|
||||
const inputs = deepQuerySelectorAll("input");
|
||||
const pwd = deepQuerySelectorAll("input[type='password']");
|
||||
console.warn("[AutoLogin] 填充超时: input=" + inputs.length + " pwd=" + pwd.length + " form=" + document.querySelectorAll("form").length + " | " + inputs.slice(0, 6).map((i) => "type=" + (i.type || "?") + "/name=" + (i.name || i.id || "?")).join(", "));
|
||||
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 = t("envPickerTitle");
|
||||
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 || t("defaultEnvName"));
|
||||
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 = t("cancelBtn");
|
||||
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;
|
||||
}
|
||||
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);
|
||||
if (filled) {
|
||||
sendResponse({ success: true });
|
||||
}
|
||||
else {
|
||||
const inputs = deepQuerySelectorAll("input");
|
||||
const pwdInputs = deepQuerySelectorAll("input[type='password']");
|
||||
sendResponse({
|
||||
success: false,
|
||||
diag: {
|
||||
total: inputs.length,
|
||||
pwd: pwdInputs.length,
|
||||
forms: document.querySelectorAll("form").length,
|
||||
sample: inputs.slice(0, 10).map((i) => `type=${i.type || "?"}|name=${(i.name || i.id || i.placeholder || "?").slice(0, 20)}`),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
else if (message.action === "manualFillMulti" && Array.isArray(message.configs)) {
|
||||
showEnvPicker(message.configs);
|
||||
sendResponse({ success: true });
|
||||
}
|
||||
return true;
|
||||
});
|
||||
autoFillOnLoad();
|
||||
// URL 变化后若新页面不再匹配任何配置,重置之前匹配环境的失败计数
|
||||
let lastUrl = window.location.href;
|
||||
const observer = new MutationObserver(() => {
|
||||
if (window.location.href !== lastUrl) {
|
||||
const prevUrl = lastUrl;
|
||||
lastUrl = window.location.href;
|
||||
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 });
|
||||
Vendored
+47
@@ -0,0 +1,47 @@
|
||||
const enc = new TextEncoder();
|
||||
const dec = new TextDecoder();
|
||||
export function bufToBase64(buf) {
|
||||
const bytes = new Uint8Array(buf);
|
||||
let bin = "";
|
||||
for (let i = 0; i < bytes.length; i++)
|
||||
bin += String.fromCharCode(bytes[i]);
|
||||
return btoa(bin);
|
||||
}
|
||||
export function base64ToBuf(b64) {
|
||||
const bin = atob(b64);
|
||||
const bytes = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++)
|
||||
bytes[i] = bin.charCodeAt(i);
|
||||
return bytes.buffer;
|
||||
}
|
||||
async function deriveKey(password, salt, iterations) {
|
||||
const keyMaterial = await crypto.subtle.importKey("raw", enc.encode(password), { name: "PBKDF2" }, false, ["deriveKey"]);
|
||||
return crypto.subtle.deriveKey({ name: "PBKDF2", salt, iterations, hash: "SHA-256" }, keyMaterial, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]);
|
||||
}
|
||||
export async function encryptConfigs(configs, password) {
|
||||
const salt = crypto.getRandomValues(new Uint8Array(16));
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const iterations = 100000;
|
||||
const key = await deriveKey(password, salt, iterations);
|
||||
const plaintext = enc.encode(JSON.stringify(configs));
|
||||
const ciphertext = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, plaintext);
|
||||
return {
|
||||
algorithm: "AES-GCM",
|
||||
iv: bufToBase64(iv),
|
||||
ciphertext: bufToBase64(ciphertext),
|
||||
kdf: {
|
||||
algorithm: "PBKDF2",
|
||||
iterations,
|
||||
hash: "SHA-256",
|
||||
salt: bufToBase64(salt)
|
||||
}
|
||||
};
|
||||
}
|
||||
export async function decryptConfigs(cipher, password) {
|
||||
const salt = new Uint8Array(base64ToBuf(cipher.kdf.salt));
|
||||
const iv = new Uint8Array(base64ToBuf(cipher.iv));
|
||||
const iterations = cipher.kdf.iterations || 100000;
|
||||
const key = await deriveKey(password, salt, iterations);
|
||||
const plainBuf = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, base64ToBuf(cipher.ciphertext));
|
||||
return JSON.parse(dec.decode(plainBuf));
|
||||
}
|
||||
Vendored
+159
@@ -0,0 +1,159 @@
|
||||
"use strict";
|
||||
/**
|
||||
* 加密库:主口令 + AES-GCM 整库加密。
|
||||
* local 存 vaultMeta / encryptedVault;解锁后明文放 session,不进 local;
|
||||
* 派生密钥仅由 background 在内存中持有。
|
||||
*/
|
||||
(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;
|
||||
}
|
||||
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"]);
|
||||
}
|
||||
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);
|
||||
}
|
||||
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,口令错误抛错,返回明文与密钥 */
|
||||
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 };
|
||||
}
|
||||
async function lock() {
|
||||
await getStorage("session").remove([SESSION_ENV_KEY, SESSION_ACTIVE_KEY]);
|
||||
}
|
||||
async function getVaultMeta() {
|
||||
return getLocal(VAULT_META_KEY);
|
||||
}
|
||||
async function getEncryptedVault() {
|
||||
return getLocal(ENCRYPTED_VAULT_KEY);
|
||||
}
|
||||
/** 用给定密钥加密 environments 并写回 local */
|
||||
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,
|
||||
};
|
||||
});
|
||||
Vendored
+136
@@ -0,0 +1,136 @@
|
||||
"use strict";
|
||||
/**
|
||||
* 环境数据层:环境/配置的读写,数据存于 chrome.storage.session(解锁后明文)。
|
||||
* 首次建口令由 crypto-store.setupVault 完成;加密回写由 background 监听完成。
|
||||
*/
|
||||
(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;
|
||||
}
|
||||
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 });
|
||||
}
|
||||
const MAX_ENVIRONMENTS = 5;
|
||||
async function createEnvironment(name) {
|
||||
const { environments } = await loadEnvironments();
|
||||
if (environments.length >= MAX_ENVIRONMENTS) {
|
||||
throw new Error(`最多 ${MAX_ENVIRONMENTS} 个环境`);
|
||||
}
|
||||
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 ?? undefined);
|
||||
// 失败计数存于 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);
|
||||
}
|
||||
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,
|
||||
MAX_ENVIRONMENTS,
|
||||
loadEnvironments,
|
||||
saveEnvironments,
|
||||
getActiveEnv,
|
||||
setActiveEnv,
|
||||
createEnvironment,
|
||||
renameEnvironment,
|
||||
deleteEnvironment,
|
||||
loadConfigs,
|
||||
saveConfigs,
|
||||
loadAllConfigs,
|
||||
genId,
|
||||
genCfgId,
|
||||
};
|
||||
});
|
||||
Vendored
+41
@@ -0,0 +1,41 @@
|
||||
export function t(key, ...args) {
|
||||
const msg = chrome.i18n.getMessage(key, args);
|
||||
return msg || key;
|
||||
}
|
||||
export function applyI18n(root = document) {
|
||||
root.querySelectorAll("[data-i18n]").forEach((el) => {
|
||||
const key = el.dataset.i18n || "";
|
||||
if (el.children.length === 0) {
|
||||
const msg = chrome.i18n.getMessage(key);
|
||||
if (msg)
|
||||
el.textContent = msg;
|
||||
}
|
||||
else {
|
||||
const msg = chrome.i18n.getMessage(key);
|
||||
if (msg) {
|
||||
const firstText = el.firstChild;
|
||||
if (firstText && firstText.nodeType === Node.TEXT_NODE) {
|
||||
firstText.nodeValue = msg;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
root.querySelectorAll("[data-i18n-placeholder]").forEach((el) => {
|
||||
const key = el.dataset.i18nPlaceholder || "";
|
||||
const msg = chrome.i18n.getMessage(key);
|
||||
if (msg)
|
||||
el.placeholder = msg;
|
||||
});
|
||||
root.querySelectorAll("[data-i18n-title]").forEach((el) => {
|
||||
const key = el.dataset.i18nTitle || "";
|
||||
const msg = chrome.i18n.getMessage(key);
|
||||
if (msg)
|
||||
el.title = msg;
|
||||
});
|
||||
root.querySelectorAll("[data-i18n-aria]").forEach((el) => {
|
||||
const key = el.dataset.i18nAria || "";
|
||||
const msg = chrome.i18n.getMessage(key);
|
||||
if (msg)
|
||||
el.setAttribute("aria-label", msg);
|
||||
});
|
||||
}
|
||||
Vendored
+27
@@ -0,0 +1,27 @@
|
||||
import { t } from "./i18n.js";
|
||||
export function sendMessage(message, retries = 3) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let attempt = 0;
|
||||
const trySend = () => {
|
||||
attempt++;
|
||||
if (!chrome.runtime?.id) {
|
||||
location.reload();
|
||||
reject(new Error(t("toastContextReload")));
|
||||
return;
|
||||
}
|
||||
chrome.runtime.sendMessage(message, (resp) => {
|
||||
const err = chrome.runtime.lastError;
|
||||
if (err) {
|
||||
if (attempt < retries && /Receiving end does not exist/i.test(err.message ?? "")) {
|
||||
setTimeout(trySend, 200 * attempt);
|
||||
return;
|
||||
}
|
||||
reject(new Error(err.message ?? ""));
|
||||
return;
|
||||
}
|
||||
resolve(resp);
|
||||
});
|
||||
};
|
||||
trySend();
|
||||
});
|
||||
}
|
||||
Vendored
+1400
File diff suppressed because it is too large
Load Diff
Vendored
+67
@@ -0,0 +1,67 @@
|
||||
import { t } from "./i18n.js";
|
||||
const ATTACK_RATE = 1e7;
|
||||
export function estimateStrength(pwd) {
|
||||
if (!pwd)
|
||||
return { entropy: 0, seconds: 0, level: "empty", label: t("strengthEmpty") };
|
||||
let pool = 0;
|
||||
if (/[a-z]/.test(pwd))
|
||||
pool += 26;
|
||||
if (/[A-Z]/.test(pwd))
|
||||
pool += 26;
|
||||
if (/[0-9]/.test(pwd))
|
||||
pool += 10;
|
||||
if (/[^a-zA-Z0-9]/.test(pwd))
|
||||
pool += 32;
|
||||
if (pool === 0)
|
||||
pool = 1;
|
||||
const entropy = pwd.length * Math.log2(pool);
|
||||
const combinations = Math.pow(2, entropy);
|
||||
const seconds = combinations / 2 / ATTACK_RATE;
|
||||
let level;
|
||||
let label;
|
||||
if (seconds < 1) {
|
||||
level = "very-weak";
|
||||
label = t("strengthVeryWeak");
|
||||
}
|
||||
else if (seconds < 3600) {
|
||||
level = "weak";
|
||||
label = t("strengthWeak");
|
||||
}
|
||||
else if (seconds < 86400 * 30) {
|
||||
level = "medium";
|
||||
label = t("strengthMedium");
|
||||
}
|
||||
else if (seconds < 86400 * 365 * 1000) {
|
||||
level = "strong";
|
||||
label = t("strengthStrong");
|
||||
}
|
||||
else {
|
||||
level = "very-strong";
|
||||
label = t("strengthVeryStrong");
|
||||
}
|
||||
return { entropy, seconds, level, label };
|
||||
}
|
||||
export function formatTime(seconds) {
|
||||
if (seconds < 1)
|
||||
return t("crackInstant");
|
||||
if (seconds < 60)
|
||||
return t("crackSeconds", String(Math.round(seconds)));
|
||||
if (seconds < 3600)
|
||||
return t("crackMinutes", String(Math.round(seconds / 60)));
|
||||
if (seconds < 86400)
|
||||
return t("crackHours", String(Math.round(seconds / 3600)));
|
||||
if (seconds < 86400 * 30)
|
||||
return t("crackDays", String(Math.round(seconds / 86400)));
|
||||
if (seconds < 86400 * 365)
|
||||
return t("crackMonths", String(Math.round(seconds / 86400 / 30)));
|
||||
const years = seconds / 86400 / 365;
|
||||
if (years < 1000)
|
||||
return t("crackYears", String(Math.round(years)));
|
||||
if (years < 1e6)
|
||||
return t("crackThousandYears", String(Math.round(years / 1000)));
|
||||
if (years < 1e9)
|
||||
return t("crackMillionYears", String(Math.round(years / 1e6)));
|
||||
if (years < 1e12)
|
||||
return t("crackBillionYears", String(Math.round(years / 1e9)));
|
||||
return t("crackTrillionYears", (years / 1e12).toExponential(2));
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
export {};
|
||||
Vendored
+24
@@ -0,0 +1,24 @@
|
||||
export function openConfigUrl(domain) {
|
||||
if (!domain)
|
||||
return;
|
||||
const url = /^https?:\/\//i.test(domain) ? domain : "http://" + domain;
|
||||
window.open(url, "_blank", "noopener");
|
||||
}
|
||||
export async function fetchTitleByDomain(domain) {
|
||||
try {
|
||||
const base = /^https?:\/\//i.test(domain) ? domain : "*://" + domain;
|
||||
const patterns = [base];
|
||||
if (!base.endsWith("*"))
|
||||
patterns.push(base + "/*");
|
||||
const tabs = await chrome.tabs.query({ url: patterns });
|
||||
for (const tab of tabs) {
|
||||
if (tab.title)
|
||||
return tab.title;
|
||||
}
|
||||
}
|
||||
catch (e) { }
|
||||
return null;
|
||||
}
|
||||
export function closeAllMenus() {
|
||||
document.querySelectorAll(".config-menu.open").forEach((m) => m.classList.remove("open"));
|
||||
}
|
||||
Vendored
+80
@@ -0,0 +1,80 @@
|
||||
"use strict";
|
||||
// URL 匹配 + HTML 转义,浏览器与 Node 通用
|
||||
(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 是否匹配规则。
|
||||
* 纯 hostname:example.com / *.example.com
|
||||
* hostname+path:example.com/login/*,路径部分支持 * 通配
|
||||
*/
|
||||
function isDomainMatch(url, pattern) {
|
||||
if (!url || !pattern)
|
||||
return false;
|
||||
try {
|
||||
const u = new URL(url);
|
||||
const rawPattern = String(pattern).trim().replace(/^https?:\/\//i, "");
|
||||
if (rawPattern.includes("/")) {
|
||||
const slashIdx = rawPattern.indexOf("/");
|
||||
const hostPart = rawPattern.slice(0, slashIdx);
|
||||
const pathPart = rawPattern.slice(slashIdx + 1) || "";
|
||||
const hostForMatch = hostPart.includes(":") ? u.host : u.hostname;
|
||||
if (!matchHost(hostForMatch, hostPart))
|
||||
return false;
|
||||
return matchPath(u.pathname, pathPart);
|
||||
}
|
||||
const hostForMatch = rawPattern.includes(":") ? u.host : u.hostname;
|
||||
return matchHost(hostForMatch, 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) {
|
||||
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 转义,防止配置中的特殊字符破坏页面 */
|
||||
function escapeHtml(str) {
|
||||
if (str == null)
|
||||
return "";
|
||||
return String(str)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
return { isDomainMatch, escapeHtml };
|
||||
});
|
||||
+2
-2
@@ -22,12 +22,12 @@
|
||||
"128": "icons/icon128.png"
|
||||
},
|
||||
"background": {
|
||||
"service_worker": "background.js"
|
||||
"service_worker": "dist/background.js"
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": ["<all_urls>"],
|
||||
"js": ["utils.js", "content.js"],
|
||||
"js": ["dist/utils.js", "dist/content.js"],
|
||||
"run_at": "document_idle"
|
||||
}
|
||||
]
|
||||
|
||||
Generated
+65
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"name": "auto-login",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "auto-login",
|
||||
"version": "1.0.0",
|
||||
"devDependencies": {
|
||||
"@types/chrome": "^0.0.287",
|
||||
"typescript": "^5.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/chrome": {
|
||||
"version": "0.0.287",
|
||||
"resolved": "https://registry.npmmirror.com/@types/chrome/-/chrome-0.0.287.tgz",
|
||||
"integrity": "sha512-wWhBNPNXZHwycHKNYnexUcpSbrihVZu++0rdp6GEk5ZgAglenLx+RwdEouh6FrHS0XQiOxSd62yaujM1OoQlZQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/filesystem": "*",
|
||||
"@types/har-format": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/filesystem": {
|
||||
"version": "0.0.36",
|
||||
"resolved": "https://registry.npmmirror.com/@types/filesystem/-/filesystem-0.0.36.tgz",
|
||||
"integrity": "sha512-vPDXOZuannb9FZdxgHnqSwAG/jvdGM8Wq+6N4D/d80z+D4HWH+bItqsZaVRQykAn6WEVeEkLm2oQigyHtgb0RA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/filewriter": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/filewriter": {
|
||||
"version": "0.0.33",
|
||||
"resolved": "https://registry.npmmirror.com/@types/filewriter/-/filewriter-0.0.33.tgz",
|
||||
"integrity": "sha512-xFU8ZXTw4gd358lb2jw25nxY9QAgqn2+bKKjKOYfNCzN4DKCFetK7sPtrlpg66Ywe3vWY9FNxprZawAh9wfJ3g==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/har-format": {
|
||||
"version": "1.2.16",
|
||||
"resolved": "https://registry.npmmirror.com/@types/har-format/-/har-format-1.2.16.tgz",
|
||||
"integrity": "sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "auto-login",
|
||||
"version": "1.0.0",
|
||||
"description": "Auto Login Manager Chrome Extension",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"watch": "tsc --watch",
|
||||
"test": "node --test tests/*.test.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/chrome": "^0.0.287",
|
||||
"typescript": "^5.6.0"
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -334,9 +334,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="utils.js"></script>
|
||||
<script src="env-store.js"></script>
|
||||
<script src="crypto-store.js"></script>
|
||||
<script src="popup.js"></script>
|
||||
<script src="dist/utils.js"></script>
|
||||
<script src="dist/env-store.js"></script>
|
||||
<script src="dist/crypto-store.js"></script>
|
||||
<script type="module" src="dist/popup.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
importScripts("crypto-store.js");
|
||||
|
||||
let masterKey = null; // 解锁后持有,锁定或 SW 重启后清空
|
||||
let autoLockTimer = null;
|
||||
|
||||
let masterKey: CryptoKey | null = null; // 解锁后持有,锁定或 SW 重启后清空
|
||||
let autoLockTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const AUTO_LOCK_MS = 10 * 60 * 1000; // 10 分钟无操作自动锁定
|
||||
|
||||
chrome.runtime.onInstalled.addListener(() => {
|
||||
chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true })
|
||||
.catch((err) => console.warn("[AutoLogin] 设置 sidePanel 行为失败:", err));
|
||||
chrome.sidePanel.setOptions({ panelWidth: 340 })
|
||||
.catch((err) => console.warn("[AutoLogin] 设置 sidePanel 宽度失败:", err));
|
||||
|
||||
migrateLegacyVault();
|
||||
console.log("[AutoLogin] 扩展已安装");
|
||||
});
|
||||
@@ -122,7 +122,7 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
await ensurePlainEnvironments();
|
||||
const local = await chrome.storage.local.get("environments");
|
||||
const sess = await chrome.storage.session.get("environments");
|
||||
const cfgCount = (arr) => (arr || []).reduce((s, e) => s + (e.configs?.length || 0), 0);
|
||||
const cfgCount = (arr: Environment[] | undefined) => (arr || []).reduce((s: number, e: Environment) => s + (e.configs?.length || 0), 0);
|
||||
sendResponse({
|
||||
hasVault: false, unlocked: true, pendingImport: false,
|
||||
localEnvs: (local.environments || []).length, localCfgs: cfgCount(local.environments),
|
||||
@@ -139,9 +139,9 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
if (!(await CryptoStore.hasVault())) await ensurePlainEnvironments();
|
||||
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 }));
|
||||
const all: (DomainConfig & { envId: string; envName: string })[] = [];
|
||||
environments.forEach((env: Environment) => {
|
||||
(env.configs || []).forEach((cfg: DomainConfig) => all.push({ ...cfg, envId: env.id, envName: env.name }));
|
||||
});
|
||||
sendResponse({ configs: all });
|
||||
break;
|
||||
@@ -210,7 +210,7 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
resetAutoLockTimer();
|
||||
sendResponse({ success: true });
|
||||
} catch (err) {
|
||||
sendResponse({ success: false, error: err.message });
|
||||
sendResponse({ success: false, error: (err as Error).message });
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -241,7 +241,7 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
sendResponse({ error: "unknown action" });
|
||||
}
|
||||
} catch (err) {
|
||||
sendResponse({ success: false, error: err.message });
|
||||
sendResponse({ success: false, error: (err as Error).message });
|
||||
}
|
||||
})();
|
||||
return true; // 异步响应
|
||||
+35
-31
@@ -1,5 +1,9 @@
|
||||
|
||||
const t = (key, ...args) => chrome.i18n.getMessage(key, args) || key;
|
||||
|
||||
type Config = DomainConfig & { envId: string; envName: string };
|
||||
|
||||
const t = (key: string, ...args: string[]): string => chrome.i18n.getMessage(key, args) || key;
|
||||
const _sendMessage = chrome.runtime.sendMessage as any;
|
||||
|
||||
|
||||
// 2FA / 验证码检测:检测到则不自动提交,仅填充
|
||||
@@ -22,7 +26,7 @@ function detectCaptcha() {
|
||||
for (const sel of sliderSelectors) {
|
||||
try {
|
||||
const el = document.querySelector(sel);
|
||||
if (el && el.offsetParent !== null) {
|
||||
if (el && (el as HTMLElement).offsetParent !== null) {
|
||||
return { type: "slider/captcha", reason: t("captchaReasonSlider", sel) };
|
||||
}
|
||||
} catch { /* selector 可能无效 */ }
|
||||
@@ -31,15 +35,15 @@ function detectCaptcha() {
|
||||
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") {
|
||||
for (const input of Array.from(codeInputs) as HTMLElement[]) {
|
||||
if (input.offsetParent !== null && (input as HTMLInputElement).type !== "hidden") {
|
||||
return { type: "captcha-input", reason: t("captchaReasonInput") };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function setNativeValue(el, value) {
|
||||
function setNativeValue(el: HTMLInputElement | HTMLTextAreaElement, value: string): void {
|
||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype,
|
||||
"value"
|
||||
@@ -58,11 +62,11 @@ function setNativeValue(el, value) {
|
||||
el.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
}
|
||||
|
||||
function deepQuerySelectorAll(selector, root) {
|
||||
const result = [];
|
||||
const stack = [root || document];
|
||||
function deepQuerySelectorAll(selector: string, root?: ParentNode): any[] {
|
||||
const result: any[] = [];
|
||||
const stack: ParentNode[] = [root || document];
|
||||
while (stack.length) {
|
||||
const r = stack.pop();
|
||||
const r = stack.pop()!;
|
||||
result.push(...r.querySelectorAll(selector));
|
||||
for (const el of r.querySelectorAll("*")) {
|
||||
if (el.shadowRoot) stack.push(el.shadowRoot);
|
||||
@@ -71,11 +75,11 @@ function deepQuerySelectorAll(selector, root) {
|
||||
return result;
|
||||
}
|
||||
|
||||
function deepQuerySelector(selector, root) {
|
||||
function deepQuerySelector(selector: string, root?: ParentNode): any {
|
||||
return deepQuerySelectorAll(selector, root)[0] || null;
|
||||
}
|
||||
|
||||
function resolveElement(value, multiple) {
|
||||
function resolveElement(value: string, multiple?: boolean): any {
|
||||
if (!value) return null;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed.startsWith("<")) {
|
||||
@@ -108,7 +112,7 @@ function resolveElement(value, multiple) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function findUsernameField(config) {
|
||||
function findUsernameField(config: Config): any {
|
||||
if (config.usernameSelector) {
|
||||
const el = resolveElement(config.usernameSelector);
|
||||
if (el) return el;
|
||||
@@ -165,7 +169,7 @@ function findUsernameField(config) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function findPasswordField(config) {
|
||||
function findPasswordField(config: Config): any {
|
||||
if (config.passwordSelector) {
|
||||
const el = resolveElement(config.passwordSelector);
|
||||
if (el) return el;
|
||||
@@ -193,7 +197,7 @@ function findPasswordField(config) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function findSubmitButton(config, passwordField) {
|
||||
function findSubmitButton(config: Config, passwordField: any): any {
|
||||
if (config.submitSelector) {
|
||||
const els = resolveElement(config.submitSelector, true);
|
||||
if (els && els.length === 1) return els[0];
|
||||
@@ -261,7 +265,7 @@ function findSubmitButton(config, passwordField) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function fillForm(config, canSubmit = true) {
|
||||
function fillForm(config: Config, canSubmit = true): boolean {
|
||||
const passwordField = findPasswordField(config);
|
||||
if (!passwordField) {
|
||||
console.log("[AutoLogin]", t("logNoPasswordField"));
|
||||
@@ -325,8 +329,8 @@ function fillForm(config, canSubmit = true) {
|
||||
}
|
||||
|
||||
// 验证码提示浮窗,8 秒后自动消失
|
||||
let captchaToastEl = null;
|
||||
function showCaptchaToast(reason) {
|
||||
let captchaToastEl: HTMLElement | null = null;
|
||||
function showCaptchaToast(reason: string): void {
|
||||
if (captchaToastEl && captchaToastEl.parentNode) {
|
||||
captchaToastEl.parentNode.removeChild(captchaToastEl);
|
||||
}
|
||||
@@ -360,35 +364,35 @@ const MAX_LOGIN_ATTEMPTS = 3;
|
||||
const ATTEMPT_COOLDOWN = 5 * 60 * 1000;
|
||||
|
||||
|
||||
async function getDomainAttempts(envId, domain) {
|
||||
const resp = await chrome.runtime.sendMessage({ action: "getDomainAttempts", envId, domain }).catch(() => null);
|
||||
async function getDomainAttempts(envId: string, domain: string): Promise<any> {
|
||||
const resp = await _sendMessage({ action: "getDomainAttempts", envId, domain }).catch(() => null);
|
||||
return resp?.attempts || { count: 0, lastAttempt: 0 };
|
||||
}
|
||||
|
||||
async function incrementDomainAttempts(envId, domain) {
|
||||
const resp = await chrome.runtime.sendMessage({ action: "incrementDomainAttempts", envId, domain }).catch(() => null);
|
||||
async function incrementDomainAttempts(envId: string, domain: string): Promise<any> {
|
||||
const resp = await _sendMessage({ action: "incrementDomainAttempts", envId, domain }).catch(() => null);
|
||||
return resp?.attempts || { count: 0, lastAttempt: 0 };
|
||||
}
|
||||
|
||||
async function resetDomainAttempts(envId, domain) {
|
||||
await chrome.runtime.sendMessage({ action: "resetDomainAttempts", envId, domain }).catch(() => {});
|
||||
async function resetDomainAttempts(envId: string, domain: string): Promise<void> {
|
||||
await _sendMessage({ action: "resetDomainAttempts", envId, domain }).catch(() => {});
|
||||
}
|
||||
|
||||
|
||||
async function loadAllConfigs() {
|
||||
const resp = await chrome.runtime.sendMessage({ action: "loadAllConfigs" }).catch(() => null);
|
||||
async function loadAllConfigs(): Promise<Config[]> {
|
||||
const resp = await _sendMessage({ action: "loadAllConfigs" }).catch(() => null);
|
||||
return resp?.configs || [];
|
||||
}
|
||||
|
||||
async function autoFillOnLoad() {
|
||||
async function autoFillOnLoad(): Promise<void> {
|
||||
let allConfigs = await loadAllConfigs();
|
||||
if (allConfigs.length === 0) {
|
||||
// session 可能为空(明文模式 SW 重启后未初始化),触发 background 加载后重试
|
||||
try {
|
||||
await chrome.runtime.sendMessage({ action: "getVaultStatus" });
|
||||
await _sendMessage({ action: "getVaultStatus" });
|
||||
allConfigs = await loadAllConfigs();
|
||||
} catch (e) {
|
||||
console.warn("[AutoLogin] 初始化失败:", e.message || e);
|
||||
console.warn("[AutoLogin] 初始化失败:", (e as Error).message || e);
|
||||
return;
|
||||
}
|
||||
if (allConfigs.length === 0) return;
|
||||
@@ -415,7 +419,7 @@ async function autoFillOnLoad() {
|
||||
const SPA_FILL_TIMEOUT_MS = 30000;
|
||||
const SPA_RETRY_INTERVAL_MS = 500;
|
||||
|
||||
async function fillSingleConfig(config) {
|
||||
async function fillSingleConfig(config: Config): Promise<void> {
|
||||
console.log("[AutoLogin]", t("logStartFillEnv", config.envName || t("defaultEnvName")));
|
||||
|
||||
// 超限后仅填充不自动提交
|
||||
@@ -463,8 +467,8 @@ async function fillSingleConfig(config) {
|
||||
}
|
||||
|
||||
// 多环境命中时在页面右上角插入选择浮窗
|
||||
let envPickerEl = null;
|
||||
function showEnvPicker(configs) {
|
||||
let envPickerEl: HTMLElement | null = null;
|
||||
function showEnvPicker(configs: Config[]): void {
|
||||
if (envPickerEl && envPickerEl.parentNode) {
|
||||
envPickerEl.parentNode.removeChild(envPickerEl);
|
||||
envPickerEl = null;
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { CipherPayload } from "./types.js";
|
||||
|
||||
const enc = new TextEncoder();
|
||||
const dec = new TextDecoder();
|
||||
|
||||
export function bufToBase64(buf: ArrayBuffer | Uint8Array): string {
|
||||
const bytes = new Uint8Array(buf);
|
||||
let bin = "";
|
||||
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
|
||||
return btoa(bin);
|
||||
}
|
||||
|
||||
export function base64ToBuf(b64: string): ArrayBuffer {
|
||||
const bin = atob(b64);
|
||||
const bytes = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
||||
return bytes.buffer;
|
||||
}
|
||||
|
||||
async function deriveKey(password: string, salt: Uint8Array<ArrayBuffer>, iterations: number): Promise<CryptoKey> {
|
||||
const keyMaterial = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
enc.encode(password),
|
||||
{ name: "PBKDF2" },
|
||||
false,
|
||||
["deriveKey"]
|
||||
);
|
||||
return crypto.subtle.deriveKey(
|
||||
{ name: "PBKDF2", salt, iterations, hash: "SHA-256" },
|
||||
keyMaterial,
|
||||
{ name: "AES-GCM", length: 256 },
|
||||
false,
|
||||
["encrypt", "decrypt"]
|
||||
);
|
||||
}
|
||||
|
||||
export async function encryptConfigs(configs: unknown, password: string): Promise<CipherPayload> {
|
||||
const salt = crypto.getRandomValues(new Uint8Array(16));
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const iterations = 100000;
|
||||
const key = await deriveKey(password, salt, iterations);
|
||||
const plaintext = enc.encode(JSON.stringify(configs));
|
||||
const ciphertext = await crypto.subtle.encrypt(
|
||||
{ name: "AES-GCM", iv },
|
||||
key,
|
||||
plaintext
|
||||
);
|
||||
return {
|
||||
algorithm: "AES-GCM",
|
||||
iv: bufToBase64(iv),
|
||||
ciphertext: bufToBase64(ciphertext),
|
||||
kdf: {
|
||||
algorithm: "PBKDF2",
|
||||
iterations,
|
||||
hash: "SHA-256",
|
||||
salt: bufToBase64(salt)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export async function decryptConfigs(cipher: CipherPayload, password: string): Promise<any> {
|
||||
const salt = new Uint8Array(base64ToBuf(cipher.kdf.salt));
|
||||
const iv = new Uint8Array(base64ToBuf(cipher.iv));
|
||||
const iterations = cipher.kdf.iterations || 100000;
|
||||
const key = await deriveKey(password, salt, iterations);
|
||||
const plainBuf = await crypto.subtle.decrypt(
|
||||
{ name: "AES-GCM", iv },
|
||||
key,
|
||||
base64ToBuf(cipher.ciphertext)
|
||||
);
|
||||
return JSON.parse(dec.decode(plainBuf));
|
||||
}
|
||||
@@ -3,11 +3,18 @@
|
||||
* local 存 vaultMeta / encryptedVault;解锁后明文放 session,不进 local;
|
||||
* 派生密钥仅由 background 在内存中持有。
|
||||
*/
|
||||
(function (root, factory) {
|
||||
|
||||
|
||||
interface CryptoStoreDeps {
|
||||
getCrypto: () => Crypto;
|
||||
getStorage: (area: "local" | "session") => any;
|
||||
}
|
||||
|
||||
(function (root: any, factory: (deps: CryptoStoreDeps) => any): void {
|
||||
if (typeof module === "object" && module.exports) {
|
||||
module.exports = factory({
|
||||
getCrypto: () => globalThis.crypto,
|
||||
getStorage: (area) => {
|
||||
getStorage: (area: "local" | "session") => {
|
||||
const c = typeof chrome !== "undefined" ? chrome : null;
|
||||
return c?.storage?.[area] || null;
|
||||
},
|
||||
@@ -15,11 +22,11 @@
|
||||
} else {
|
||||
const api = factory({
|
||||
getCrypto: () => crypto,
|
||||
getStorage: (area) => chrome.storage[area],
|
||||
getStorage: (area: "local" | "session") => chrome.storage[area],
|
||||
});
|
||||
root.CryptoStore = api;
|
||||
}
|
||||
})(typeof globalThis !== "undefined" ? globalThis : this, function ({ getCrypto, getStorage }) {
|
||||
})(typeof globalThis !== "undefined" ? globalThis : this, function ({ getCrypto, getStorage }: CryptoStoreDeps) {
|
||||
const VAULT_META_KEY = "vaultMeta";
|
||||
const ENCRYPTED_VAULT_KEY = "encryptedVault";
|
||||
const SESSION_ENV_KEY = "environments";
|
||||
@@ -27,28 +34,28 @@
|
||||
const PBKDF2_ITERATIONS = 200000;
|
||||
const VAULT_VERSION = 1;
|
||||
|
||||
function b64encode(buf) {
|
||||
function b64encode(buf: ArrayBuffer | Uint8Array<ArrayBuffer>): string {
|
||||
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) {
|
||||
function b64decode(str: string): Uint8Array<ArrayBuffer> {
|
||||
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) {
|
||||
function randomBytes(n: number): Uint8Array<ArrayBuffer> {
|
||||
const arr = new Uint8Array(n);
|
||||
getCrypto().getRandomValues(arr);
|
||||
return arr;
|
||||
}
|
||||
|
||||
|
||||
async function deriveKey(password, salt, iterations) {
|
||||
async function deriveKey(password: string, salt: Uint8Array<ArrayBuffer>, iterations: number): Promise<CryptoKey> {
|
||||
const enc = new TextEncoder();
|
||||
const keyMaterial = await getCrypto().subtle.importKey(
|
||||
"raw", enc.encode(password), "PBKDF2", false, ["deriveKey"]
|
||||
@@ -63,7 +70,7 @@
|
||||
}
|
||||
|
||||
|
||||
async function encryptJson(obj, key) {
|
||||
async function encryptJson(obj: unknown, key: CryptoKey): Promise<{ iv: string; ciphertext: string }> {
|
||||
const enc = new TextEncoder();
|
||||
const iv = randomBytes(12);
|
||||
const ciphertext = await getCrypto().subtle.encrypt(
|
||||
@@ -73,7 +80,7 @@
|
||||
}
|
||||
|
||||
/** 解密为 JSON 对象,口令错误或数据损坏时抛错 */
|
||||
async function decryptJson({ iv, ciphertext }, key) {
|
||||
async function decryptJson({ iv, ciphertext }: { iv: string; ciphertext: string }, key: CryptoKey): Promise<any> {
|
||||
const dec = new TextDecoder();
|
||||
const plain = await getCrypto().subtle.decrypt(
|
||||
{ name: "AES-GCM", iv: b64decode(iv) }, key, b64decode(ciphertext)
|
||||
@@ -81,25 +88,25 @@
|
||||
return JSON.parse(dec.decode(plain));
|
||||
}
|
||||
|
||||
async function getLocal(key) {
|
||||
async function getLocal(key: string): Promise<any> {
|
||||
return (await getStorage("local").get(key))[key];
|
||||
}
|
||||
|
||||
|
||||
async function hasVault() {
|
||||
async function hasVault(): Promise<boolean> {
|
||||
const meta = await getLocal(VAULT_META_KEY);
|
||||
const cipher = await getLocal(ENCRYPTED_VAULT_KEY);
|
||||
return !!(meta && cipher);
|
||||
}
|
||||
|
||||
|
||||
async function isUnlocked() {
|
||||
async function isUnlocked(): Promise<boolean> {
|
||||
const data = await getStorage("session").get(SESSION_ENV_KEY);
|
||||
return !!data[SESSION_ENV_KEY];
|
||||
}
|
||||
|
||||
/** 首次设置口令:生成盐、加密初始环境写入 local,并把明文写入 session,返回派生密钥 */
|
||||
async function setupVault(password, environments) {
|
||||
async function setupVault(password: string, environments: Environment[]): Promise<CryptoKey> {
|
||||
if (!password) throw new Error("口令不能为空");
|
||||
const salt = randomBytes(16);
|
||||
const key = await deriveKey(password, salt, PBKDF2_ITERATIONS);
|
||||
@@ -122,12 +129,12 @@
|
||||
}
|
||||
|
||||
/** 解锁:解密 local 密文写入 session,口令错误抛错,返回明文与密钥 */
|
||||
async function unlock(password) {
|
||||
async function unlock(password: string): Promise<{ environments: Environment[]; key: CryptoKey }> {
|
||||
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;
|
||||
let environments: Environment[];
|
||||
try {
|
||||
environments = await decryptJson(cipher, key);
|
||||
} catch {
|
||||
@@ -142,20 +149,20 @@
|
||||
return { environments, key };
|
||||
}
|
||||
|
||||
async function lock() {
|
||||
async function lock(): Promise<void> {
|
||||
await getStorage("session").remove([SESSION_ENV_KEY, SESSION_ACTIVE_KEY]);
|
||||
}
|
||||
|
||||
async function getVaultMeta() {
|
||||
async function getVaultMeta(): Promise<any> {
|
||||
return getLocal(VAULT_META_KEY);
|
||||
}
|
||||
|
||||
async function getEncryptedVault() {
|
||||
async function getEncryptedVault(): Promise<any> {
|
||||
return getLocal(ENCRYPTED_VAULT_KEY);
|
||||
}
|
||||
|
||||
/** 用给定密钥加密 environments 并写回 local */
|
||||
async function persistEncrypted(environments, key) {
|
||||
async function persistEncrypted(environments: Environment[], key: CryptoKey): Promise<void> {
|
||||
const cipher = await encryptJson(environments, key);
|
||||
await getStorage("local").set({ [ENCRYPTED_VAULT_KEY]: cipher });
|
||||
}
|
||||
@@ -2,7 +2,16 @@
|
||||
* 环境数据层:环境/配置的读写,数据存于 chrome.storage.session(解锁后明文)。
|
||||
* 首次建口令由 crypto-store.setupVault 完成;加密回写由 background 监听完成。
|
||||
*/
|
||||
(function (root, factory) {
|
||||
|
||||
|
||||
interface EnvStoreDeps {
|
||||
getStorage: () => any;
|
||||
getLocalStorage: () => any;
|
||||
}
|
||||
|
||||
type ConfigWithEnv = DomainConfig & { envId: string; envName: string };
|
||||
|
||||
(function (root: any, factory: (deps: EnvStoreDeps) => any): void {
|
||||
if (typeof module === "object" && module.exports) {
|
||||
module.exports = factory({
|
||||
getStorage: () => (typeof chrome !== "undefined" ? chrome.storage.session : null),
|
||||
@@ -15,50 +24,50 @@
|
||||
});
|
||||
root.EnvStore = api;
|
||||
}
|
||||
})(typeof globalThis !== "undefined" ? globalThis : this, function ({ getStorage, getLocalStorage }) {
|
||||
})(typeof globalThis !== "undefined" ? globalThis : this, function ({ getStorage, getLocalStorage }: EnvStoreDeps) {
|
||||
const ENV_KEY = "environments";
|
||||
const ACTIVE_ENV_KEY = "activeEnvId";
|
||||
|
||||
function genId() {
|
||||
function genId(): string {
|
||||
return "env_" + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
|
||||
}
|
||||
|
||||
function genCfgId() {
|
||||
function genCfgId(): string {
|
||||
return Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
|
||||
}
|
||||
|
||||
function storage() {
|
||||
function storage(): any {
|
||||
const s = getStorage();
|
||||
if (!s) throw new Error("chrome.storage.session 不可用");
|
||||
return s;
|
||||
}
|
||||
|
||||
|
||||
async function loadEnvironments() {
|
||||
async function loadEnvironments(): Promise<{ environments: Environment[]; activeEnvId: string | null }> {
|
||||
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 };
|
||||
async function saveEnvironments(environments: Environment[], activeEnvId?: string): Promise<void> {
|
||||
const patch: Record<string, any> = { [ENV_KEY]: environments };
|
||||
if (activeEnvId !== undefined) patch[ACTIVE_ENV_KEY] = activeEnvId;
|
||||
await storage().set(patch);
|
||||
}
|
||||
|
||||
async function getActiveEnv() {
|
||||
async function getActiveEnv(): Promise<Environment | null> {
|
||||
const { environments, activeEnvId } = await loadEnvironments();
|
||||
return environments.find((e) => e.id === activeEnvId) || environments[0] || null;
|
||||
return environments.find((e: Environment) => e.id === activeEnvId) || environments[0] || null;
|
||||
}
|
||||
|
||||
async function setActiveEnv(envId) {
|
||||
async function setActiveEnv(envId: string): Promise<void> {
|
||||
await storage().set({ [ACTIVE_ENV_KEY]: envId });
|
||||
}
|
||||
|
||||
const MAX_ENVIRONMENTS = 5;
|
||||
|
||||
async function createEnvironment(name) {
|
||||
async function createEnvironment(name: string): Promise<Environment> {
|
||||
const { environments } = await loadEnvironments();
|
||||
if (environments.length >= MAX_ENVIRONMENTS) {
|
||||
throw new Error(`最多 ${MAX_ENVIRONMENTS} 个环境`);
|
||||
@@ -69,9 +78,9 @@
|
||||
return env;
|
||||
}
|
||||
|
||||
async function renameEnvironment(envId, newName) {
|
||||
async function renameEnvironment(envId: string, newName: string): Promise<Environment | undefined> {
|
||||
const { environments } = await loadEnvironments();
|
||||
const env = environments.find((e) => e.id === envId);
|
||||
const env = environments.find((e: Environment) => e.id === envId);
|
||||
if (env) {
|
||||
env.name = newName;
|
||||
await saveEnvironments(environments);
|
||||
@@ -79,16 +88,16 @@
|
||||
return env;
|
||||
}
|
||||
|
||||
async function deleteEnvironment(envId) {
|
||||
async function deleteEnvironment(envId: string): Promise<void> {
|
||||
const { environments, activeEnvId } = await loadEnvironments();
|
||||
if (environments.length <= 1) {
|
||||
throw new Error("至少保留一个环境");
|
||||
}
|
||||
const idx = environments.findIndex((e) => e.id === envId);
|
||||
const idx = environments.findIndex((e: Environment) => e.id === envId);
|
||||
if (idx === -1) return;
|
||||
environments.splice(idx, 1);
|
||||
const newActive = activeEnvId === envId ? environments[0].id : activeEnvId;
|
||||
await saveEnvironments(environments, newActive);
|
||||
await saveEnvironments(environments, newActive ?? undefined);
|
||||
// 失败计数存于 local(非敏感),删除环境时一并清理
|
||||
const local = getLocalStorage();
|
||||
if (local) {
|
||||
@@ -102,26 +111,26 @@
|
||||
}
|
||||
|
||||
|
||||
async function loadConfigs() {
|
||||
async function loadConfigs(): Promise<DomainConfig[]> {
|
||||
const env = await getActiveEnv();
|
||||
return env?.configs || [];
|
||||
}
|
||||
|
||||
|
||||
async function saveConfigs(configs) {
|
||||
async function saveConfigs(configs: DomainConfig[]): Promise<void> {
|
||||
const { environments, activeEnvId } = await loadEnvironments();
|
||||
const env = environments.find((e) => e.id === activeEnvId);
|
||||
const env = environments.find((e: Environment) => e.id === activeEnvId);
|
||||
if (!env) throw new Error("未找到当前环境");
|
||||
env.configs = configs;
|
||||
await saveEnvironments(environments);
|
||||
}
|
||||
|
||||
|
||||
async function loadAllConfigs() {
|
||||
async function loadAllConfigs(): Promise<ConfigWithEnv[]> {
|
||||
const { environments } = await loadEnvironments();
|
||||
const all = [];
|
||||
environments.forEach((env) => {
|
||||
(env.configs || []).forEach((cfg) => {
|
||||
const all: ConfigWithEnv[] = [];
|
||||
environments.forEach((env: Environment) => {
|
||||
(env.configs || []).forEach((cfg: DomainConfig) => {
|
||||
all.push({ ...cfg, envId: env.id, envName: env.name });
|
||||
});
|
||||
});
|
||||
Vendored
+54
@@ -0,0 +1,54 @@
|
||||
import type { Environment, DomainConfig } from "./types.js";
|
||||
|
||||
export {};
|
||||
|
||||
interface CryptoStoreApi {
|
||||
VAULT_META_KEY: string;
|
||||
ENCRYPTED_VAULT_KEY: string;
|
||||
SESSION_ENV_KEY: string;
|
||||
SESSION_ACTIVE_KEY: string;
|
||||
PBKDF2_ITERATIONS: number;
|
||||
hasVault(): Promise<boolean>;
|
||||
isUnlocked(): Promise<boolean>;
|
||||
setupVault(password: string, environments: Environment[]): Promise<CryptoKey>;
|
||||
unlock(password: string): Promise<{ environments: Environment[]; key: CryptoKey }>;
|
||||
lock(): Promise<void>;
|
||||
deriveKey(password: string, salt: Uint8Array<ArrayBuffer>, iterations: number): Promise<CryptoKey>;
|
||||
encryptJson(obj: unknown, key: CryptoKey): Promise<{ iv: string; ciphertext: string }>;
|
||||
decryptJson(cipher: { iv: string; ciphertext: string }, key: CryptoKey): Promise<any>;
|
||||
getVaultMeta(): Promise<any>;
|
||||
getEncryptedVault(): Promise<any>;
|
||||
persistEncrypted(environments: Environment[], key: CryptoKey): Promise<void>;
|
||||
b64encode(buf: ArrayBuffer | Uint8Array<ArrayBuffer>): string;
|
||||
b64decode(str: string): Uint8Array<ArrayBuffer>;
|
||||
randomBytes(n: number): Uint8Array<ArrayBuffer>;
|
||||
}
|
||||
|
||||
interface EnvStoreApi {
|
||||
ENV_KEY: string;
|
||||
ACTIVE_ENV_KEY: string;
|
||||
MAX_ENVIRONMENTS: number;
|
||||
loadEnvironments(): Promise<{ environments: Environment[]; activeEnvId: string | null }>;
|
||||
saveEnvironments(environments: Environment[], activeEnvId?: string): Promise<void>;
|
||||
getActiveEnv(): Promise<Environment | null>;
|
||||
setActiveEnv(envId: string): Promise<void>;
|
||||
createEnvironment(name: string): Promise<Environment>;
|
||||
renameEnvironment(envId: string, newName: string): Promise<Environment | undefined>;
|
||||
deleteEnvironment(envId: string): Promise<void>;
|
||||
loadConfigs(): Promise<DomainConfig[]>;
|
||||
saveConfigs(configs: DomainConfig[]): Promise<void>;
|
||||
loadAllConfigs(): Promise<(DomainConfig & { envId: string; envName: string })[]>;
|
||||
genId(): string;
|
||||
genCfgId(): string;
|
||||
}
|
||||
|
||||
declare global {
|
||||
type Environment = import("./types.js").Environment;
|
||||
type DomainConfig = import("./types.js").DomainConfig;
|
||||
var importScripts: (...urls: string[]) => void;
|
||||
var isDomainMatch: (url: string | null | undefined, pattern: string) => boolean;
|
||||
var escapeHtml: (str: string | null | undefined) => string;
|
||||
var EnvStore: EnvStoreApi;
|
||||
var CryptoStore: CryptoStoreApi;
|
||||
var module: { exports: any } | undefined;
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
export function t(key: string, ...args: string[]): string {
|
||||
const msg = chrome.i18n.getMessage(key, args);
|
||||
return msg || key;
|
||||
}
|
||||
|
||||
export function applyI18n(root: ParentNode = document): void {
|
||||
root.querySelectorAll<HTMLElement>("[data-i18n]").forEach((el) => {
|
||||
const key = el.dataset.i18n || "";
|
||||
if (el.children.length === 0) {
|
||||
const msg = chrome.i18n.getMessage(key);
|
||||
if (msg) el.textContent = msg;
|
||||
} else {
|
||||
const msg = chrome.i18n.getMessage(key);
|
||||
if (msg) {
|
||||
const firstText = el.firstChild;
|
||||
if (firstText && firstText.nodeType === Node.TEXT_NODE) {
|
||||
firstText.nodeValue = msg;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
root.querySelectorAll<HTMLInputElement>("[data-i18n-placeholder]").forEach((el) => {
|
||||
const key = el.dataset.i18nPlaceholder || "";
|
||||
const msg = chrome.i18n.getMessage(key);
|
||||
if (msg) el.placeholder = msg;
|
||||
});
|
||||
root.querySelectorAll<HTMLElement>("[data-i18n-title]").forEach((el) => {
|
||||
const key = el.dataset.i18nTitle || "";
|
||||
const msg = chrome.i18n.getMessage(key);
|
||||
if (msg) el.title = msg;
|
||||
});
|
||||
root.querySelectorAll<HTMLElement>("[data-i18n-aria]").forEach((el) => {
|
||||
const key = el.dataset.i18nAria || "";
|
||||
const msg = chrome.i18n.getMessage(key);
|
||||
if (msg) el.setAttribute("aria-label", msg);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { t } from "./i18n.js";
|
||||
|
||||
export function sendMessage(message: Record<string, unknown>, retries = 3): Promise<unknown> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let attempt = 0;
|
||||
const trySend = () => {
|
||||
attempt++;
|
||||
if (!chrome.runtime?.id) {
|
||||
location.reload();
|
||||
reject(new Error(t("toastContextReload")));
|
||||
return;
|
||||
}
|
||||
chrome.runtime.sendMessage(message, (resp) => {
|
||||
const err = chrome.runtime.lastError;
|
||||
if (err) {
|
||||
if (attempt < retries && /Receiving end does not exist/i.test(err.message ?? "")) {
|
||||
setTimeout(trySend, 200 * attempt);
|
||||
return;
|
||||
}
|
||||
reject(new Error(err.message ?? ""));
|
||||
return;
|
||||
}
|
||||
resolve(resp);
|
||||
});
|
||||
};
|
||||
trySend();
|
||||
});
|
||||
}
|
||||
+74
-267
@@ -1,45 +1,12 @@
|
||||
import { t, applyI18n } from "./i18n.js";
|
||||
import { encryptConfigs, decryptConfigs } from "./crypto-file.js";
|
||||
import { estimateStrength, formatTime } from "./strength.js";
|
||||
import { sendMessage } from "./messaging.js";
|
||||
import { openConfigUrl, fetchTitleByDomain, closeAllMenus } from "./ui-utils.js";
|
||||
import type { DomainConfig, Environment, PwdModalMode, LockMode } from "./types.js";
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
// i18n
|
||||
const t = (key, ...args) => {
|
||||
const msg = chrome.i18n.getMessage(key, args);
|
||||
return msg || key;
|
||||
};
|
||||
|
||||
// 遍历 data-i18n* 属性应用翻译;含嵌套标签的元素只替换首个文本节点
|
||||
function applyI18n(root = document) {
|
||||
root.querySelectorAll("[data-i18n]").forEach((el) => {
|
||||
const key = el.dataset.i18n;
|
||||
if (el.children.length === 0) {
|
||||
const msg = chrome.i18n.getMessage(key);
|
||||
if (msg) el.textContent = msg;
|
||||
} else {
|
||||
const msg = chrome.i18n.getMessage(key);
|
||||
if (msg) {
|
||||
const firstText = el.firstChild;
|
||||
if (firstText && firstText.nodeType === Node.TEXT_NODE) {
|
||||
firstText.nodeValue = msg;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
root.querySelectorAll("[data-i18n-placeholder]").forEach((el) => {
|
||||
const key = el.dataset.i18nPlaceholder;
|
||||
const msg = chrome.i18n.getMessage(key);
|
||||
if (msg) el.placeholder = msg;
|
||||
});
|
||||
root.querySelectorAll("[data-i18n-title]").forEach((el) => {
|
||||
const key = el.dataset.i18nTitle;
|
||||
const msg = chrome.i18n.getMessage(key);
|
||||
if (msg) el.title = msg;
|
||||
});
|
||||
root.querySelectorAll("[data-i18n-aria]").forEach((el) => {
|
||||
const key = el.dataset.i18nAria;
|
||||
const msg = chrome.i18n.getMessage(key);
|
||||
if (msg) el.setAttribute("aria-label", msg);
|
||||
});
|
||||
}
|
||||
const $ = (id: string): any => document.getElementById(id);
|
||||
|
||||
applyI18n();
|
||||
|
||||
@@ -91,7 +58,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
const editConfigCancel = $("editConfigCancel");
|
||||
const editConfigSave = $("editConfigSave");
|
||||
const editFillDomainBtn = $("editFillDomain");
|
||||
let editModalEditingId = null;
|
||||
let editModalEditingId: string | null = null;
|
||||
const addEntryBtn = $("addEntryBtn");
|
||||
const searchBtn = $("searchBtn");
|
||||
const searchBox = $("searchBox");
|
||||
@@ -146,48 +113,11 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
const crackTime = $("crackTime");
|
||||
|
||||
|
||||
let modalResolve = null;
|
||||
let modalResolve: ((value: any) => void) | null = null;
|
||||
|
||||
// 密码强度估算:按 GPU 集群约 10^7 次/秒(PBKDF2-SHA256)计
|
||||
const ATTACK_RATE = 1e7;
|
||||
|
||||
function estimateStrength(pwd) {
|
||||
if (!pwd) return { entropy: 0, seconds: 0, level: "empty", label: t("strengthEmpty") };
|
||||
let pool = 0;
|
||||
if (/[a-z]/.test(pwd)) pool += 26;
|
||||
if (/[A-Z]/.test(pwd)) pool += 26;
|
||||
if (/[0-9]/.test(pwd)) pool += 10;
|
||||
if (/[^a-zA-Z0-9]/.test(pwd)) pool += 32;
|
||||
if (pool === 0) pool = 1;
|
||||
const entropy = pwd.length * Math.log2(pool);
|
||||
const combinations = Math.pow(2, entropy);
|
||||
// 平均破解时间约为搜索空间的一半
|
||||
const seconds = combinations / 2 / ATTACK_RATE;
|
||||
let level, label;
|
||||
if (seconds < 1) { level = "very-weak"; label = t("strengthVeryWeak"); }
|
||||
else if (seconds < 3600) { level = "weak"; label = t("strengthWeak"); }
|
||||
else if (seconds < 86400 * 30) { level = "medium"; label = t("strengthMedium"); }
|
||||
else if (seconds < 86400 * 365 * 1000) { level = "strong"; label = t("strengthStrong"); }
|
||||
else { level = "very-strong"; label = t("strengthVeryStrong"); }
|
||||
return { entropy, seconds, level, label };
|
||||
}
|
||||
|
||||
function formatTime(seconds) {
|
||||
if (seconds < 1) return t("crackInstant");
|
||||
if (seconds < 60) return t("crackSeconds", String(Math.round(seconds)));
|
||||
if (seconds < 3600) return t("crackMinutes", String(Math.round(seconds / 60)));
|
||||
if (seconds < 86400) return t("crackHours", String(Math.round(seconds / 3600)));
|
||||
if (seconds < 86400 * 30) return t("crackDays", String(Math.round(seconds / 86400)));
|
||||
if (seconds < 86400 * 365) return t("crackMonths", String(Math.round(seconds / 86400 / 30)));
|
||||
const years = seconds / 86400 / 365;
|
||||
if (years < 1000) return t("crackYears", String(Math.round(years)));
|
||||
if (years < 1e6) return t("crackThousandYears", String(Math.round(years / 1000)));
|
||||
if (years < 1e9) return t("crackMillionYears", String(Math.round(years / 1e6)));
|
||||
if (years < 1e12) return t("crackBillionYears", String(Math.round(years / 1e9)));
|
||||
return t("crackTrillionYears", (years / 1e12).toExponential(2));
|
||||
}
|
||||
|
||||
function updateStrength(pwd) {
|
||||
function updateStrength(pwd: string): void {
|
||||
const { entropy, seconds, level, label } = estimateStrength(pwd);
|
||||
// 5 档进度条宽度,对数缩放避免弱口令条太短看不见
|
||||
const widthMap = {
|
||||
@@ -208,81 +138,10 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
if (strengthBlock.style.display !== "none") updateStrength(modalPwd.value);
|
||||
});
|
||||
|
||||
// Web Crypto 工具:AES-GCM + PBKDF2
|
||||
const enc = new TextEncoder();
|
||||
const dec = new TextDecoder();
|
||||
|
||||
function bufToBase64(buf) {
|
||||
const bytes = new Uint8Array(buf);
|
||||
let bin = "";
|
||||
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
|
||||
return btoa(bin);
|
||||
}
|
||||
|
||||
function base64ToBuf(b64) {
|
||||
const bin = atob(b64);
|
||||
const bytes = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
||||
return bytes.buffer;
|
||||
}
|
||||
|
||||
async function deriveKey(password, salt, iterations) {
|
||||
const keyMaterial = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
enc.encode(password),
|
||||
{ name: "PBKDF2" },
|
||||
false,
|
||||
["deriveKey"]
|
||||
);
|
||||
return crypto.subtle.deriveKey(
|
||||
{ name: "PBKDF2", salt, iterations, hash: "SHA-256" },
|
||||
keyMaterial,
|
||||
{ name: "AES-GCM", length: 256 },
|
||||
false,
|
||||
["encrypt", "decrypt"]
|
||||
);
|
||||
}
|
||||
|
||||
async function encryptConfigs(configs, password) {
|
||||
const salt = crypto.getRandomValues(new Uint8Array(16));
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const iterations = 100000;
|
||||
const key = await deriveKey(password, salt, iterations);
|
||||
const plaintext = enc.encode(JSON.stringify(configs));
|
||||
const ciphertext = await crypto.subtle.encrypt(
|
||||
{ name: "AES-GCM", iv },
|
||||
key,
|
||||
plaintext
|
||||
);
|
||||
return {
|
||||
algorithm: "AES-GCM",
|
||||
iv: bufToBase64(iv),
|
||||
ciphertext: bufToBase64(ciphertext),
|
||||
kdf: {
|
||||
algorithm: "PBKDF2",
|
||||
iterations,
|
||||
hash: "SHA-256",
|
||||
salt: bufToBase64(salt)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function decryptConfigs(cipher, password) {
|
||||
const salt = new Uint8Array(base64ToBuf(cipher.kdf.salt));
|
||||
const iv = new Uint8Array(base64ToBuf(cipher.iv));
|
||||
const iterations = cipher.kdf.iterations || 100000;
|
||||
const key = await deriveKey(password, salt, iterations);
|
||||
const plainBuf = await crypto.subtle.decrypt(
|
||||
{ name: "AES-GCM", iv },
|
||||
key,
|
||||
base64ToBuf(cipher.ciphertext)
|
||||
);
|
||||
return JSON.parse(dec.decode(plainBuf));
|
||||
}
|
||||
|
||||
// 文件密码 modal:export 显示加密勾选 + 两次输入,import 只输入一次
|
||||
function openPwdModal(mode) {
|
||||
return new Promise((resolve) => {
|
||||
function openPwdModal(mode: PwdModalMode): Promise<any> {
|
||||
return new Promise<any>((resolve) => {
|
||||
modalResolve = resolve;
|
||||
if (mode === "export") {
|
||||
modalTitle.textContent = t("pwdModalExportTitle");
|
||||
@@ -363,7 +222,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
closePwdModal();
|
||||
});
|
||||
|
||||
modalPwd.addEventListener("keydown", (e) => {
|
||||
modalPwd.addEventListener("keydown", (e: KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
if (confirmGroup.style.display === "none") {
|
||||
@@ -376,14 +235,14 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
}
|
||||
});
|
||||
|
||||
modalPwdConfirm.addEventListener("keydown", (e) => {
|
||||
modalPwdConfirm.addEventListener("keydown", (e: KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
modalConfirm.click();
|
||||
}
|
||||
});
|
||||
|
||||
function showToast(msg) {
|
||||
function showToast(msg: string): void {
|
||||
toast.textContent = msg;
|
||||
toast.classList.add("show");
|
||||
setTimeout(() => toast.classList.remove("show"), 2000);
|
||||
@@ -391,7 +250,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
|
||||
document.querySelectorAll(".toggle-pwd").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const target = $(btn.dataset.target);
|
||||
const target = $((btn as HTMLElement).dataset.target || "");
|
||||
if (target.type === "password") {
|
||||
target.type = "text";
|
||||
btn.classList.remove("eye-open");
|
||||
@@ -475,10 +334,10 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
}
|
||||
|
||||
// 环境栏
|
||||
let envModalResolve = null;
|
||||
let envModalResolve: ((value: any) => void) | null = null;
|
||||
// mode: "create" | "rename"
|
||||
function openEnvModal(mode, currentName = "") {
|
||||
return new Promise((resolve) => {
|
||||
function openEnvModal(mode: "create" | "rename", currentName = ""): Promise<string | null> {
|
||||
return new Promise<string | null>((resolve) => {
|
||||
envModalResolve = resolve;
|
||||
if (mode === "create") {
|
||||
envModalTitle.textContent = t("envCreateTitle");
|
||||
@@ -506,9 +365,9 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
envModalResolve = null;
|
||||
}
|
||||
|
||||
presetEnvTags.querySelectorAll(".preset-tag").forEach((tag) => {
|
||||
presetEnvTags.querySelectorAll(".preset-tag").forEach((tag: HTMLElement) => {
|
||||
tag.addEventListener("click", () => {
|
||||
envNameInput.value = tag.dataset.name;
|
||||
envNameInput.value = tag.dataset.name || "";
|
||||
envNameInput.focus();
|
||||
});
|
||||
});
|
||||
@@ -537,7 +396,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
closeEnvModal();
|
||||
});
|
||||
|
||||
envNameInput.addEventListener("keydown", (e) => {
|
||||
envNameInput.addEventListener("keydown", (e: KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
envModalConfirm.click();
|
||||
@@ -610,7 +469,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
await renderEnvSelect();
|
||||
showToast(t("toastRenamed"));
|
||||
} catch (err) {
|
||||
showToast(err.message || t("toastRenameFail"));
|
||||
showToast((err as Error).message || t("toastRenameFail"));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -636,7 +495,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
await renderConfigList();
|
||||
showToast(t("toastEnvDeleted"));
|
||||
} catch (err) {
|
||||
showToast(err.message || t("toastDeleteFail"));
|
||||
showToast((err as Error).message || t("toastDeleteFail"));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -660,38 +519,16 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
await renderEnvSelect();
|
||||
resetForm();
|
||||
await renderConfigList();
|
||||
showToast(t("toastEnvCreated", name));
|
||||
showToast(t("toastEnvCreated", name as string));
|
||||
} catch (err) {
|
||||
showToast(err.message || t("toastCreateFail"));
|
||||
showToast((err as Error).message || t("toastCreateFail"));
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
function openConfigUrl(domain) {
|
||||
if (!domain) return;
|
||||
const url = /^https?:\/\//i.test(domain) ? domain : "http://" + domain;
|
||||
window.open(url, "_blank", "noopener");
|
||||
}
|
||||
|
||||
async function fetchTitleByDomain(domain) {
|
||||
try {
|
||||
const base = /^https?:\/\//i.test(domain) ? domain : "*://" + domain;
|
||||
const patterns = [base];
|
||||
if (!base.endsWith("*")) patterns.push(base + "/*");
|
||||
const tabs = await chrome.tabs.query({ url: patterns });
|
||||
for (const tab of tabs) {
|
||||
if (tab.title) return tab.title;
|
||||
}
|
||||
} catch (e) {}
|
||||
return null;
|
||||
}
|
||||
|
||||
function closeAllMenus() {
|
||||
document.querySelectorAll(".config-menu.open").forEach((m) => m.classList.remove("open"));
|
||||
}
|
||||
|
||||
document.addEventListener("click", (e) => {
|
||||
if (!e.target.closest(".config-actions")) closeAllMenus();
|
||||
if (!(e.target as HTMLElement).closest(".config-actions")) closeAllMenus();
|
||||
});
|
||||
|
||||
|
||||
@@ -749,26 +586,26 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
return;
|
||||
}
|
||||
|
||||
let draggedConfigId = null;
|
||||
let draggedConfigId: string | null = null;
|
||||
|
||||
configs.forEach((cfg) => {
|
||||
const item = document.createElement("div");
|
||||
item.className = "config-item" + (cfg.enabled === false ? " config-disabled" : "");
|
||||
if (!kw) {
|
||||
item.draggable = true;
|
||||
item.addEventListener("dragstart", (e) => {
|
||||
item.addEventListener("dragstart", (e: DragEvent) => {
|
||||
draggedConfigId = cfg.id;
|
||||
item.classList.add("dragging");
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
if (e.dataTransfer) e.dataTransfer.effectAllowed = "move";
|
||||
});
|
||||
item.addEventListener("dragend", () => {
|
||||
draggedConfigId = null;
|
||||
item.classList.remove("dragging");
|
||||
configList.querySelectorAll(".config-item").forEach((el) => el.classList.remove("drag-over"));
|
||||
configList.querySelectorAll(".config-item").forEach((el: Element) => el.classList.remove("drag-over"));
|
||||
});
|
||||
item.addEventListener("dragover", (e) => {
|
||||
item.addEventListener("dragover", (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
|
||||
if (cfg.id !== draggedConfigId) {
|
||||
item.classList.add("drag-over");
|
||||
}
|
||||
@@ -915,7 +752,8 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const config = {
|
||||
const config: DomainConfig = {
|
||||
id: genCfgId(),
|
||||
domain,
|
||||
alias: aliasInput.value.trim() || await fetchTitleByDomain(domain) || null,
|
||||
username,
|
||||
@@ -930,7 +768,6 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
|
||||
const configs = await loadConfigs();
|
||||
|
||||
config.id = Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
|
||||
configs.push(config);
|
||||
showToast(t("toastAdded"));
|
||||
|
||||
@@ -952,7 +789,8 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const config = {
|
||||
const config: DomainConfig = {
|
||||
id: editModalEditingId || genCfgId(),
|
||||
domain,
|
||||
alias: editAliasInput.value.trim() || await fetchTitleByDomain(domain) || null,
|
||||
username,
|
||||
@@ -970,12 +808,10 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
if (editModalEditingId) {
|
||||
const idx = configs.findIndex((c) => c.id === editModalEditingId);
|
||||
if (idx !== -1) {
|
||||
config.id = editModalEditingId;
|
||||
configs[idx] = config;
|
||||
}
|
||||
showToast(t("toastUpdated"));
|
||||
} else {
|
||||
config.id = Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
|
||||
configs.push(config);
|
||||
showToast(t("toastAdded"));
|
||||
}
|
||||
@@ -1050,7 +886,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
editConfigCloseX.addEventListener("click", closeEditConfigModal);
|
||||
|
||||
// 点击遮罩关闭编辑弹窗
|
||||
editConfigModal.addEventListener("click", (e) => {
|
||||
editConfigModal.addEventListener("click", (e: MouseEvent) => {
|
||||
if (e.target === editConfigModal) {
|
||||
editConfigModal.classList.remove("show");
|
||||
editModalEditingId = null;
|
||||
@@ -1059,7 +895,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
|
||||
fillCurrentBtn.addEventListener("click", async () => {
|
||||
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
if (!tab) {
|
||||
if (!tab || tab.id === undefined) {
|
||||
showToast(t("toastNoTab"));
|
||||
return;
|
||||
}
|
||||
@@ -1075,11 +911,11 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
|
||||
if (matched.length === 1) {
|
||||
try {
|
||||
const resp = await chrome.tabs.sendMessage(tab.id, {
|
||||
const resp: any = await chrome.tabs.sendMessage(tab.id, {
|
||||
action: "manualFill",
|
||||
config: matched[0],
|
||||
});
|
||||
if (resp && resp.success) {
|
||||
if (resp && (resp as any).success) {
|
||||
showToast(t("toastFilled"));
|
||||
} else if (resp && resp.diag) {
|
||||
const d = resp.diag;
|
||||
@@ -1089,10 +925,10 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
}
|
||||
} catch (err) {
|
||||
// content script 未注入:常见于安装扩展前已打开的页面
|
||||
if (/Could not establish connection|Receiving end does not exist/i.test(err.message)) {
|
||||
if (/Could not establish connection|Receiving end does not exist/i.test((err as Error).message)) {
|
||||
showToast(t("toastScriptNotInjected"));
|
||||
} else {
|
||||
showToast(t("toastSendFail", err.message || String(err)));
|
||||
showToast(t("toastSendFail", (err as Error).message || String(err)));
|
||||
}
|
||||
}
|
||||
return;
|
||||
@@ -1107,10 +943,10 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
});
|
||||
showToast(t("toastMultiMatch", String(matched.length)));
|
||||
} catch (err) {
|
||||
if (/Could not establish connection|Receiving end does not exist/i.test(err.message)) {
|
||||
if (/Could not establish connection|Receiving end does not exist/i.test((err as Error).message)) {
|
||||
showToast(t("toastScriptNotInjected"));
|
||||
} else {
|
||||
showToast(t("toastSendFail", err.message || String(err)));
|
||||
showToast(t("toastSendFail", (err as Error).message || String(err)));
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1173,8 +1009,8 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
const stamp = new Date().toISOString().replace(/[:T]/g, "").slice(0, 15);
|
||||
let payload;
|
||||
let filename;
|
||||
if (choice.encrypt) {
|
||||
const cipher = await encryptConfigs(environments, choice.password);
|
||||
if ((choice as any).encrypt) {
|
||||
const cipher = await encryptConfigs(environments, (choice as any).password);
|
||||
payload = {
|
||||
app: "AutoLoginManager",
|
||||
version: 3,
|
||||
@@ -1202,20 +1038,20 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
showToast(t("toastExported", String(environments.length), String(totalConfigs), choice.encrypt ? t("encryptedMark") : ""));
|
||||
showToast(t("toastExported", String(environments.length), String(totalConfigs), (choice as any).encrypt ? t("encryptedMark") : ""));
|
||||
});
|
||||
|
||||
// 导入配置:兼容 v1(数组) / v2(configs) / v3(environments),加密则弹口令 modal
|
||||
importBtn.addEventListener("click", () => importFile.click());
|
||||
|
||||
importFile.addEventListener("change", async (e) => {
|
||||
const file = e.target.files[0];
|
||||
importFile.addEventListener("change", async (e: Event) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
const text = await file.text();
|
||||
const data = JSON.parse(text);
|
||||
let incomingEnvironments = null;
|
||||
let incomingConfigs = null;
|
||||
let incomingEnvironments: Environment[] | null = null;
|
||||
let incomingConfigs: DomainConfig[] | null = null;
|
||||
|
||||
// 1. v1: 直接数组格式
|
||||
if (Array.isArray(data)) {
|
||||
@@ -1230,7 +1066,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const decrypted = await decryptConfigs(data.cipher, choice.password);
|
||||
const decrypted = await decryptConfigs(data.cipher, (choice as any).password);
|
||||
// 解密后可能是 v3 (environments 数组/对象) 或 v1 (configs 数组)
|
||||
if (Array.isArray(decrypted)) {
|
||||
// 区分环境数组(v3, 元素含 configs)与配置数组(v1, 元素含 domain)
|
||||
@@ -1286,7 +1122,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
continue;
|
||||
}
|
||||
existingNames.add(env.name);
|
||||
const newEnv = {
|
||||
const newEnv: Environment = {
|
||||
id: genId(),
|
||||
name: env.name,
|
||||
configs: [],
|
||||
@@ -1314,7 +1150,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
existingEnvs.push(newEnv);
|
||||
addedEnvs++;
|
||||
}
|
||||
await saveEnvironments(existingEnvs, activeEnvId);
|
||||
await saveEnvironments(existingEnvs, activeEnvId ?? undefined);
|
||||
await renderEnvSelect();
|
||||
await renderConfigList();
|
||||
showToast(t("toastImportedEnvs", String(addedEnvs), String(addedCfgs), String(skippedCfgs)));
|
||||
@@ -1326,7 +1162,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
const seenDomains = new Set(curEnv.configs.map((c) => c.domain));
|
||||
let added = 0;
|
||||
let skipped = 0;
|
||||
for (const cfg of incomingConfigs) {
|
||||
for (const cfg of incomingConfigs || []) {
|
||||
if (!cfg || !cfg.domain || !cfg.username || !cfg.password) {
|
||||
skipped++;
|
||||
continue;
|
||||
@@ -1355,14 +1191,14 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
await renderConfigList();
|
||||
showToast(t("toastImportedCfgs", String(added), String(skipped)));
|
||||
} catch (err) {
|
||||
showToast(t("toastImportFail", err.message || "parse error"));
|
||||
showToast(t("toastImportFail", (err as Error).message || "parse error"));
|
||||
} finally {
|
||||
importFile.value = "";
|
||||
}
|
||||
});
|
||||
|
||||
// 从书签导入
|
||||
function flattenBookmarks(nodes, out) {
|
||||
function flattenBookmarks(nodes: chrome.bookmarks.BookmarkTreeNode[], out: { title: string; url: string }[]): void {
|
||||
for (const node of nodes) {
|
||||
if (node.url) {
|
||||
out.push({ title: node.title || node.url, url: node.url });
|
||||
@@ -1373,7 +1209,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
}
|
||||
}
|
||||
|
||||
function urlToDomain(url) {
|
||||
function urlToDomain(url: string): string | null {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
let domain = u.host + u.pathname;
|
||||
@@ -1385,9 +1221,9 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
}
|
||||
}
|
||||
|
||||
let bookmarkItems = [];
|
||||
let bookmarkItems: { title: string; url: string }[] = [];
|
||||
|
||||
function renderBookmarkList(filter) {
|
||||
function renderBookmarkList(filter: string): void {
|
||||
const kw = (filter || "").trim().toLowerCase();
|
||||
bookmarkList.innerHTML = "";
|
||||
const items = kw
|
||||
@@ -1446,7 +1282,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
|
||||
bookmarkSelectAll.addEventListener("change", () => {
|
||||
const cbs = bookmarkList.querySelectorAll("input[type=checkbox]");
|
||||
cbs.forEach((cb) => (cb.checked = bookmarkSelectAll.checked));
|
||||
cbs.forEach((cb: HTMLInputElement) => (cb.checked = bookmarkSelectAll.checked));
|
||||
bookmarkConfirm.disabled = !bookmarkSelectAll.checked;
|
||||
});
|
||||
|
||||
@@ -1454,7 +1290,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
bookmarkModal.classList.remove("show");
|
||||
});
|
||||
|
||||
bookmarkModal.addEventListener("click", (e) => {
|
||||
bookmarkModal.addEventListener("click", (e: MouseEvent) => {
|
||||
if (e.target === bookmarkModal) bookmarkModal.classList.remove("show");
|
||||
});
|
||||
|
||||
@@ -1494,41 +1330,12 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
showToast(t("toastBookmarkImported", String(added), String(skipped)));
|
||||
});
|
||||
|
||||
// 消息发送:SW 刚休眠唤醒时首次 sendMessage 会失败,需重试
|
||||
function sendMessage(message, retries = 3) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let attempt = 0;
|
||||
const trySend = () => {
|
||||
attempt++;
|
||||
// 扩展上下文已失效(扩展被重载/更新),刷新页面恢复
|
||||
if (!chrome.runtime?.id) {
|
||||
location.reload();
|
||||
reject(new Error(t("toastContextReload")));
|
||||
return;
|
||||
}
|
||||
chrome.runtime.sendMessage(message, (resp) => {
|
||||
const err = chrome.runtime.lastError;
|
||||
if (err) {
|
||||
// 接收端不存在:SW 还没注册 listener,等 200ms 重试
|
||||
if (attempt < retries && /Receiving end does not exist/i.test(err.message)) {
|
||||
setTimeout(trySend, 200 * attempt);
|
||||
return;
|
||||
}
|
||||
reject(new Error(err.message));
|
||||
return;
|
||||
}
|
||||
resolve(resp);
|
||||
});
|
||||
};
|
||||
trySend();
|
||||
});
|
||||
}
|
||||
|
||||
// 锁定 / 解锁
|
||||
let lockMode = "setup"; // "setup" | "unlock"
|
||||
let lockAfterSetup = false;
|
||||
|
||||
function showLockScreen(mode) {
|
||||
function showLockScreen(mode: LockMode): void {
|
||||
lockMode = mode;
|
||||
lockScreen.style.display = "flex";
|
||||
mainContainer.style.display = "none";
|
||||
@@ -1560,13 +1367,13 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
try {
|
||||
const resp = await sendMessage({ action: "getVaultStatus" });
|
||||
lockBtn.hidden = false;
|
||||
if (!resp.hasVault) {
|
||||
if (!(resp as any).hasVault) {
|
||||
// 无 vault:明文模式,直接进入,不锁定
|
||||
showMainUI();
|
||||
loadDraft();
|
||||
renderEnvSelect();
|
||||
renderConfigList();
|
||||
} else if (!resp.unlocked) {
|
||||
} else if (!(resp as any).unlocked) {
|
||||
showLockScreen("unlock");
|
||||
} else {
|
||||
showMainUI();
|
||||
@@ -1617,39 +1424,39 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
renderConfigList();
|
||||
}
|
||||
} catch (err) {
|
||||
lockWarn.textContent = t("lockWarnSetupFail", err.message || String(err));
|
||||
lockWarn.textContent = t("lockWarnSetupFail", (err as Error).message || String(err));
|
||||
lockWarn.style.display = "block";
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const resp = await sendMessage({ action: "unlock", password: pwd });
|
||||
if (resp.success) {
|
||||
if ((resp as any).success) {
|
||||
showToast(t("toastUnlocked"));
|
||||
showMainUI();
|
||||
loadDraft();
|
||||
renderEnvSelect();
|
||||
renderConfigList();
|
||||
} else {
|
||||
lockWarn.textContent = resp.error || t("lockWarnUnlockFail", "");
|
||||
lockWarn.textContent = (resp as any).error || t("lockWarnUnlockFail", "");
|
||||
lockWarn.style.display = "block";
|
||||
}
|
||||
} catch (err) {
|
||||
lockWarn.textContent = t("lockWarnUnlockFail", err.message || String(err));
|
||||
lockWarn.textContent = t("lockWarnUnlockFail", (err as Error).message || String(err));
|
||||
lockWarn.style.display = "block";
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
lockPassword.addEventListener("keydown", (e) => {
|
||||
lockPassword.addEventListener("keydown", (e: KeyboardEvent) => {
|
||||
if (e.key === "Enter") lockSubmitBtn.click();
|
||||
});
|
||||
lockPasswordConfirm.addEventListener("keydown", (e) => {
|
||||
lockPasswordConfirm.addEventListener("keydown", (e: KeyboardEvent) => {
|
||||
if (e.key === "Enter") lockSubmitBtn.click();
|
||||
});
|
||||
|
||||
lockBtn.addEventListener("click", async () => {
|
||||
const resp = await sendMessage({ action: "getVaultStatus" }).catch(() => null);
|
||||
if (resp && !resp.hasVault) {
|
||||
if (resp && !(resp as any).hasVault) {
|
||||
// 明文模式:先设密码再锁定
|
||||
lockAfterSetup = true;
|
||||
showLockScreen("setup");
|
||||
@@ -0,0 +1,40 @@
|
||||
import { t } from "./i18n.js";
|
||||
import type { StrengthResult } from "./types.js";
|
||||
|
||||
const ATTACK_RATE = 1e7;
|
||||
|
||||
export function estimateStrength(pwd: string): StrengthResult {
|
||||
if (!pwd) return { entropy: 0, seconds: 0, level: "empty", label: t("strengthEmpty") };
|
||||
let pool = 0;
|
||||
if (/[a-z]/.test(pwd)) pool += 26;
|
||||
if (/[A-Z]/.test(pwd)) pool += 26;
|
||||
if (/[0-9]/.test(pwd)) pool += 10;
|
||||
if (/[^a-zA-Z0-9]/.test(pwd)) pool += 32;
|
||||
if (pool === 0) pool = 1;
|
||||
const entropy = pwd.length * Math.log2(pool);
|
||||
const combinations = Math.pow(2, entropy);
|
||||
const seconds = combinations / 2 / ATTACK_RATE;
|
||||
let level: StrengthResult["level"];
|
||||
let label: string;
|
||||
if (seconds < 1) { level = "very-weak"; label = t("strengthVeryWeak"); }
|
||||
else if (seconds < 3600) { level = "weak"; label = t("strengthWeak"); }
|
||||
else if (seconds < 86400 * 30) { level = "medium"; label = t("strengthMedium"); }
|
||||
else if (seconds < 86400 * 365 * 1000) { level = "strong"; label = t("strengthStrong"); }
|
||||
else { level = "very-strong"; label = t("strengthVeryStrong"); }
|
||||
return { entropy, seconds, level, label };
|
||||
}
|
||||
|
||||
export function formatTime(seconds: number): string {
|
||||
if (seconds < 1) return t("crackInstant");
|
||||
if (seconds < 60) return t("crackSeconds", String(Math.round(seconds)));
|
||||
if (seconds < 3600) return t("crackMinutes", String(Math.round(seconds / 60)));
|
||||
if (seconds < 86400) return t("crackHours", String(Math.round(seconds / 3600)));
|
||||
if (seconds < 86400 * 30) return t("crackDays", String(Math.round(seconds / 86400)));
|
||||
if (seconds < 86400 * 365) return t("crackMonths", String(Math.round(seconds / 86400 / 30)));
|
||||
const years = seconds / 86400 / 365;
|
||||
if (years < 1000) return t("crackYears", String(Math.round(years)));
|
||||
if (years < 1e6) return t("crackThousandYears", String(Math.round(years / 1000)));
|
||||
if (years < 1e9) return t("crackMillionYears", String(Math.round(years / 1e6)));
|
||||
if (years < 1e12) return t("crackBillionYears", String(Math.round(years / 1e9)));
|
||||
return t("crackTrillionYears", (years / 1e12).toExponential(2));
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/** 域名配置 */
|
||||
export interface DomainConfig {
|
||||
id: string;
|
||||
domain: string;
|
||||
alias: string | null;
|
||||
username: string;
|
||||
password: string;
|
||||
autoSubmit: boolean;
|
||||
usernameSelector: string | null;
|
||||
passwordSelector: string | null;
|
||||
submitSelector: string | null;
|
||||
enabled: boolean;
|
||||
delayMs: number | null;
|
||||
}
|
||||
|
||||
/** 环境 */
|
||||
export interface Environment {
|
||||
id: string;
|
||||
name: string;
|
||||
configs: DomainConfig[];
|
||||
}
|
||||
|
||||
/** 加密导出结构 */
|
||||
export interface CipherPayload {
|
||||
algorithm: string;
|
||||
iv: string;
|
||||
ciphertext: string;
|
||||
kdf: {
|
||||
algorithm: string;
|
||||
iterations: number;
|
||||
hash: string;
|
||||
salt: string;
|
||||
};
|
||||
}
|
||||
|
||||
/** 密码强度估算结果 */
|
||||
export type StrengthLevel = "empty" | "very-weak" | "weak" | "medium" | "strong" | "very-strong";
|
||||
|
||||
export interface StrengthResult {
|
||||
entropy: number;
|
||||
seconds: number;
|
||||
level: StrengthLevel;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/** 锁定状态 */
|
||||
export type LockMode = "setup" | "unlock";
|
||||
|
||||
/** 文件密码 modal 模式 */
|
||||
export type PwdModalMode = "export" | "import";
|
||||
|
||||
/** 文件密码 modal 返回值 */
|
||||
export interface PwdModalResult {
|
||||
encrypt: boolean;
|
||||
password: string;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
export function openConfigUrl(domain: string): void {
|
||||
if (!domain) return;
|
||||
const url = /^https?:\/\//i.test(domain) ? domain : "http://" + domain;
|
||||
window.open(url, "_blank", "noopener");
|
||||
}
|
||||
|
||||
export async function fetchTitleByDomain(domain: string): Promise<string | null> {
|
||||
try {
|
||||
const base = /^https?:\/\//i.test(domain) ? domain : "*://" + domain;
|
||||
const patterns = [base];
|
||||
if (!base.endsWith("*")) patterns.push(base + "/*");
|
||||
const tabs = await chrome.tabs.query({ url: patterns });
|
||||
for (const tab of tabs) {
|
||||
if (tab.title) return tab.title;
|
||||
}
|
||||
} catch (e) {}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function closeAllMenus() {
|
||||
document.querySelectorAll(".config-menu.open").forEach((m) => m.classList.remove("open"));
|
||||
}
|
||||
@@ -15,7 +15,7 @@
|
||||
* 纯 hostname:example.com / *.example.com
|
||||
* hostname+path:example.com/login/*,路径部分支持 * 通配
|
||||
*/
|
||||
function isDomainMatch(url, pattern) {
|
||||
function isDomainMatch(url: string | null | undefined, pattern: string | null | undefined): boolean {
|
||||
if (!url || !pattern) return false;
|
||||
try {
|
||||
const u = new URL(url);
|
||||
@@ -38,7 +38,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
function matchHost(hostname, hostPart) {
|
||||
function matchHost(hostname: string, hostPart: string): boolean {
|
||||
if (hostPart.startsWith("*.")) {
|
||||
const suffix = hostPart.slice(2);
|
||||
return hostname === suffix || hostname.endsWith("." + suffix);
|
||||
@@ -50,7 +50,7 @@
|
||||
return hostname === hostPart;
|
||||
}
|
||||
|
||||
function matchPath(pathname, pathPart) {
|
||||
function matchPath(pathname: string, pathPart: string): boolean {
|
||||
const pagePath = pathname.startsWith("/") ? pathname.slice(1) : pathname;
|
||||
if (!pathPart) return true;
|
||||
if (pathPart === "*") return true;
|
||||
@@ -66,7 +66,7 @@
|
||||
}
|
||||
|
||||
/** HTML 转义,防止配置中的特殊字符破坏页面 */
|
||||
function escapeHtml(str) {
|
||||
function escapeHtml(str: string | null | undefined): string {
|
||||
if (str == null) return "";
|
||||
return String(str)
|
||||
.replace(/&/g, "&")
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"types": ["chrome"],
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"downlevelIteration": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"sourceMap": false,
|
||||
"declaration": false,
|
||||
"removeComments": false
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist", "tests"]
|
||||
}
|
||||
Reference in New Issue
Block a user