Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

222 changes: 213 additions & 9 deletions public/scripts/service-worker.js
Original file line number Diff line number Diff line change
@@ -1,19 +1,223 @@
function setBadge() {
chrome.storage.local.get(["totalActiveRules"], (result) => {
if(result.totalActiveRules) {
const total = parseInt(result.totalActiveRules);
chrome.action.setBadgeBackgroundColor({ color: "blue" })
chrome.action.setBadgeText({ text: total > 0 ? `${total}`: '' });
/**
* ModBox service worker
* 负责扩展角标:显示"当前活动标签页命中的规则数量",0 命中时隐藏角标。
* 角标写入权收敛于此处,popup(App.vue)只负责写 rules 数据。
*/

/**
* Parse a domain list string (comma/newline separated) into a cleaned, deduped array.
* 与 src/utils.ts 的 parseDomains 语义保持一致。
*/
function parseDomains(domains) {
if (!domains) return [];
const parts = Array.isArray(domains)
? domains
: String(domains).split(/[,\n]/);
const cleaned = parts
.map((d) => String(d || "").trim().replace(/^https?:\/\//i, ""))
.filter((d) => d !== "");
return [...new Set(cleaned)];
}

/**
* Host suffix match,对齐 DNR requestDomains 语义:
* host 等于域名本身,或以 "." + 域名 结尾(子域);比较前忽略端口、统一小写。
*/
function hostMatchesDomain(host, domain) {
if (!host || !domain) return false;
const h = host.toLowerCase();
const d = domain.toLowerCase();
if (!d) return false;
return h === d || h.endsWith("." + d);
}

/**
* Convert a DNR urlFilter into a RegExp.
* 对齐 Chrome urlFilter 语法:
* - "|" 锚定开头/结尾
* - "||" 锚定"协议或子域边界之后"(任意 scheme 下匹配 host 或其子域路径起点)
* - "*" 通配任意字符
* - 其余字符按字面匹配;URL 整体大小写不敏感(Chrome 实际按 scheme/host 不敏感、path 敏感,
* 此处取宽松策略:整串不敏感)
*/
function urlFilterToRegExp(urlFilter) {
let pattern = "";
let i = 0;
while (i < urlFilter.length) {
const ch = urlFilter[i];
if (ch === "*") {
pattern += ".*";
i++;
} else if (ch === "|") {
if (urlFilter[i + 1] === "|") {
// ||example.com 匹配 "://example.com"、"://sub.example.com" 等
pattern += "(?:[^:]*:\\/\\/|\\.)";
i += 2;
} else {
pattern += "^";
i++;
}
} else {
pattern += ch.replace(/[.+?^${}()[\]\\]/g, "\\$&");
i++;
}
}
return new RegExp(pattern, "i");
}

const urlFilterRegExpCache = new Map();

function getCachedUrlFilterRegExp(urlFilter) {
let re = urlFilterRegExpCache.get(urlFilter);
if (!re) {
re = urlFilterToRegExp(urlFilter);
if (urlFilterRegExpCache.size > 500) urlFilterRegExpCache.clear();
urlFilterRegExpCache.set(urlFilter, re);
}
return re;
}

/**
* 判断单条规则的 condition 是否命中给定页面。
* pageUrl 为顶层文档 URL;语义近似复刻 buildCondition(src/utils.ts)生成的 DNR 条件:
* - urlFilter 与 requestDomains 是 AND 关系
* - 两者皆空 = 匹配一切
* - document:true 的 block 规则只保留域名条件(urlFilter 被 buildCondition 丢弃),此处同样处理
*/
function conditionMatchesPage(condition, fallbackDomains, pageUrl, pageHost) {
if (!condition) return false;

// document:true 的 block 规则在 generateRules 中会丢掉 urlFilter,仅保留域名维度
const urlFilter =
condition.document === true ? "" : String(condition.urlFilter || "").trim();
const domainsRaw =
condition.requestDomains && String(condition.requestDomains).trim() !== ""
? condition.requestDomains
: fallbackDomains;
const domains = parseDomains(domainsRaw);

const hasUrlFilter = urlFilter !== "";
const hasDomains = domains.length > 0;

if (!hasUrlFilter && !hasDomains) return true; // 空条件 = 匹配一切

if (hasDomains && !domains.some((d) => hostMatchesDomain(pageHost, d))) {
return false;
}

if (hasUrlFilter && !getCachedUrlFilterRegExp(urlFilter).test(pageUrl)) {
return false;
}

return true;
}

/**
* 从 storage 里的 rules JSON 统计当前页面命中的启用规则数。
* 遍历逻辑与 generateRules 一致:data.active → folder.active → tab.active → 各类规则 active。
*/
function countMatchedRules(data, pageUrl, pageHost) {
let count = 0;

if (!data?.active || !Array.isArray(data.folders)) return 0;

for (const folder of data.folders) {
if (!folder?.active || !Array.isArray(folder.tabs)) continue;
for (const tab of folder.tabs) {
if (!tab?.active) continue;
const fallbackDomains = tab.requestDomains;

const ruleGroups = [
tab.requestHeaders,
tab.responseHeaders,
tab.redirectRequests,
tab.blockedRequests,
];
for (const group of ruleGroups) {
if (!Array.isArray(group)) continue;
for (const rule of group) {
if (!rule?.active) continue;
if (
conditionMatchesPage(rule.condition, fallbackDomains, pageUrl, pageHost)
) {
count++;
}
}
}
}
}
return count;
}

/** 更新角标为当前活动标签页的命中数;拿不到 URL 或 0 命中则隐藏角标。 */
function updateBadge() {
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
const tab = tabs && tabs[0];
const url = tab?.url;
chrome.action.setBadgeBackgroundColor({ color: "blue" });

if (!url || !/^https?:\/\//i.test(url)) {
// 特殊页面(chrome:// 等)或拿不到 URL:DNR 规则不会作用于这些页面
chrome.action.setBadgeText({ text: "" });
return;
}

let pageHost = "";
try {
pageHost = new URL(url).hostname;
} catch (e) {
chrome.action.setBadgeText({ text: "" });
return;
}

chrome.storage.local.get(["rules"], (result) => {
if (chrome.runtime.lastError || !result.rules) {
chrome.action.setBadgeText({ text: "" });
return;
}
let data;
try {
data = JSON.parse(result.rules);
} catch (e) {
chrome.action.setBadgeText({ text: "" });
return;
}
const matched = countMatchedRules(data, url, pageHost);
chrome.action.setBadgeText({ text: matched > 0 ? `${matched}` : "" });
});
});
}

function handleStartup() {
setBadge();
updateBadge();
}

function handleInstalled() {
setBadge();
updateBadge();
}

chrome.runtime.onStartup.addListener(handleStartup);
chrome.runtime.onInstalled.addListener(handleInstalled)
chrome.runtime.onInstalled.addListener(handleInstalled);

// 切换标签页 / 页面导航刷新时重算
chrome.tabs.onActivated.addListener(updateBadge);
chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
// 只在导航实际发生时重算,避免 title 等无关更新反复触发
if (changeInfo.url || changeInfo.status === "loading") {
updateBadge();
}
});

// popup 内修改规则后重算
chrome.storage.onChanged.addListener((changes, areaName) => {
if (areaName === "local" && changes.rules) {
updateBadge();
}
});

// popup 打开时窗口焦点变化也会切回对应标签页,补一次刷新以保证准确
chrome.windows.onFocusChanged.addListener((windowId) => {
if (windowId !== chrome.windows.WINDOW_ID_NONE) {
updateBadge();
}
});
70 changes: 61 additions & 9 deletions src/App.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<template>
<div>
<div v-if="error" class="error error--top">
There was an error setting the rules, did you use an invalid header name
{{ t("app.error") }}
</div>
<FolderPanel
v-if="data.folders"
Expand All @@ -17,11 +17,15 @@
</template>

<script setup lang="ts">
import { ref, onMounted, watch } from "vue";
import { ref, provide, onMounted, watch } from "vue";
import type { Ref } from "vue";

import { DataType, FolderType } from "./interfaces";
import { generateRules, isChrome } from "./utils";
import { detectLocale, SUPPORTED_LOCALES } from "./locales";
import type { Locale } from "./locales";
import { LOCALE_KEY } from "./composables";
import { getMessage } from "./locales";

import FolderPanel from "./components/FolderPanel.vue";

Expand Down Expand Up @@ -60,6 +64,56 @@ const data: Ref<DataType> = ref({
const totalActiveRules = ref(0);
const error = ref(false);

/**
* i18n 初始化
* 优先级:chrome.storage > localStorage > 浏览器语言自动检测
*/
const locale: Ref<Locale> = ref(detectLocale());
provide(LOCALE_KEY, locale);

// 在 provide 之后创建 i18n 实例供本组件使用
const { t } = {
t(path: string, params?: Record<string, string | number>) {
return getMessage(locale.value, path, params);
},
};

/**
* 将当前语言偏好持久化到 chrome.storage 或 localStorage
* chrome 环境下失败静默处理,不影响用户体验
*
* @param newLocale - 当前 locale 值
*/
function saveLocale(newLocale: Locale) {
if (isChrome) {
chrome.storage.local.set({ locale: newLocale }).then(() => {
console.log("Chrome storage locale set");
});
} else {
window.localStorage.setItem("locale", newLocale);
}
}

/**
* 从 storage 恢复已保存的语言偏好
* 如果用户之前手动切换过则覆盖浏览器的自动检测结果
* 提升:chrome.storage.local 需要异步读取,非 chrome 环境从 localStorage 读取
*/
async function loadLocale() {
let storedLocale: string | null | undefined;

if (isChrome) {
const chromeData = await chrome.storage.local.get(["locale"]);
storedLocale = chromeData.locale;
} else {
storedLocale = window.localStorage.getItem("locale");
}

if (storedLocale && SUPPORTED_LOCALES.includes(storedLocale as Locale)) {
locale.value = storedLocale as Locale;
}
}

/**
* Event handlers from FolderPanel
*/
Expand Down Expand Up @@ -127,12 +181,6 @@ async function save() {

chrome.storage.local.set({ rules: JSON.stringify(data.value) });
chrome.storage.local.set({ totalActiveRules: `${activeRules.length}` });

// Update badge
chrome.action.setBadgeBackgroundColor({ color: "blue" });
chrome.action.setBadgeText({
text: totalActiveRules.value > 0 ? `${totalActiveRules.value}` : "",
});
} else {
window.localStorage.setItem("rules", JSON.stringify(data.value));
}
Expand Down Expand Up @@ -165,10 +213,14 @@ watch(
{ deep: true }
);

// 监听 locale 变更并持久化
watch(locale, (newLocale) => saveLocale(newLocale));

/**
* Lifecycle
*/
onMounted(() => {
onMounted(async () => {
await loadLocale();
loadData();
});
</script>
9 changes: 6 additions & 3 deletions src/components/Dropdown.vue
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@
</svg>
</button>
<div v-show="isOpen" class="dropdown__menu">
<button v-for="(option, index) in options" :key="index" @click="option.action ? option.action() : set(option.value)" class="dropdown__menu__item" :class="{'dropdown__menu__item--active' : model === option.value}">
<button v-for="(option, index) in options" :key="index" @click="option.action ? option.action() : set(option.value)"
class="dropdown__menu__item" :class="{'dropdown__menu__item--active' : model === option.value}"
:title="option.tooltip || ''">
{{ option.label }}
</button>
</div>
Expand All @@ -26,7 +28,8 @@ const { isOpen, toggle, close } = useOutsideClick(root);
interface Option {
label: string,
value: string | boolean,
action?: Function
action?: Function,
tooltip?: string,
}

const props = defineProps({
Expand All @@ -43,7 +46,7 @@ const defaultOption = computed(() => {
if(typeof model.value !== "undefined" && model.value !== "") {
return options.value.find(option => option.value === model.value)
}

return options.value[0]
})

Expand Down
Loading